From 86123dea8599331808feaf7514776158a94391c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Omar=20Vergara=20P=C3=A9rez?= Date: Fri, 20 Sep 2024 15:30:44 -0600 Subject: [PATCH] feat: add improvements to the http server (#65) * chore(main): Add graceful shutdown to the http service * build(compose): add a health check to the compose file * build(main): rename the temporary path environment variable * build(make): add changes to the makefile * fix(templates): fix zip files preview * fix(docker): add non-root user to the dockerfile * fix(templates): shorten the modal title * build(make): set the build target as prerequisite * feat(main): add api router * refactor(main): refactor the functions and handlers that are responsible to convert input files * docs(README): add documentation on how to use the api --- Dockerfile | 4 + Makefile | 5 +- README.md | 39 +++ docker-compose.yml | 22 +- main.go | 356 +++++++++++++++++++-------- static/zip-icon.png | Bin 0 -> 5142 bytes templates/partials/active_modal.tmpl | 8 +- templates/partials/form.tmpl | 2 +- templates/partials/js.tmpl | 13 +- 9 files changed, 327 insertions(+), 122 deletions(-) create mode 100644 static/zip-icon.png diff --git a/Dockerfile b/Dockerfile index 617dee1..777fab1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,10 @@ COPY --from=builder /usr/share/fonts /usr/share/fonts ENV FONTCONFIG_PATH /usr/share/fonts +# Use morphos as user +RUN useradd -m morphos +USER morphos + EXPOSE 8080 ENTRYPOINT ["/bin/morphos"] diff --git a/Makefile b/Makefile index da73c54..663d3da 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ HTMX_VERSION=1.9.6 RESPONSE_TARGETS_VERSION=1.9.11 BOOTSTRAP_VERSION=5.3.2 -GO_VERSION=1.21.5 .PHONY: run ## run: Runs the air command. @@ -27,11 +26,11 @@ download-bootstrap: .PHONY: docker-build ## docker-build: Builds the container image docker-build: - docker build --build-arg="GO_VERSION=${GO_VERSION}" -t morphos . + docker build -t morphos . .PHONY: docker-run ## docker-run: Runs the container -docker-run: +docker-run: docker-build docker run --rm -p 8080:8080 -v /tmp:/tmp morphos .PHONY: help diff --git a/README.md b/README.md index ed005ec..dc60f13 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ docker run --rm -p 8080:8080 -v /tmp:/tmp ghcr.io/danvergara/morphos-server:late ## Usage +### HTML form + Run the server as mentioned above and open up your favorite browser. You'll see something like this: @@ -68,6 +70,43 @@ A modal will pop up with a preview of the converted image. +### API + +You can consume morphos through an API, so other systems can integrate with it. + +##### Endpoints + +`GET /api/v1/formats` + +This returns a JSON that shows the supported formats at the moment. + +e.g. + +``` +{"documents": ["docx", "xls"], "image": ["png", "jpeg"]} +``` + +`POST /api/v1/upload` + +This is the endpoint that converts files to a desired format. It is basically a multipart form data in a POST request. The API simply writes the converted files to the response body. + +e.g. + +``` + curl -F 'targetFormat=epub' -F 'uploadFile=@/path/to/file/foo.pdf' localhost:8080/api/v1/upload --output foo.epub +``` +The form fields are: + +* targetFormat: the target format the file will be converted to +* uploadFile: The path to the file that is going to be converted + +### Configuration + +The configuration is only done by the environment varibles shown below. + +* `MORPHOS_PORT` changes the port the server will listen to (default is `8080`) +* `MORPHOS_UPLOAD_PATH` defines the temporary path the files will be stored on disk (default is `/tmp`) + ## Supported Files And Convert Matrix ### Images X Images diff --git a/docker-compose.yml b/docker-compose.yml index 97a3338..be70ceb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,18 @@ name: morphos services: - morphos-server: - ports: - - 8080:8080 - volumes: - - /tmp:/tmp - image: ghcr.io/danvergara/morphos-server:latest + morphos-server: + image: ghcr.io/danvergara/morphos-server:latest + # uncomment this if you want to build the container yourself. + # build: + # context: . + # target: release + ports: + - 8080:8080 + volumes: + - /tmp:/tmp + healthcheck: + test: timeout 10s bash -c ':> /dev/tcp/127.0.0.1/8080' || exit 1 + interval: 60s + retries: 3 + start_period: 20s + timeout: 30s diff --git a/main.go b/main.go index ca7a23b..e17268c 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,9 @@ package main import ( "bytes" + "context" "embed" + "encoding/json" "errors" "fmt" "html/template" @@ -10,8 +12,12 @@ import ( "log" "net/http" "os" + "os/signal" "path/filepath" "strings" + "sync" + "syscall" + "time" "github.com/gabriel-vasile/mimetype" "github.com/go-chi/chi/v5" @@ -39,7 +45,7 @@ var ( ) func init() { - uploadPath = os.Getenv("TMP_DIR") + uploadPath = os.Getenv("MORPHOS_UPLOAD_PATH") if uploadPath == "" { uploadPath = "/tmp" } @@ -129,95 +135,9 @@ func index(w http.ResponseWriter, _ *http.Request) error { } func handleUploadFile(w http.ResponseWriter, r *http.Request) error { - var ( - convertedFile io.Reader - convertedFilePath string - convertedFileName string - err error - ) - - // Parse and validate file and post parameters. - file, fileHeader, err := r.FormFile(uploadFileFormField) - if err != nil { - log.Printf("error ocurred getting file from form: %v", err) - return WithHTTPStatus(err, http.StatusBadRequest) - } - defer file.Close() - - // Get the content of the file in form of a slice of bytes. - fileBytes, err := io.ReadAll(file) + convertedFileName, convertedFileType, _, err := convertFile(r) if err != nil { - log.Printf("error ocurred reading file: %v", err) - return WithHTTPStatus(err, http.StatusBadRequest) - } - - // Get the sub-type of the input file from the form. - targetFileSubType := r.FormValue("input_format") - - // Call Detect fuction to get the mimetype of the input file. - detectedFileType := mimetype.Detect(fileBytes) - - // Parse the mimetype to get the type and the sub-type of the input file. - fileType, subType, err := files.TypeAndSupType(detectedFileType.String()) - if err != nil { - log.Printf("error occurred getting type and subtype from mimetype: %v", err) - return WithHTTPStatus(err, http.StatusBadRequest) - } - - // Get the right factory based off the input file type. - fileFactory, err := files.BuildFactory(fileType, fileHeader.Filename) - if err != nil { - log.Printf("error occurred while getting a file factory: %v", err) - return WithHTTPStatus(err, http.StatusBadRequest) - } - - // Returns an object that implements the File interface based on the sub-type of the input file. - f, err := fileFactory.NewFile(subType) - if err != nil { - log.Printf("error occurred getting the file object: %v", err) - return WithHTTPStatus(err, http.StatusBadRequest) - } - - // Return the kind of the output file. - targetFileType := files.SupportedFileTypes()[targetFileSubType] - - // Convert the file to the target format. - // convertedFile is an io.Reader. - convertedFile, err = f.ConvertTo( - cases.Title(language.English).String(targetFileType), - targetFileSubType, - bytes.NewReader(fileBytes), - ) - if err != nil { - log.Printf("error ocurred while processing the input file: %v", err) - return WithHTTPStatus(err, http.StatusInternalServerError) - } - - switch fileType { - case "application", "text": - targetFileSubType = "zip" - } - - convertedFileName = filename(fileHeader.Filename, targetFileSubType) - convertedFilePath = filepath.Join(uploadPath, convertedFileName) - - newFile, err := os.Create(convertedFilePath) - if err != nil { - log.Printf("error occurred while creating the output file: %v", err) - return WithHTTPStatus(err, http.StatusInternalServerError) - } - defer newFile.Close() - - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(convertedFile); err != nil { - log.Printf("error occurred while readinf from the converted file: %v", err) - return WithHTTPStatus(err, http.StatusInternalServerError) - } - - convertedFileBytes := buf.Bytes() - if _, err := newFile.Write(convertedFileBytes); err != nil { - log.Printf("error occurred writing converted output to a file in disk: %v", err) - return WithHTTPStatus(err, http.StatusInternalServerError) + return err } tmpls := []string{ @@ -231,14 +151,6 @@ func handleUploadFile(w http.ResponseWriter, r *http.Request) error { return WithHTTPStatus(err, http.StatusInternalServerError) } - convertedFileMimeType := mimetype.Detect(convertedFileBytes) - - convertedFileType, _, err := files.TypeAndSupType(convertedFileMimeType.String()) - if err != nil { - log.Printf("error occurred getting the file type of the result file: %v", err) - return WithHTTPStatus(err, http.StatusInternalServerError) - } - err = tmpl.ExecuteTemplate( w, "content", @@ -290,11 +202,16 @@ func handleFileFormat(w http.ResponseWriter, r *http.Request) error { } tmpl, err := template.ParseFS(templatesHTML, templates...) - if err = tmpl.ExecuteTemplate(w, "format-elements", f.SupportedFormats()); err != nil { + if err != nil { log.Printf("error occurred parsing template files: %v", err) return WithHTTPStatus(err, http.StatusInternalServerError) } + if err = tmpl.ExecuteTemplate(w, "format-elements", f.SupportedFormats()); err != nil { + log.Printf("error occurred executing template files: %v", err) + return WithHTTPStatus(err, http.StatusInternalServerError) + } + return nil } @@ -320,13 +237,39 @@ func handleModal(w http.ResponseWriter, r *http.Request) error { return nil } -func main() { - port := os.Getenv("MORPHOS_PORT") - // default port. - if port == "" { - port = "8080" +func getFormats(w http.ResponseWriter, r *http.Request) error { + resp, err := json.Marshal(supportedFormatsJSONResponse()) + if err != nil { + log.Printf("error ocurred marshalling the response: %v", err) + return WithHTTPStatus(err, http.StatusInternalServerError) + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(resp); err != nil { + log.Printf("error ocurred writting to the ResponseWriter : %v", err) + return WithHTTPStatus(err, http.StatusInternalServerError) + } + + return nil +} + +func uploadFile(w http.ResponseWriter, r *http.Request) error { + _, _, convertedFileBytes, err := convertFile(r) + if err != nil { + return err + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/octet-stream") + if _, err := w.Write(convertedFileBytes); err != nil { + log.Printf("error occurred writing converted file to response writer: %v", err) + return WithHTTPStatus(err, http.StatusInternalServerError) } + return nil +} + +func newRouter() http.Handler { r := chi.NewRouter() r.Use(middleware.Logger) @@ -335,6 +278,20 @@ func main() { var staticFS = http.FS(staticFiles) fs := http.FileServer(staticFS) + addRoutes(r, fs, fsUpload) + + return r +} + +func apiRouter() http.Handler { + r := chi.NewRouter() + r.Get("/formats", toHandler(getFormats)) + r.Post("/upload", toHandler(uploadFile)) + return r +} + +func addRoutes(r *chi.Mux, fs, fsUpload http.Handler) { + r.HandleFunc("/healthz", healthz) r.Handle("/static/*", fs) r.Handle("/files/*", http.StripPrefix("/files", fsUpload)) r.Get("/", toHandler(index)) @@ -342,14 +299,79 @@ func main() { r.Post("/format", toHandler(handleFileFormat)) r.Get("/modal", toHandler(handleModal)) - http.ListenAndServe(fmt.Sprintf(":%s", port), r) + // Mount the api router. + r.Mount("/api/v1", apiRouter()) +} + +func run(ctx context.Context) error { + port := os.Getenv("MORPHOS_PORT") + + // default port. + if port == "" { + port = "8080" + } + + ctx, stop := signal.NotifyContext(ctx, + os.Interrupt, + syscall.SIGTERM, + syscall.SIGQUIT) + defer stop() + + r := newRouter() + + srv := &http.Server{ + Addr: fmt.Sprintf(":%s", port), + Handler: r, + } + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err) + } + }() + + var wg sync.WaitGroup + wg.Add(1) + + go func() { + defer wg.Done() + <-ctx.Done() + + log.Println("shutdown signal received") + + ctxTimeout, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + srv.SetKeepAlivesEnabled(false) + + if err := srv.Shutdown(ctxTimeout); err != nil { + fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err) + } + + log.Println("shutdown completed") + }() + + wg.Wait() + + return nil +} + +func main() { + ctx := context.Background() + + if err := run(ctx); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + + log.Println("exiting...") } // renderError functions executes the error template. func renderError(w http.ResponseWriter, message string, statusCode int) { w.WriteHeader(statusCode) tmpl, _ := template.ParseFS(templatesHTML, "templates/partials/error.tmpl") - tmpl.ExecuteTemplate(w, "error", struct{ ErrorMessage string }{ErrorMessage: message}) + _ = tmpl.ExecuteTemplate(w, "error", struct{ ErrorMessage string }{ErrorMessage: message}) } func fileNameWithoutExtension(fileName string) string { @@ -359,3 +381,127 @@ func fileNameWithoutExtension(fileName string) string { func filename(filename, extension string) string { return fmt.Sprintf("%s.%s", fileNameWithoutExtension(filename), extension) } + +func healthz(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +// convertFile handles everything required to convert a file. +// It returns the name of the file, the target file type, the file as a slice of bytes and a possible error. +// It is both used by the HTML form and the API. +func convertFile(r *http.Request) (string, string, []byte, error) { + var ( + convertedFile io.Reader + convertedFilePath string + convertedFileName string + err error + ) + + // Parse and validate file and post parameters. + file, fileHeader, err := r.FormFile(uploadFileFormField) + if err != nil { + log.Printf("error ocurred getting file from form: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) + } + defer file.Close() + + // Get the content of the file in form of a slice of bytes. + fileBytes, err := io.ReadAll(file) + if err != nil { + log.Printf("error ocurred reading file: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) + } + + // Get the sub-type of the input file from the form. + targetFileSubType := r.FormValue("targetFormat") + + // Call Detect fuction to get the mimetype of the input file. + detectedFileType := mimetype.Detect(fileBytes) + + // Parse the mimetype to get the type and the sub-type of the input file. + fileType, subType, err := files.TypeAndSupType(detectedFileType.String()) + if err != nil { + log.Printf("error occurred getting type and subtype from mimetype: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) + } + + // Get the right factory based off the input file type. + fileFactory, err := files.BuildFactory(fileType, fileHeader.Filename) + if err != nil { + log.Printf("error occurred while getting a file factory: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) + } + + // Returns an object that implements the File interface based on the sub-type of the input file. + f, err := fileFactory.NewFile(subType) + if err != nil { + log.Printf("error occurred getting the file object: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusBadRequest) + } + + // Return the kind of the output file. + targetFileType := files.SupportedFileTypes()[targetFileSubType] + + // Convert the file to the target format. + // convertedFile is an io.Reader. + convertedFile, err = f.ConvertTo( + cases.Title(language.English).String(targetFileType), + targetFileSubType, + bytes.NewReader(fileBytes), + ) + if err != nil { + log.Printf("error ocurred while processing the input file: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) + } + + switch fileType { + case "application", "text": + targetFileSubType = "zip" + } + + convertedFileName = filename(fileHeader.Filename, targetFileSubType) + convertedFilePath = filepath.Join(uploadPath, convertedFileName) + + newFile, err := os.Create(convertedFilePath) + if err != nil { + log.Printf("error occurred while creating the output file: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) + } + defer newFile.Close() + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(convertedFile); err != nil { + log.Printf("error occurred while readinf from the converted file: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) + } + + convertedFileBytes := buf.Bytes() + if _, err := newFile.Write(convertedFileBytes); err != nil { + log.Printf("error occurred writing converted output to a file in disk: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) + } + + convertedFileMimeType := mimetype.Detect(convertedFileBytes) + + convertedFileType, _, err := files.TypeAndSupType(convertedFileMimeType.String()) + if err != nil { + log.Printf("error occurred getting the file type of the result file: %v", err) + return "", "", nil, WithHTTPStatus(err, http.StatusInternalServerError) + } + + return convertedFileName, convertedFileType, convertedFileBytes, nil +} + +// supportedFormatsJSONResponse returns the supported formas as a map formatted to be shown as JSON. +// The intention of this is showing the supported formats to the client. +// Example: +// {"documents": ["docx", "xls"], "image": ["png", "jpeg"]} +func supportedFormatsJSONResponse() map[string][]string { + result := make(map[string][]string) + + for k, v := range files.SupportedFileTypes() { + result[v] = append(result[v], k) + } + + return result +} diff --git a/static/zip-icon.png b/static/zip-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c137c25b0e945dd0c4dceefcdbeb4f5016f33b17 GIT binary patch literal 5142 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