package api import ( "compress/flate" "context" "errors" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "strings" "time" "unicode" "github.com/google/uuid" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/labstack/echo/v4" "github.com/mholt/archiver/v3" "go.uber.org/zap" "golang.org/x/text/runes" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" ) var ( // ErrContextAlreadyClosed happens when the context has been canceled. ErrContextAlreadyClosed = errors.New("context already closed") // ErrOutOfBoundsOutputPath happens when an output path is not within // context's working directory. It enforces having all the files in the // same directory. ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory") ) // Context is the request context for a "multipart/form-data" requests. type Context struct { dirPath string values map[string][]string files map[string]string outputPaths []string cancelled bool logger *zap.Logger echoCtx echo.Context context.Context } // newContext returns a Context by parsing a "multipart/form-data" request. func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration) (*Context, context.CancelFunc, error) { processCtx, processCancel := context.WithTimeout(context.Background(), timeout) ctx := &Context{ outputPaths: make([]string, 0), cancelled: false, logger: logger, echoCtx: echoCtx, Context: processCtx, } // A custom cancel function which removes the context's working directory // when called. cancel := func() context.CancelFunc { return func() { if ctx.cancelled { return } processCancel() if ctx.dirPath == "" { return } err := os.RemoveAll(ctx.dirPath) if err != nil { ctx.logger.Error(fmt.Sprintf("remove context's working directory: %s", err)) return } ctx.logger.Debug(fmt.Sprintf("'%s' removed", ctx.dirPath)) ctx.cancelled = true } }() form, err := echoCtx.MultipartForm() if err != nil { if errors.Is(err, http.ErrNotMultipart) { return nil, cancel, WrapError( fmt.Errorf("get multipart form: %w", err), NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: want 'multipart/form-data'"), ) } if errors.Is(err, http.ErrMissingBoundary) { return nil, cancel, WrapError( fmt.Errorf("get multipart form: %w", err), NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: no boundary"), ) } if strings.Contains(err.Error(), io.EOF.Error()) { return nil, cancel, WrapError( fmt.Errorf("get multipart form: %w", err), NewSentinelHTTPError(http.StatusBadRequest, "Malformed body: it does not match the 'Content-Type' header boundaries"), ) } return nil, cancel, fmt.Errorf("get multipart form: %w", err) } dirPath, err := gotenberg.MkdirAll() if err != nil { return nil, cancel, fmt.Errorf("create working directory: %w", err) } ctx.dirPath = dirPath ctx.values = form.Value ctx.files = make(map[string]string) copyToDisk := func(fh *multipart.FileHeader) error { // Avoid directory traversal and normalize filename. // See https://github.com/thecodingmachine/gotenberg/issues/104. // See https://github.com/thecodingmachine/gotenberg/issues/228. t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) filename, _, err := transform.String(t, strings.ToLower(filepath.Base(fh.Filename))) if err != nil { return fmt.Errorf("transform filename: %w", err) } in, err := fh.Open() if err != nil { return fmt.Errorf("open multipart file: %w", err) } defer func() { err := in.Close() if err != nil { logger.Error(fmt.Sprintf("close file header: %s", err)) } }() path := fmt.Sprintf("%s/%s", ctx.dirPath, filename) out, err := os.Create(path) if err != nil { return fmt.Errorf("create local file: %w", err) } defer func() { err := out.Close() if err != nil { logger.Error(fmt.Sprintf("close local file: %s", err)) } }() _, err = io.Copy(out, in) if err != nil { return fmt.Errorf("copy multipart file to local file: %w", err) } ctx.files[filename] = path return nil } for _, files := range form.File { for _, fh := range files { err = copyToDisk(fh) if err != nil { return ctx, cancel, fmt.Errorf("copy to disk: %w", err) } } } ctx.Log().Debug(fmt.Sprintf("form data values: %+v", ctx.values)) ctx.Log().Debug(fmt.Sprintf("form data files: %+v", ctx.files)) return ctx, cancel, err } // Request returns the http.Request. func (ctx Context) Request() *http.Request { return ctx.echoCtx.Request() } // FormData return a FormData. func (ctx Context) FormData() *FormData { return &FormData{ values: ctx.values, files: ctx.files, errors: nil, } } // GeneratePath generates a path within the context's working directory. It // does not create a file. func (ctx Context) GeneratePath(extension string) string { return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension) } // AddOutputPaths adds the given paths. Those paths will be used later to build // the output file. func (ctx *Context) AddOutputPaths(paths ...string) error { if ctx.cancelled { return ErrContextAlreadyClosed } for _, path := range paths { if !strings.HasPrefix(path, ctx.dirPath) { return ErrOutOfBoundsOutputPath } ctx.outputPaths = append(ctx.outputPaths, path) } return nil } // Log returns the context zap.Logger. func (ctx Context) Log() *zap.Logger { return ctx.logger } // buildOutputFile builds the output file according to the output paths // registered in the context. If many output paths, an archive is created. func (ctx Context) buildOutputFile() (string, error) { if ctx.cancelled { return "", ErrContextAlreadyClosed } if len(ctx.outputPaths) == 0 { return "", errors.New("no output path") } if len(ctx.outputPaths) == 1 { ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0])) return ctx.outputPaths[0], nil } z := archiver.Zip{ CompressionLevel: flate.DefaultCompression, MkdirAll: true, SelectiveCompression: true, ContinueOnError: false, OverwriteExisting: false, ImplicitTopLevelFolder: false, } archivePath := ctx.GeneratePath(".zip") err := z.Archive(ctx.outputPaths, archivePath) if err != nil { return "", fmt.Errorf("archive output files: %w", err) } ctx.logger.Debug(fmt.Sprintf("archive '%s' created", archivePath)) return archivePath, nil } // MockContext is a helper for tests. // // ctx := &api.MockContext{Context: &api.Context{}} type MockContext struct { *Context } // SetDirPath sets the context's working directory path. // // ctx := &api.MockContext{Context: &api.Context{}} // ctx.SetDirPath("/foo") func (ctx *MockContext) SetDirPath(path string) { ctx.dirPath = path } // SetValues sets the values. // // ctx := &api.MockContext{Context: &api.Context{}} // ctx.SetValues(map[string][]string{ // "url": { // "foo", // }, // }) func (ctx *MockContext) SetValues(values map[string][]string) { ctx.values = values } // SetFiles sets the files. // // ctx := &api.MockContext{Context: &api.Context{}} // ctx.SetFiles(map[string]string{ // "foo": "/foo", // }) func (ctx *MockContext) SetFiles(files map[string]string) { ctx.files = files } // SetCancelled sets if the context is cancelled or not. // // ctx := &api.MockContext{Context: &api.Context{}} // ctx.SetCancelled(true) func (ctx *MockContext) SetCancelled(cancelled bool) { ctx.cancelled = cancelled } // OutputPaths returns the registered output paths. // ctx := &api.MockContext{Context: &api.Context{}} // outputPaths := ctx.OutputPaths() func (ctx MockContext) OutputPaths() []string { return ctx.outputPaths }