Initial orientator

This commit is contained in:
2026-05-13 19:52:51 +03:00
commit 49fb60f831
24 changed files with 1919 additions and 0 deletions

164
internal/server/server.go Normal file
View File

@@ -0,0 +1,164 @@
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"
}