diff --git a/app/app.go b/app/app.go new file mode 100644 index 00000000..749b3c88 --- /dev/null +++ b/app/app.go @@ -0,0 +1,59 @@ +package app + +import ( + "fmt" + "net/http" + "time" + + "github.com/gulien/gotenberg/app/config" + "github.com/gulien/gotenberg/app/handlers" + "github.com/gulien/gotenberg/app/handlers/converter/process" + "github.com/gulien/gotenberg/app/logger" + + "github.com/gorilla/mux" +) + +type App struct { + version string + config *config.AppConfig + Server *http.Server +} + +func NewApp(version string) (*App, error) { + c, err := config.NewAppConfig() + if err != nil { + logger.Error(err) + return nil, &appConfigError{} + } + + a := &App{} + a.version = version + a.config = c + + // defines our application logging. + logger.SetLevel(a.config.Logs.Level) + logger.SetFormatter(a.config.Logs.Formatter) + + // defines our application router. + r := mux.NewRouter() + r.Handle("/", handlers.GetHandlersChain()) + + a.Server = &http.Server{ + Addr: fmt.Sprintf(":%s", a.config.Port), + // good practice to set timeouts to avoid Slowloris attacks. + WriteTimeout: time.Second * 15, + ReadTimeout: time.Second * 15, + IdleTimeout: time.Second * 60, + Handler: r, + } + + return a, nil +} + +func (a *App) Run() error { + process.Load(a.config.CommandsConfig) + logger.Infof("Gotenberg %s", a.version) + logger.Infof("Application is starting on %s", a.Server.Addr) + + return a.Server.ListenAndServe() +} diff --git a/app/config/config.go b/app/config/config.go new file mode 100644 index 00000000..14d7a479 --- /dev/null +++ b/app/config/config.go @@ -0,0 +1,170 @@ +package config + +import ( + "io/ioutil" + "text/template" + + "github.com/gulien/gotenberg/app/logger" + + "github.com/satori/go.uuid" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +type ( + AppConfig struct { + Port string + Logs struct { + Level logrus.Level + Formatter logrus.Formatter + } + CommandsConfig *CommandsConfig + } + + CommandsConfig struct { + HTML *CommandConfig + Office *CommandConfig + Merge *CommandConfig + } + + CommandConfig struct { + Timeout int + Template *template.Template + } +) + +func NewAppConfig() (*AppConfig, error) { + fileConfig, err := loadFileConfig() + if err != nil { + logger.Error(err) + return nil, &fileConfigError{} + } + + c := &AppConfig{} + c.Port = fileConfig.Port + c.Logs.Level = getLoggingLevelFromFileConfig(fileConfig) + c.Logs.Formatter = getLoggingFormatterFromFileConfig(fileConfig) + + if c.Logs.Level == 999 { + return nil, &wrongLoggingLevelError{} + } + + if c.Logs.Formatter == nil { + return nil, &wrongLoggingFormatError{} + } + + c.CommandsConfig = &CommandsConfig{} + c.CommandsConfig.HTML = &CommandConfig{} + c.CommandsConfig.Office = &CommandConfig{} + c.CommandsConfig.Merge = &CommandConfig{} + c.CommandsConfig.HTML.Timeout = fileConfig.Commands.HTML.Timeout + c.CommandsConfig.Office.Timeout = fileConfig.Commands.Office.Timeout + c.CommandsConfig.Merge.Timeout = fileConfig.Commands.Merge.Timeout + + tmplHTML, err := getCommandTemplate(fileConfig.Commands.HTML.Template) + if err != nil { + logger.Error(err) + return nil, &wrongHTMLCommandTemplate{} + } + + tmplOffice, err := getCommandTemplate(fileConfig.Commands.Office.Template) + if err != nil { + logger.Error(err) + return nil, &wrongOfficeCommandTemplate{} + } + + tmplMerge, err := getCommandTemplate(fileConfig.Commands.Merge.Template) + if err != nil { + logger.Error(err) + return nil, &wrongMergeCommandTemplate{} + } + + c.CommandsConfig.HTML.Template = tmplHTML + c.CommandsConfig.Office.Template = tmplOffice + c.CommandsConfig.Merge.Template = tmplMerge + + return c, nil +} + +type fileConfig struct { + Port string `yaml:"port"` + Logs struct { + Level string `yaml:"level"` + Format string `yaml:"format"` + } `yaml:"logs"` + Commands struct { + HTML struct { + Timeout int + Template string + } `yaml:"html"` + Office struct { + Timeout int `yaml:"timeout"` + Template string `yaml:"template"` + } `yaml:"office"` + Merge struct { + Timeout int `yaml:"timeout"` + Template string `yaml:"template"` + } `yaml:"merge"` + } `yaml:"commands"` +} + +// configurationFilePath is our default configuration file to parse. +const configurationFilePath = "gotenberg.yml" + +func loadFileConfig() (*fileConfig, error) { + c := &fileConfig{} + + data, err := ioutil.ReadFile(configurationFilePath) + if err != nil { + logger.Error(err) + return nil, &readFileError{} + } + + if err := yaml.Unmarshal(data, &c); err != nil { + logger.Error(err) + return nil, &unmarshalError{} + } + + return c, nil +} + +var levels = map[string]logrus.Level{ + "DEBUG": logrus.DebugLevel, + "INFO": logrus.InfoLevel, + "WARN": logrus.WarnLevel, + "ERROR": logrus.ErrorLevel, + "FATAL": logrus.FatalLevel, + "PANIC": logrus.PanicLevel, +} + +func getLoggingLevelFromFileConfig(c *fileConfig) logrus.Level { + l, ok := levels[c.Logs.Level] + if !ok { + return 999 + } + + return l +} + +var formatters = map[string]logrus.Formatter{ + "text": &logrus.TextFormatter{}, + "json": &logrus.JSONFormatter{}, +} + +func getLoggingFormatterFromFileConfig(c *fileConfig) logrus.Formatter { + f, ok := formatters[c.Logs.Format] + if !ok { + return nil + } + + return f +} + +func getCommandTemplate(command string) (*template.Template, error) { + t, err := template.New(uuid.NewV4().String()).Parse(command) + if err != nil { + return nil, err + } + + return t, nil +} diff --git a/app/config/errors.go b/app/config/errors.go new file mode 100644 index 00000000..93e986de --- /dev/null +++ b/app/config/errors.go @@ -0,0 +1,49 @@ +package config + +type fileConfigError struct{} + +func (e *fileConfigError) Error() string { + return "An error occured while trying to load the configuration file" +} + +type wrongLoggingLevelError struct{} + +func (e *wrongLoggingLevelError) Error() string { + return "Accepted values for logging level: DEBUG, INFO, WARN, ERROR, FATAL, PANIC" +} + +type wrongLoggingFormatError struct{} + +func (e *wrongLoggingFormatError) Error() string { + return "Accepted value for logging format: text, json" +} + +type wrongHTMLCommandTemplate struct{} + +func (e *wrongHTMLCommandTemplate) Error() string { + return "An error occured while trying to parse the HTML command's template" +} + +type wrongOfficeCommandTemplate struct{} + +func (e *wrongOfficeCommandTemplate) Error() string { + return "An error occured while trying to parse the Office command's template" +} + +type wrongMergeCommandTemplate struct{} + +func (e *wrongMergeCommandTemplate) Error() string { + return "An error occured while trying to parse the merge command's template" +} + +type readFileError struct{} + +func (e *readFileError) Error() string { + return "An error occured while trying to read the configuration file" +} + +type unmarshalError struct{} + +func (e *unmarshalError) Error() string { + return "An error occured while trying to decode the configuration file as YAML" +} diff --git a/app/errors.go b/app/errors.go new file mode 100644 index 00000000..56b95e44 --- /dev/null +++ b/app/errors.go @@ -0,0 +1,7 @@ +package app + +type appConfigError struct{} + +func (e *appConfigError) Error() string { + return "A fatal error occured while setting up the application" +} diff --git a/app/handlers/context/context.go b/app/handlers/context/context.go new file mode 100644 index 00000000..d880bac9 --- /dev/null +++ b/app/handlers/context/context.go @@ -0,0 +1,51 @@ +package context + +import ( + "context" + "net/http" + + "github.com/gulien/gotenberg/app/handlers/converter" + + ghttp "github.com/gulien/gotenberg/app/handlers/http" +) + +type key uint32 + +const ( + contentTypeKey key = iota + converterKey +) + +func WithContentType(r *http.Request, contentType ghttp.ContentType) *http.Request { + ctx := r.Context() + ctx = context.WithValue(ctx, contentTypeKey, contentType) + r = r.WithContext(ctx) + + return r +} + +func GetContentType(r *http.Request) (ghttp.ContentType, error) { + ct, ok := r.Context().Value(contentTypeKey).(ghttp.ContentType) + if !ok { + return "", &contentTypeNotFoundError{} + } + + return ct, nil +} + +func WithConverter(r *http.Request, converter *converter.Converter) *http.Request { + ctx := r.Context() + ctx = context.WithValue(ctx, converterKey, converter) + r = r.WithContext(ctx) + + return r +} + +func GetConverter(r *http.Request) (*converter.Converter, error) { + c, ok := r.Context().Value(converterKey).(*converter.Converter) + if !ok { + return nil, &converterNotFoundError{} + } + + return c, nil +} diff --git a/app/handlers/context/errors.go b/app/handlers/context/errors.go new file mode 100644 index 00000000..d0cf844a --- /dev/null +++ b/app/handlers/context/errors.go @@ -0,0 +1,13 @@ +package context + +type contentTypeNotFoundError struct{} + +func (e *contentTypeNotFoundError) Error() string { + return "The 'Content-Type' was not found in request context" +} + +type converterNotFoundError struct{} + +func (e *converterNotFoundError) Error() string { + return "The converter was not found in request context" +} diff --git a/app/handlers/converter/converter.go b/app/handlers/converter/converter.go new file mode 100644 index 00000000..762b6fe7 --- /dev/null +++ b/app/handlers/converter/converter.go @@ -0,0 +1,118 @@ +package converter + +import ( + "net/http" + "os" + + gfile "github.com/gulien/gotenberg/app/handlers/converter/file" + "github.com/gulien/gotenberg/app/handlers/converter/process" + ghttp "github.com/gulien/gotenberg/app/handlers/http" +) + +type Converter struct { + files []*gfile.File + resultFilesPaths []string + FinalFilePath string +} + +func NewConverter(r *http.Request, contentType ghttp.ContentType) (*Converter, error) { + c := &Converter{} + + switch contentType { + case ghttp.MultipartFormDataContentType: + err := r.ParseMultipartForm(32 << 20) + if err != nil { + return nil, err + } + + formData := r.MultipartForm + files, ok := formData.File["files"] + if !ok { + // TODO + return nil, nil + } + + for i := range files { + file, err := files[i].Open() + if err != nil { + return nil, err + } + + defer file.Close() + + f, err := gfile.NewFile(file) + if err != nil { + // todo err + return nil, err + } + + c.files = append(c.files, f) + } + break + default: + f, err := gfile.NewFile(r.Body) + if err != nil { + // todo err + return nil, err + } + + c.files = append(c.files, f) + } + + return c, nil +} + +func (c *Converter) Convert() error { + if len(c.files) == 0 { + return &noFileToConvertError{} + } + + var filesPaths []string + for _, f := range c.files { + if f.Type != gfile.PDFType { + path, err := process.ExecConversion(f) + if err != nil { + return err + } + + filesPaths = append(filesPaths, path) + } else { + filesPaths = append(filesPaths, f.Path) + } + } + + path, err := process.ExecMerge(filesPaths) + if err != nil { + return err + } + + c.resultFilesPaths = filesPaths + c.FinalFilePath = path + + return nil +} + +func (c *Converter) Clear() error { + for _, f := range c.files { + err := os.Remove(f.Path) + if err != nil { + return err + } + } + + for _, path := range c.resultFilesPaths { + err := os.Remove(path) + if err != nil { + return err + } + } + + if c.FinalFilePath != "" { + err := os.Remove(c.FinalFilePath) + if err != nil { + return err + } + } + + return nil +} diff --git a/app/handlers/converter/errors.go b/app/handlers/converter/errors.go new file mode 100644 index 00000000..5d4046e4 --- /dev/null +++ b/app/handlers/converter/errors.go @@ -0,0 +1,7 @@ +package converter + +type noFileToConvertError struct{} + +func (e *noFileToConvertError) Error() string { + return "There is no file to convert" +} diff --git a/app/handlers/converter/file/file.go b/app/handlers/converter/file/file.go new file mode 100644 index 00000000..93347e02 --- /dev/null +++ b/app/handlers/converter/file/file.go @@ -0,0 +1,120 @@ +package file + +import ( + "fmt" + "io" + "os" + + ghttp "github.com/gulien/gotenberg/app/handlers/http" + + "github.com/satori/go.uuid" +) + +type ( + File struct { + Type FileType + Path string + } + + FileType string + + FileExt string +) + +const ( + PDFType FileType = "PDF" + HTMLType FileType = "HTML" + OfficeType FileType = "Office" + + PDFExt FileExt = ".pdf" + HTMLExt FileExt = ".html" + OfficeExt FileExt = "" +) + +func NewFile(r io.Reader) (*File, error) { + f := &File{ + Path: MakeFilePath(), + } + + file, err := os.Create(f.Path) + if err != nil { + return nil, err + } + + defer file.Close() + + _, err = io.Copy(file, r) + if err != nil { + return nil, err + } + + // resets the read pointer. + file.Seek(0, 0) + + t, err := findFileType(file) + if err != nil { + return nil, err + } + + f.Type = t + + f, err = reworkFilePath(f) + if err != nil { + return nil, err + } + + return f, nil +} + +func MakeFilePath() string { + return fmt.Sprintf("./%s", uuid.NewV4().String()) +} + +var filesTypes = map[ghttp.ContentType]FileType{ + ghttp.PDFContentType: PDFType, + ghttp.HTMLContentType: HTMLType, + ghttp.OctetStreamContentType: OfficeType, + ghttp.ZipContentType: OfficeType, +} + +func findFileType(f *os.File) (FileType, error) { + ct, err := ghttp.SniffContentType(f) + if err != nil { + return "", err + } + + t, ok := filesTypes[ct] + if !ok { + // TODO error + return "", nil + } + + return t, nil +} + +var filesExtensions = map[FileType]FileExt{ + PDFType: PDFExt, + HTMLType: HTMLExt, + OfficeType: OfficeExt, +} + +func reworkFilePath(f *File) (*File, error) { + ext, ok := filesExtensions[f.Type] + if !ok { + // TODO error + return nil, nil + } + + if ext != OfficeExt { + newPath := fmt.Sprintf("./%s%s", MakeFilePath(), ext) + + err := os.Rename(f.Path, newPath) + if err != nil { + return nil, err + } + + f.Path = newPath + } + + return f, nil +} diff --git a/app/handlers/converter/process/errors.go b/app/handlers/converter/process/errors.go new file mode 100644 index 00000000..9b8179a7 --- /dev/null +++ b/app/handlers/converter/process/errors.go @@ -0,0 +1,13 @@ +package process + +type impossibleConversionError struct{} + +func (e *impossibleConversionError) Error() string { + return "Impossible conversion" +} + +type commandTimeoutError struct{} + +func (e *commandTimeoutError) Error() string { + return "The command has reached timeout" +} diff --git a/app/handlers/converter/process/process.go b/app/handlers/converter/process/process.go new file mode 100644 index 00000000..e74627fb --- /dev/null +++ b/app/handlers/converter/process/process.go @@ -0,0 +1,119 @@ +package process + +import ( + "bytes" + "fmt" + "os/exec" + "text/template" + "time" + + "github.com/gulien/gotenberg/app/config" + gfile "github.com/gulien/gotenberg/app/handlers/converter/file" +) + +var commandsConfig *config.CommandsConfig + +func Load(config *config.CommandsConfig) { + commandsConfig = config +} + +func Reset() { + commandsConfig = nil +} + +type conversionData struct { + FilePath string + ResultFilePath string +} + +func ExecConversion(file *gfile.File) (string, error) { + cmdData := &conversionData{ + FilePath: file.Path, + ResultFilePath: fmt.Sprintf("%s%s", gfile.MakeFilePath(), gfile.PDFExt), + } + + var ( + cmdTemplate *template.Template + cmdTimeout int + ) + + switch file.Type { + case gfile.HTMLType: + cmdTemplate = commandsConfig.HTML.Template + cmdTimeout = commandsConfig.HTML.Timeout + break + case gfile.OfficeType: + cmdTemplate = commandsConfig.Office.Template + cmdTimeout = commandsConfig.Office.Timeout + break + default: + return "", &impossibleConversionError{} + } + + var data bytes.Buffer + if err := cmdTemplate.Execute(&data, cmdData); err != nil { + return "", err + } + + err := execCommand(data.String(), cmdTimeout) + if err != nil { + return "", err + } + + return cmdData.ResultFilePath, nil +} + +type mergeData struct { + FilesPaths []string + ResultFilePath string +} + +func ExecMerge(filesPaths []string) (string, error) { + cmdData := &mergeData{ + FilesPaths: filesPaths, + ResultFilePath: fmt.Sprintf("%s%s", gfile.MakeFilePath(), gfile.PDFExt), + } + + cmdTemplate := commandsConfig.Merge.Template + cmdTimeout := commandsConfig.Merge.Timeout + + var data bytes.Buffer + if err := cmdTemplate.Execute(&data, cmdData); err != nil { + return "", err + } + + err := execCommand(data.String(), cmdTimeout) + if err != nil { + return "", err + } + + return cmdData.ResultFilePath, nil +} + +func execCommand(command string, timeout int) error { + // Wait for the process to finish or kill it after a timeout. + cmd := exec.Command("/bin/sh", "-c", command) + if err := cmd.Start(); err != nil { + return err + } + + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + select { + case <-time.After(time.Duration(timeout) * time.Second): + if err := cmd.Process.Kill(); err != nil { + return err + } + + return &commandTimeoutError{} + case err := <-done: + if err != nil { + return err + } + + return nil + } +} diff --git a/app/handlers/errors.go b/app/handlers/errors.go new file mode 100644 index 00000000..852aa599 --- /dev/null +++ b/app/handlers/errors.go @@ -0,0 +1,7 @@ +package handlers + +type requestHasNoContentError struct{} + +func (e *requestHasNoContentError) Error() string { + return "Request has not content" +} diff --git a/app/handlers/handlers.go b/app/handlers/handlers.go new file mode 100644 index 00000000..1178bf1f --- /dev/null +++ b/app/handlers/handlers.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/gulien/gotenberg/app/handlers/context" + "github.com/gulien/gotenberg/app/handlers/converter" + ghttp "github.com/gulien/gotenberg/app/handlers/http" + "github.com/gulien/gotenberg/app/logger" + + "github.com/justinas/alice" +) + +func GetHandlersChain() http.Handler { + return alice.New(enforceContentLengthHandler, enforceContentTypeHandler, convertHandler, serveHandler).ThenFunc(clearHandler) +} + +// enforeContentLengthHandler checks if the request has content. +func enforceContentLengthHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength == 0 { + e := &requestHasNoContentError{} + http.Error(w, e.Error(), http.StatusBadRequest) + logger.Error(e) + return + } + + next.ServeHTTP(w, r) + }) +} + +// enforceContentTypeHandler checks if the "Content-Type" entry +// from the request's header matches one of the allowed content types. +func enforceContentTypeHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ct, err := ghttp.FindAuthorizedContentType(r.Header) + if err != nil { + http.Error(w, err.Error(), http.StatusUnsupportedMediaType) + logger.Error(err) + return + } + + r = context.WithContentType(r, ct) + + next.ServeHTTP(w, r) + }) +} + +// convertHandler is in charge of converting the file(s) from the request to PDF. +func convertHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ct, err := context.GetContentType(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + logger.Error(err) + return + } + + c, err := converter.NewConverter(r, ct) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + logger.Error(err) + return + } + + err = c.Convert() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + logger.Error(err) + return + } + + r = context.WithConverter(r, c) + + next.ServeHTTP(w, r) + }) +} + +// serveHandler simply serves the created PDF. +func serveHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := context.GetConverter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + logger.Error(err) + } + + reader, err := os.Open(c.FinalFilePath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + logger.Error(err) + return + } + + defer reader.Close() + + resultFileInfo, err := reader.Stat() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + logger.Error(err) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", resultFileInfo.Name())) + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Length", fmt.Sprintf("%d", resultFileInfo.Size())) + io.Copy(w, reader) + + next.ServeHTTP(w, r) + }) +} + +// clearHandler removes all files created during the conversion. +func clearHandler(w http.ResponseWriter, r *http.Request) { + c, err := context.GetConverter(r) + if err != nil { + logger.Warn(err.Error()) + } + + if err := c.Clear(); err != nil { + logger.Warn(err.Error()) + } +} diff --git a/app/handlers/http/errors.go b/app/handlers/http/errors.go new file mode 100644 index 00000000..71096d74 --- /dev/null +++ b/app/handlers/http/errors.go @@ -0,0 +1,15 @@ +package http + +import "fmt" + +type notAuthorizedContentTypeError struct{} + +func (e *notAuthorizedContentTypeError) Error() string { + return fmt.Sprintf("Accepted values for 'Content-Type': %s, %s, %s, %s", PDFContentType, HTMLContentType, OctetStreamContentType, MultipartFormDataContentType) +} + +type notAuthorizedFileContentTypeError struct{} + +func (e *notAuthorizedFileContentTypeError) Error() string { + return fmt.Sprintf("Unable to detect a file 'Content-Type'") +} diff --git a/app/handlers/http/http.go b/app/handlers/http/http.go new file mode 100644 index 00000000..cd8fef5a --- /dev/null +++ b/app/handlers/http/http.go @@ -0,0 +1,60 @@ +package http + +import ( + "net/http" + "os" + "strings" +) + +type ContentType string + +const ( + PDFContentType ContentType = "application/pdf" + HTMLContentType ContentType = "text/html" + OctetStreamContentType ContentType = "application/octet-stream" + ZipContentType ContentType = "application/zip" + MultipartFormDataContentType ContentType = "multipart/form-data" +) + +func FindAuthorizedContentType(h http.Header) (ContentType, error) { + ct := findContentType(h.Get("Content-Type"), HTMLContentType, OctetStreamContentType, MultipartFormDataContentType) + if ct == "" { + return "", ¬AuthorizedContentTypeError{} + } + + return ct, nil +} + +func SniffContentType(f *os.File) (ContentType, error) { + // only the first 512 bytes are used to sniff the content type. + buffer := make([]byte, 512) + n, err := f.Read(buffer) + if err != nil { + return "", err + } + + // resets the read pointer. + f.Seek(0, 0) + + // using n if size of buffer < 512 bytes. + ct := findContentType(http.DetectContentType(buffer[:n]), PDFContentType, HTMLContentType, OctetStreamContentType, ZipContentType) + if ct == "" { + return "", ¬AuthorizedFileContentTypeError{} + } + + return ct, nil +} + +func findContentType(contentType string, contentTypes ...ContentType) ContentType { + for _, ct := range contentTypes { + if i := strings.IndexRune(contentType, ';'); i != -1 { + contentType = contentType[0:i] + } + + if contentType == string(ct) { + return ct + } + } + + return "" +} diff --git a/app/logger/logger.go b/app/logger/logger.go new file mode 100644 index 00000000..fbf04db8 --- /dev/null +++ b/app/logger/logger.go @@ -0,0 +1,65 @@ +// Package logger +package logger + +import ( + "os" + + "github.com/sirupsen/logrus" +) + +type logger struct { + logger *logrus.Logger +} + +var log *logger = newLogger() + +func newLogger() *logger { + l := &logger{ + logger: logrus.New(), + } + + l.logger.Out = os.Stdout + l.logger.Level = logrus.InfoLevel + + return l +} + +func SetLevel(level logrus.Level) { + log.logger.SetLevel(level) +} + +func SetFormatter(formatter logrus.Formatter) { + log.logger.Formatter = formatter +} + +func Debug(message string) { + log.logger.Debug(message) +} + +func Debugf(format string, args ...interface{}) { + log.logger.Debugf(format, args) +} + +func Info(message string) { + log.logger.Info(message) +} + +func Infof(format string, args ...interface{}) { + log.logger.Infof(format, args) +} + +func Warn(message string) { + log.logger.Warn(message) +} + +func Error(err error) { + log.logger.Error(err.Error()) +} + +func Fatal(err error) { + log.logger.Fatal(err.Error()) +} + +func Panic(err error) { + log.logger.Panic(err.Error()) +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index c1e4bd94..00000000 --- a/config/config.go +++ /dev/null @@ -1,78 +0,0 @@ -// Package config implements a solution for parsing a configuration file ("gotenberg.yml") -// and populating an instance of Config which will be used accross the application. -package config - -import ( - "fmt" - "io/ioutil" - "text/template" - - "gopkg.in/yaml.v2" -) - -// Config represents the data provided by a YAML file. -type Config struct { - // Port the port the application will listen to. - Port string `yaml:"port"` - // LogLevel the log level used by the logger of the application. - LogLevel string `yaml:"logLevel"` - // Commands the commands' templates from the configuration file. - Commands struct { - // HTMLtoPDF the command's template to convert an HTML file to a PDF file. - HTMLtoPDF string `yaml:"HTMLtoPDF"` - // WordToPDF the command's template to convert a Word file to a PDF file. - WordToPDF string `yaml:"WordToPDF"` - // MergePDF the command's template to merge many PDF files into one final PDF file. - MergePDF string `yaml:"MergePDF"` - } `yaml:"commands"` - // Templates gathers all instances of Template which will be created from previous - // Commands block. - Templates struct { - // HTMLtoPDF the instance of template created from Commands.HTMLtoPDF. - HTMLtoPDF *template.Template - // WordToPDF the instance of template created from Commands.HTMLtoPDF. - WordToPDF *template.Template - // MergePDF the instance of template created from Commands.HTMLtoPDF. - MergePDF *template.Template - } -} - -// AppConfig is the configuration instance used accross the application. -var AppConfig *Config - -// configurationFilePath is our default configuration file to parse. -const configurationFilePath = "gotenberg.yml" - -// MakeConfig instantiates our configuration by parsing a YAML file. -func MakeConfig() error { - AppConfig = &Config{} - - data, err := ioutil.ReadFile(configurationFilePath) - if err != nil { - return err - } - - if err := yaml.Unmarshal(data, &AppConfig); err != nil { - return err - } - - tmpl, err := template.New("HTMLToPDF").Parse(AppConfig.Commands.HTMLtoPDF) - if err != nil { - return fmt.Errorf("Unable to parse the HTML to PDF command: %s", err) - } - AppConfig.Templates.HTMLtoPDF = tmpl - - tmpl, err = template.New("WordToPDF").Parse(AppConfig.Commands.WordToPDF) - if err != nil { - return fmt.Errorf("Unable to parse the Word to PDF command: %s", err) - } - AppConfig.Templates.WordToPDF = tmpl - - tmpl, err = template.New("MergePDF").Parse(AppConfig.Commands.MergePDF) - if err != nil { - return fmt.Errorf("Unable to parse the merge PDF command: %s", err) - } - AppConfig.Templates.MergePDF = tmpl - - return nil -} diff --git a/context/context.go b/context/context.go deleted file mode 100644 index f1d8dce5..00000000 --- a/context/context.go +++ /dev/null @@ -1,93 +0,0 @@ -// Package context implements a solution for accessing and setting a request's context values. -package context - -import ( - "context" - - "github.com/gulien/gotenberg/converters" - "github.com/gulien/gotenberg/logger" -) - -// transactionIDCtxKeyType is a basic type for transactionIDCtxKey. -type transactionIDCtxKeyType string - -// transactionIDCtxKey is the transactionID accessing key. -const transactionIDCtxKey transactionIDCtxKeyType = "transactionID" - -// WithTransactionID populates a context ctx with a transaction ID v. -func WithTransactionID(ctx context.Context, v string) context.Context { - return context.WithValue(ctx, transactionIDCtxKey, v) -} - -// GetTransactionID returns the transaction ID from the context ctx. -func GetTransactionID(ctx context.Context) string { - v, ok := ctx.Value(transactionIDCtxKey).(string) - if !ok { - logger.Log.Warn("Unable to retrieve the transaction ID from request context") - return "" - } - return v -} - -// contentTypeCtxKeyType is a basic type for contentTypeCtxKey. -type contentTypeCtxKeyType string - -// contentTypeCtxKey is the contentType accessing key. -const contentTypeCtxKey contentTypeCtxKeyType = "contentType" - -// WithContentType populates a context ctx with a content type v. -func WithContentType(ctx context.Context, v string) context.Context { - return context.WithValue(ctx, contentTypeCtxKey, v) -} - -// GetContentType returns the content type from the context ctx. -func GetContentType(ctx context.Context) string { - v, ok := ctx.Value(contentTypeCtxKey).(string) - if !ok { - logger.Log.Error("Unable to retrieve the content type from request context") - return "" - } - return v -} - -// resultFilePathCtxKeyType is a basic type for resultFilePathCtxKey. -type resultFilePathCtxKeyType string - -// resultFilePathCtxKey is the resultFilePath accessing key. -const resultFilePathCtxKey resultFilePathCtxKeyType = "resultFilePath" - -// WithResultFilePath populates a context ctx with a result file path v. -func WithResultFilePath(ctx context.Context, v string) context.Context { - return context.WithValue(ctx, resultFilePathCtxKey, v) -} - -// GetResultFilePath returns the result file path from the context ctx. -func GetResultFilePath(ctx context.Context) string { - v, ok := ctx.Value(resultFilePathCtxKey).(string) - if !ok { - logger.Log.Error("Unable to retrieve the result file path from request context") - return "" - } - return v -} - -// converterCtxKeyType is a basic type for converterCtxKey. -type converterCtxKeyType string - -// converterCtxKey is the converter accessing key. -const converterCtxKey contentTypeCtxKeyType = "converter" - -// WithConverter populates a context ctx with a converter v. -func WithConverter(ctx context.Context, v converters.Converter) context.Context { - return context.WithValue(ctx, converterCtxKey, v) -} - -// GetConverter returns the converter from the context ctx. -func GetConverter(ctx context.Context) converters.Converter { - v, ok := ctx.Value(converterCtxKey).(converters.Converter) - if !ok { - logger.Log.Warn("Unable to retrieve the converter from request context") - return nil - } - return v -} diff --git a/converters/converters.go b/converters/converters.go deleted file mode 100644 index 90e60504..00000000 --- a/converters/converters.go +++ /dev/null @@ -1,273 +0,0 @@ -// Package converters implements a solution for reading files from a request -// and converting them to PDF. -package converters - -import ( - "bytes" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "text/template" - - "github.com/gulien/gotenberg/config" - "github.com/gulien/gotenberg/helpers" - - "github.com/satori/go.uuid" -) - -type ( - // Converter is the interface used in our convertHandler middleware. - // Indeed, we don't want to know in this middleware which converter is actually used. - Converter interface { - // Convert should returns the file path of the PDF created by the converter. - Convert() (string, error) - // Clear should removes all files used by the converter. - Clear() error - } - - // DirectConverter allows us to convert a file to PDF. - DirectConverter struct { - // contentType is the content type of the file to convert. - contentType string - // filePath is the path of the file to convert. - filePath string - // resultFilePath is the path of the PDF created by - // the conversion. - resultFilePath string - } - - // MultipartFormDataConverter contains an array of - // DirectConverter instances, each one having to convert a file to PDF. - MultipartFormDataConverter struct { - // converters contains all DirectConverter instances which will be used - // to convert files to PDF. - converters []*DirectConverter - // resultFilePath is the path of the PDF file created by the merge - // of all PDF files created by the DirectConverter instances. - resultFilePath string - } -) - -// NewConverter instantiates a converter according to the content type of the request. -func NewConverter(contentType string, r *http.Request) (Converter, error) { - switch contentType { - case "multipart/form-data": - return newMultipartFromDataConverter(contentType, r) - default: - return newDirectConverter(contentType, r.Body) - } -} - -// ConverterUnprocessableEntityError is a custom error which is throwed when -// a file's content type does not match with one of the allowed content types. -type ConverterUnprocessableEntityError struct { - message string -} - -// Error is the implementation of the Error function from the error interface. -func (e *ConverterUnprocessableEntityError) Error() string { - return e.message -} - -// filesExtensions associates all allowed content types with their file extension. -var filesExtensions = map[string]string{ - "application/pdf": ".pdf", - "text/html": ".html", - "application/octet-stream": ".doc", - "application/msword": ".doc", - "application/zip": ".docx", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", -} - -// newDirectConverter instantiates a DirectConverter. -func newDirectConverter(contentType string, r io.Reader) (*DirectConverter, error) { - fileExtension, ok := filesExtensions[contentType] - if !ok { - return nil, &ConverterUnprocessableEntityError{message: "No file extension found"} - } - - filePath := fmt.Sprintf("./%s%s", uuid.NewV4().String(), fileExtension) - - file, err := os.Create(filePath) - if err != nil { - return nil, err - } - - defer file.Close() - - _, err = io.Copy(file, r) - if err != nil { - return nil, err - } - // resets the read pointer. - file.Seek(0, 0) - - contentType, err = helpers.DetectFileContentType(file) - if err != nil { - return nil, &ConverterUnprocessableEntityError{message: fmt.Sprintf("An error occured while trying to detect the content type of a file: %s", err.Error())} - } - - return &DirectConverter{contentType: contentType, filePath: filePath}, nil -} - -// ConversionCommandData will be applied to the data-driven command's template -// which will convert a file to PDF. -type ConversionCommandData struct { - // FilePath is the path of the file to convert to PDF. - FilePath string - // The path of the PDF file created by the considered command. - ResultFilePath string -} - -// Convert converts a file to PDF. -func (c *DirectConverter) Convert() (string, error) { - var cmdTemplate *template.Template - - switch c.contentType { - case "text/html": - cmdTemplate = config.AppConfig.Templates.HTMLtoPDF - break - case "application/octet-stream", "application/msword", "application/zip", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - cmdTemplate = config.AppConfig.Templates.WordToPDF - break - default: - // case "application/pdf". - cmdTemplate = nil - c.resultFilePath = c.filePath - - return c.resultFilePath, nil - } - - c.resultFilePath = fmt.Sprintf("./%s.pdf", uuid.NewV4().String()) - - cmdData := &ConversionCommandData{ - FilePath: c.filePath, - ResultFilePath: c.resultFilePath, - } - - var data bytes.Buffer - if err := cmdTemplate.Execute(&data, cmdData); err != nil { - return "", fmt.Errorf("An error occured while executing a template: %s", err) - } - cmd := data.String() - - e := exec.Command("/bin/sh", "-c", cmd) - if err := e.Run(); err != nil { - return "", fmt.Errorf("An error occured while executing the command %s: %s", cmd, err) - } - - return c.resultFilePath, nil -} - -// Clear removes all files used by an instance of DirectConverter. -func (c *DirectConverter) Clear() error { - if err := os.Remove(c.filePath); err != nil { - return err - } - - // if "application/pdf" content type, the file path is the same - // as the result file path. - if c.contentType != "application/pdf" { - if err := os.Remove(c.resultFilePath); err != nil { - return err - } - } - - return nil -} - -// newMultipartFromDataConverter instantiates a MultipartFormDataConverter. -func newMultipartFromDataConverter(contentType string, r *http.Request) (*MultipartFormDataConverter, error) { - err := r.ParseMultipartForm(32 << 20) - if err != nil { - return nil, err - } - - formData := r.MultipartForm - files := formData.File["files"] - c := &MultipartFormDataConverter{} - - for i := range files { - file, err := files[i].Open() - if err != nil { - return nil, err - } - - defer file.Close() - - contentType, err := helpers.DetectMultipartFileContentType(file) - if err != nil { - return nil, err - } - - d, err := newDirectConverter(contentType, file) - if err != nil { - return nil, err - } - - c.converters = append(c.converters, d) - } - - return c, nil -} - -// MergeCommandData will be applied to the data-driven command's template -// which will merge PDF files. -type MergeCommandData struct { - // FilesPaths are the paths of the PDF files to merge. - FilesPaths []string - // The path of the PDF file created by the considered command. - ResultFilePath string -} - -// Convert converts all files from form data to PDF -// and then merges those resulting PDF into one final PDF. -func (c *MultipartFormDataConverter) Convert() (string, error) { - var filesPaths []string - - for _, d := range c.converters { - filePath, err := d.Convert() - if err != nil { - return "", err - } - - filesPaths = append(filesPaths, filePath) - } - - c.resultFilePath = fmt.Sprintf("./%s.pdf", uuid.NewV4().String()) - cmdTemplate := config.AppConfig.Templates.MergePDF - cmdData := &MergeCommandData{ - FilesPaths: filesPaths, - ResultFilePath: c.resultFilePath, - } - - var data bytes.Buffer - if err := cmdTemplate.Execute(&data, cmdData); err != nil { - return "", fmt.Errorf("An error occured while executing a template: %s", err) - } - cmd := data.String() - - e := exec.Command("/bin/sh", "-c", cmd) - if err := e.Run(); err != nil { - return "", fmt.Errorf("An error occured while executing the command %s: %s", cmd, err) - } - - return c.resultFilePath, nil -} - -// Clear removes all files used by an instance of MultipartFormDataConverter. -func (c *MultipartFormDataConverter) Clear() error { - for _, d := range c.converters { - if err := d.Clear(); err != nil { - return err - } - } - - if err := os.Remove(c.resultFilePath); err != nil { - return err - } - - return nil -} diff --git a/gotenberg.yml b/gotenberg.yml index db2a680d..6a45e358 100644 --- a/gotenberg.yml +++ b/gotenberg.yml @@ -1,22 +1,25 @@ # The port the application will listen to. port: 3000 -# Defines the log level. -# Accepted values, in order of severity: DEBUG, INFO, WARN, ERROR, FATAL, PANIC. -# Messages at and above the selected level will be logged. -logLevel: "DEBUG" +logs: + # Accepted values, in order of severity: DEBUG, INFO, WARN, ERROR, FATAL, PANIC. + # Messages at and above the selected level will be logged. + level: "DEBUG" + + # Accepted values: text, json. + # When a TTY is not attached, the output will be in the definied format. + format: "text" -# The commands' templates. commands: - # Available data: - # - FilePath: the path of the file to convert to PDF. - # - ResultFilePath: path of the PDF file created by the command. - HTMLtoPDF: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}" - # Available data: - # - FilePath: the path of the file to convert to PDF. - # - ResultFilePath: path of the PDF file created by the command. - WordToPDF: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\"" - # Available data: - # - FilePath: the paths of the PDF files to merge. - # - ResultFilePath: path of the PDF file created by the considered command. - MergePDF: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}" + + html: + timeout: 10 + template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}" + + office: + timeout: 10 + template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\"" + + merge: + timeout: 10 + template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}" diff --git a/helpers/helpers.go b/helpers/helpers.go deleted file mode 100644 index 187366b3..00000000 --- a/helpers/helpers.go +++ /dev/null @@ -1,82 +0,0 @@ -// Package helpers implements simple functions used across the application. -package helpers - -import ( - "mime/multipart" - "net/http" - "os" - "strings" -) - -// allowedContentTypes contains all allowed content types -// by the application. -var allowedContentTypes = []string{ - "application/pdf", - "text/html", - "application/octet-stream", - "application/msword", - "application/zip", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "multipart/form-data", -} - -// GetMatchingContentType parses a content type and tries to find a match -// with our allowed content types. If no match found, returns an empty string. -func GetMatchingContentType(contentType string) string { - for _, allowedContentType := range allowedContentTypes { - if i := strings.IndexRune(contentType, ';'); i != -1 { - contentType = contentType[0:i] - } - - if contentType == allowedContentType { - return allowedContentType - } - } - - return "" -} - -// getMatchingContentTypeForFile is a simple wrapper of GetMatchingContentType -// function. It adds a condition for "multipart/form-data" content type, which -// is not an allowed content type for a file. -func getMatchingContentTypeForFile(contentType string) string { - matchingContentType := GetMatchingContentType(contentType) - if matchingContentType == "multipart/form-data" { - return "" - } - - return matchingContentType -} - -// DetectMultipartFileContentType sniffs the content type of a file from -// form data. -func DetectMultipartFileContentType(file multipart.File) (string, error) { - // only the first 512 bytes are used to sniff the content type. - buffer := make([]byte, 512) - n, err := file.Read(buffer) - if err != nil { - return "", err - } - - // resets the read pointer. - file.Seek(0, 0) - - // using n if size of buffer < 512 bytes. - return getMatchingContentTypeForFile(http.DetectContentType(buffer[:n])), nil -} - -// DetectFileContentType sniffs the content type of a file. -func DetectFileContentType(file *os.File) (string, error) { - // only the first 512 bytes are used to sniff the content type. - buffer := make([]byte, 512) - n, err := file.Read(buffer) - if err != nil { - return "", err - } - - // resets the read pointer. - file.Seek(0, 0) - - // using n if size of buffer < 512 bytes. - return getMatchingContentTypeForFile(http.DetectContentType(buffer[:n])), nil -} diff --git a/logger/logger.go b/logger/logger.go deleted file mode 100644 index d70c23f2..00000000 --- a/logger/logger.go +++ /dev/null @@ -1,63 +0,0 @@ -// Package logger implements a simple helper for displaying outputs to the user. -package logger - -import ( - "os" - - "github.com/sirupsen/logrus" -) - -// newLogger instantiates a logrus logger. -func newLogger() *logrus.Logger { - l := logrus.New() - l.Out = os.Stdout - l.Level = logrus.InfoLevel - - return l -} - -// Log is the logger instance used accross the application. -var Log = newLogger() - -// logLevels associates log levels from the configuration file with its equivalent -// in the logrus library. -var logLevels = map[string]logrus.Level{ - "DEBUG": logrus.DebugLevel, - "INFO": logrus.InfoLevel, - "WARN": logrus.WarnLevel, - "ERROR": logrus.ErrorLevel, - "FATAL": logrus.FatalLevel, - "PANIC": logrus.PanicLevel, -} - -// SetLevel changes our logger's log level according to -// the log level defined in the configuration file. -func SetLevel(logLevel string) { - lvl, ok := logLevels[logLevel] - if ok { - Log.Level = lvl - } -} - -// InfoR logs an information with a transaction field. -func InfoR(transactionID string, msg string) { - Log.WithFields(logrus.Fields{ - "transaction": transactionID, - }).Info(msg) -} - -// WarnR logs a warning with a transaction field. -func WarnR(transactionID string, msg string) { - Log.WithFields(logrus.Fields{ - "transaction": transactionID, - }).Warn(msg) -} - -// ErrorR logs an error with transaction, err and code fields. -func ErrorR(transactionID string, err error, code int, msg string) { - Log.WithFields(logrus.Fields{ - "transaction": transactionID, - "code": code, - "err": err.Error(), - }).Error(msg) -} diff --git a/gotenberg.go b/main.go similarity index 52% rename from gotenberg.go rename to main.go index e6af6136..a8c1cf0d 100644 --- a/gotenberg.go +++ b/main.go @@ -10,56 +10,35 @@ package main import ( "context" - "fmt" - "net/http" "os" "os/signal" "time" - "github.com/gulien/gotenberg/config" - "github.com/gulien/gotenberg/logger" - "github.com/gulien/gotenberg/middlewares" + "github.com/gulien/gotenberg/app" + "github.com/gulien/gotenberg/app/handlers/converter/process" + "github.com/gulien/gotenberg/app/logger" - "github.com/gorilla/mux" + "github.com/sirupsen/logrus" ) // version will be set on build time. var version = "master" -// init sets up the application configuration. -func init() { - err := config.MakeConfig() - if err != nil { - logger.Log.Fatal(err) - os.Exit(1) - } - - logger.SetLevel(config.AppConfig.LogLevel) - logger.Log.Infof("Gotenberg %s", version) -} - // main initializes the application and handles // graceful shutdown. func main() { - r := mux.NewRouter() - - // defines our entry point. - r.Handle("/", middlewares.GetMiddlewaresChain()).Methods("POST") - - srv := &http.Server{ - Addr: fmt.Sprintf(":%s", config.AppConfig.Port), - // good practice to set timeouts to avoid Slowloris attacks. - WriteTimeout: time.Second * 15, - ReadTimeout: time.Second * 15, - IdleTimeout: time.Second * 60, - Handler: r, + a, err := app.NewApp(version) + if err != nil { + resetState() + logger.Fatal(err) + os.Exit(1) } // runs our server in a goroutine so that it doesn't block. go func() { - logger.Log.Infof("Listening to port %s", config.AppConfig.Port) - if err := srv.ListenAndServe(); err != nil { - logger.Log.Panicf("Unrecoverable error: %s", err) + if err = a.Run(); err != nil { + resetState() + logger.Panic(err) os.Exit(1) } }() @@ -79,8 +58,14 @@ func main() { // doesn't block if no connections, but will otherwise wait // until the timeout deadline. - srv.Shutdown(ctx) + a.Server.Shutdown(ctx) + resetState() - logger.Log.Info("Bye!") + logger.Info("Bye!") os.Exit(0) } + +func resetState() { + logger.SetLevel(logrus.InfoLevel) + process.Reset() +} diff --git a/middlewares/middlewares.go b/middlewares/middlewares.go deleted file mode 100644 index 908759eb..00000000 --- a/middlewares/middlewares.go +++ /dev/null @@ -1,142 +0,0 @@ -/* -Package middlewares implements all handlers of the application. - -Those handlers manage requests coming from the unique entry point (aka "/"). -*/ -package middlewares - -import ( - "fmt" - "io" - "net/http" - "os" - - "github.com/gulien/gotenberg/context" - "github.com/gulien/gotenberg/converters" - "github.com/gulien/gotenberg/helpers" - "github.com/gulien/gotenberg/logger" - - "github.com/justinas/alice" - "github.com/satori/go.uuid" -) - -// GetMiddlewaresChain builds and returns the chaining of handlers -// using the alice library. -func GetMiddlewaresChain() http.Handler { - return alice.New(loggingHandler, enforceContentLengthHandler, enforceContentTypeHandler, convertHandler, serveHandler).ThenFunc(clearHandler) -} - -// loggingHandler identifies the request. -func loggingHandler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - transactionID := uuid.NewV4().String() - r = r.WithContext(context.WithTransactionID(r.Context(), transactionID)) - logger.InfoR(context.GetTransactionID(r.Context()), fmt.Sprintf("Hello %s", r.RemoteAddr)) - - next.ServeHTTP(w, r) - }) -} - -// enforeContentLengthHandler checks if the request has content. -func enforceContentLengthHandler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.ContentLength == 0 { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - logger.ErrorR(context.GetTransactionID(r.Context()), fmt.Errorf("%s", http.StatusText(http.StatusBadRequest)), http.StatusBadRequest, "Request has no content") - return - } - - next.ServeHTTP(w, r) - }) -} - -// enforceContentTypeHandler checks if the "Content-Type" entry -// from the request's header matches one of the allowed content types. -func enforceContentTypeHandler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - contentType := helpers.GetMatchingContentType(r.Header.Get("Content-Type")) - if contentType == "" { - http.Error(w, http.StatusText(http.StatusUnsupportedMediaType), http.StatusUnsupportedMediaType) - logger.ErrorR(context.GetTransactionID(r.Context()), fmt.Errorf("%s", http.StatusText(http.StatusUnsupportedMediaType)), http.StatusUnsupportedMediaType, "No matching content type found") - return - } - r = r.WithContext(context.WithContentType(r.Context(), contentType)) - - next.ServeHTTP(w, r) - }) -} - -// convertHandler is in charge of converting the file(s) from the request to PDF. -func convertHandler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - contentType := context.GetContentType(r.Context()) - - c, err := converters.NewConverter(contentType, r) - if err != nil { - if converterUnprocessableEntityError, ok := err.(*converters.ConverterUnprocessableEntityError); ok { - // a file from the request has a content type which does not match with one of the allowed - // content types. - http.Error(w, converterUnprocessableEntityError.Error(), http.StatusUnprocessableEntity) - logger.ErrorR(context.GetTransactionID(r.Context()), converterUnprocessableEntityError, http.StatusUnprocessableEntity, "A file has a content type which does not match with one of the allowed content types") - } else { - // something bad happened during the creation of the converter... - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - logger.ErrorR(context.GetTransactionID(r.Context()), err, http.StatusInternalServerError, "Something bad happened during the creation of the converter") - } - - return - } - - resultFilePath, err := c.Convert() - if err != nil { - // an error occured during conversion... - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - logger.ErrorR(context.GetTransactionID(r.Context()), err, http.StatusInternalServerError, "An error occured during conversion") - return - } - r = r.WithContext(context.WithConverter(r.Context(), c)) - r = r.WithContext(context.WithResultFilePath(r.Context(), resultFilePath)) - - next.ServeHTTP(w, r) - }) -} - -// serveHandler simply serves the created PDF. -func serveHandler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resultFilePath := context.GetResultFilePath(r.Context()) - - reader, err := os.Open(resultFilePath) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - logger.ErrorR(context.GetTransactionID(r.Context()), err, http.StatusInternalServerError, fmt.Sprintf("An error occured while opening result file \"%s\"", resultFilePath)) - return - } - - defer reader.Close() - - resultFileInfo, err := reader.Stat() - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - logger.ErrorR(context.GetTransactionID(r.Context()), err, http.StatusInternalServerError, fmt.Sprintf("An error occured while retrieving info from result file \"%s\"", resultFilePath)) - return - } - - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", resultFileInfo.Name())) - w.Header().Set("Content-Type", "application/pdf") - w.Header().Set("Content-Length", fmt.Sprintf("%d", resultFileInfo.Size())) - io.Copy(w, reader) - - next.ServeHTTP(w, r) - }) -} - -// clearHandler removes all files created during the conversion. -func clearHandler(w http.ResponseWriter, r *http.Request) { - c := context.GetConverter(r.Context()) - - if err := c.Clear(); err != nil { - logger.WarnR(context.GetTransactionID(r.Context()), err.Error()) - } - logger.InfoR(context.GetTransactionID(r.Context()), fmt.Sprintf("Bye %s", r.RemoteAddr)) -}