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 0000000..c137c25
Binary files /dev/null and b/static/zip-icon.png differ
diff --git a/templates/partials/active_modal.tmpl b/templates/partials/active_modal.tmpl
index dfbf9ed..512decc 100644
--- a/templates/partials/active_modal.tmpl
+++ b/templates/partials/active_modal.tmpl
@@ -3,19 +3,21 @@