package server import ( "embed" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "net/url" "path/filepath" "strings" "unicode" "unicode/utf8" "orientator/internal/pipeline" ) //go:embed all:web var webFS embed.FS type Server struct { mux *http.ServeMux } func New() *Server { s := &Server{mux: http.NewServeMux()} sub, _ := fs.Sub(webFS, "web") s.mux.Handle("/", http.FileServer(http.FS(sub))) s.mux.HandleFunc("/api/process", s.handleProcess) s.mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) return s } func (s *Server) Handler() http.Handler { return s.mux } const maxUploadBytes = 2 << 30 // 2 GiB func (s *Server) handleProcess(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) if err := r.ParseMultipartForm(64 << 20); err != nil { writeErr(w, http.StatusBadRequest, err) return } files := r.MultipartForm.File["files"] if len(files) == 0 { writeErr(w, http.StatusBadRequest, errors.New("no files")) return } if len(files) == 1 { fh := files[0] f, err := fh.Open() if err != nil { writeErr(w, http.StatusBadRequest, err) return } data, err := io.ReadAll(f) f.Close() if err != nil { writeErr(w, http.StatusBadRequest, err) return } res, err := pipeline.Process(fh.Filename, data) if err != nil { writeErr(w, http.StatusUnprocessableEntity, err) return } writeFile(w, res.OutputName, res.OutputBytes, res.Reports) return } // Multiple files: synthesize a tar/zip wrapping name and pack outputs. combined := &combinedZip{} for _, fh := range files { f, err := fh.Open() if err != nil { writeErr(w, http.StatusBadRequest, err) return } data, err := io.ReadAll(f) f.Close() if err != nil { writeErr(w, http.StatusBadRequest, err) return } res, err := pipeline.Process(fh.Filename, data) if err != nil { combined.addError(fh.Filename, err) continue } combined.add(res) } out, reports, err := combined.finalize() if err != nil { writeErr(w, http.StatusInternalServerError, err) return } writeFile(w, "orientator-output.zip", out, reports) } func writeErr(w http.ResponseWriter, code int, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) } func writeFile(w http.ResponseWriter, name string, data []byte, reports []pipeline.PageReport) { rep, _ := json.Marshal(reports) w.Header().Set("Content-Type", contentTypeFor(name)) w.Header().Set("Content-Disposition", contentDisposition(name)) w.Header().Set("X-Orientator-Reports", url.QueryEscape(string(rep))) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) w.Write(data) } func contentDisposition(name string) string { base := filepath.Base(name) return fmt.Sprintf( "attachment; filename=%q; filename*=UTF-8''%s", asciiFilename(base), url.PathEscape(base), ) } func asciiFilename(name string) string { var b strings.Builder b.Grow(len(name)) for _, r := range name { switch { case r < utf8.RuneSelf && (unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_'): b.WriteRune(r) case unicode.IsSpace(r): b.WriteByte('_') } } if b.Len() == 0 { return "output" } return b.String() } func contentTypeFor(name string) string { switch strings.ToLower(filepath.Ext(name)) { case ".pdf": return "application/pdf" case ".zip": return "application/zip" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".tif", ".tiff": return "image/tiff" } return "application/octet-stream" }