package main import ( "context" "crypto/subtle" "embed" "encoding/json" "errors" "log" "mime" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "git.send.nrw/sendnrw/decent-webui/internal/store" ) //go:embed ui/* var uiFS embed.FS type Config struct { ListenAddr string DataDir string APIKey string } func (c Config) BlobDir() string { return filepath.Join(c.DataDir, "blobs") } func (c Config) MetaDir() string { return filepath.Join(c.DataDir, "meta") } func (c Config) TempDir() string { return filepath.Join(c.DataDir, "tmp") } func getenv(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } func LoadConfig() Config { addr := getenv("FILESVC_LISTEN", ":8085") datadir := getenv("FILESVC_DATA", "/data") key := os.Getenv("FILESVC_API_KEY") if key == "" { log.Println("[warn] FILESVC_API_KEY is empty — set it for protection") } return Config{ListenAddr: addr, DataDir: datadir, APIKey: key} } type App struct { cfg Config store *store.Store } func main() { cfg := LoadConfig() for _, p := range []string{cfg.DataDir, cfg.BlobDir(), cfg.MetaDir(), cfg.TempDir()} { if err := os.MkdirAll(p, 0o755); err != nil { log.Fatalf("mkdir %s: %v", p, err) } } st, err := store.Open(cfg.BlobDir(), cfg.MetaDir(), cfg.TempDir()) if err != nil { log.Fatal(err) } app := &App{cfg: cfg, store: st} mux := http.NewServeMux() // API routes mux.HandleFunc("/healthz", app.health) mux.HandleFunc("/v1/files", app.with(app.files)) mux.HandleFunc("/v1/files/", app.with(app.fileByID)) // /v1/files/{id}[ /meta] mux.HandleFunc("/v1/uploads", app.with(app.uploadsRoot)) // POST init mux.HandleFunc("/v1/uploads/", app.with(app.uploadsByID)) // parts/complete/abort // UI routes (embedded) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } http.ServeFileFS(w, r, uiFS, "ui/index.html") }) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(uiFS))) srv := &http.Server{ Addr: cfg.ListenAddr, Handler: logMiddleware(securityHeaders(mux)), ReadTimeout: 60 * time.Second, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 0, IdleTimeout: 120 * time.Second, } go func() { log.Printf("file-service listening on %s", cfg.ListenAddr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server: %v", err) } }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop log.Println("shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(ctx) } func (a *App) with(h func(http.ResponseWriter, *http.Request)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if a.cfg.APIKey != "" { key := r.Header.Get("X-API-Key") if subtle.ConstantTimeCompare([]byte(key), []byte(a.cfg.APIKey)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } } h(w, r) } } func logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) }) } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // immer sinnvolle Sicherheits-Header w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") // Für UI (/, /static/...) dürfen CSS/JS & XHR von "self" laden. if r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/static/") { w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'") } else { // Für API schön streng w.Header().Set("Content-Security-Policy", "default-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") } next.ServeHTTP(w, r) }) } func (a *App) writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func (a *App) health(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) _, _ = w.Write([]byte("ok")) } // --- Routes --- // /v1/files (GET list, POST upload) func (a *App) files(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: q := r.URL.Query().Get("q") off := atoiDefault(r.URL.Query().Get("offset"), 0) lim := atoiDefault(r.URL.Query().Get("limit"), 50) items, next, err := a.store.List(r.Context(), q, off, lim) if err != nil { http.Error(w, err.Error(), 500) return } a.writeJSON(w, 200, map[string]any{"items": items, "next": next}) case http.MethodPost: r.Body = http.MaxBytesReader(w, r.Body, 1<<34) // ~16GiB ct := r.Header.Get("Content-Type") name := r.Header.Get("X-Filename") meta := r.URL.Query().Get("meta") if strings.HasPrefix(ct, "multipart/") { if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, err.Error(), 400) return } f, hdr, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), 400) return } defer f.Close() if hdr != nil { name = hdr.Filename } rec, err := a.store.Put(r.Context(), f, name, meta) if err != nil { http.Error(w, err.Error(), 500) return } a.writeJSON(w, 201, rec) return } rec, err := a.store.Put(r.Context(), r.Body, name, meta) if err != nil { http.Error(w, err.Error(), 500) return } a.writeJSON(w, 201, rec) default: w.WriteHeader(http.StatusMethodNotAllowed) } } // /v1/files/{id} and /v1/files/{id}/meta func (a *App) fileByID(w http.ResponseWriter, r *http.Request) { // path after /v1/files/ rest := strings.TrimPrefix(r.URL.Path, "/v1/files/") parts := strings.Split(rest, "/") if len(parts) == 0 || parts[0] == "" { http.NotFound(w, r) return } id := parts[0] if len(parts) == 2 && parts[1] == "meta" { switch r.Method { case http.MethodGet: rec, err := a.store.GetMeta(r.Context(), id) if err != nil { http.Error(w, err.Error(), 404) return } a.writeJSON(w, 200, rec) case http.MethodPut: var m map[string]string if err := json.NewDecoder(r.Body).Decode(&m); err != nil { http.Error(w, err.Error(), 400) return } rec, err := a.store.UpdateMeta(r.Context(), id, m) if err != nil { http.Error(w, err.Error(), 500) return } a.writeJSON(w, 200, rec) default: w.WriteHeader(http.StatusMethodNotAllowed) } return } // /v1/files/{id} switch r.Method { case http.MethodGet: f, rec, err := a.store.Open(r.Context(), id) if err != nil { http.Error(w, err.Error(), 404) return } defer f.Close() ctype := rec.ContentType if ctype == "" { ctype = mime.TypeByExtension(filepath.Ext(rec.Name)) } if ctype == "" { ctype = "application/octet-stream" } w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Length", strconv.FormatInt(rec.Size, 10)) w.Header().Set("Accept-Ranges", "bytes") if r.URL.Query().Get("download") == "1" { w.Header().Set("Content-Disposition", "attachment; filename=\""+rec.SafeName()+"\"") } http.ServeContent(w, r, rec.SafeName(), rec.CreatedAt, f) case http.MethodDelete: if err := a.store.Delete(r.Context(), id); err != nil { http.Error(w, err.Error(), 404) return } w.WriteHeader(204) default: w.WriteHeader(http.StatusMethodNotAllowed) } } // /v1/uploads (POST) and /v1/uploads/{uid}/ ... func (a *App) uploadsRoot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") meta := r.URL.Query().Get("meta") u, err := a.store.UploadInit(r.Context(), name, meta) if err != nil { http.Error(w, err.Error(), 500) return } a.writeJSON(w, 201, u) } func (a *App) uploadsByID(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/v1/uploads/") parts := strings.Split(rest, "/") if len(parts) < 1 || parts[0] == "" { http.NotFound(w, r) return } uid := parts[0] if len(parts) == 3 && parts[1] == "parts" { n := atoiDefault(parts[2], -1) if r.Method != http.MethodPut || n < 1 { http.Error(w, "invalid part", 400) return } if err := a.store.UploadPart(r.Context(), uid, n, r.Body); err != nil { http.Error(w, err.Error(), 400) return } w.WriteHeader(204) return } if len(parts) == 2 && parts[1] == "complete" { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } rec, err := a.store.UploadComplete(r.Context(), uid) if err != nil { http.Error(w, err.Error(), 400) return } a.writeJSON(w, 201, rec) return } if len(parts) == 1 && r.Method == http.MethodDelete { if err := a.store.UploadAbort(r.Context(), uid); err != nil { http.Error(w, err.Error(), 400) return } w.WriteHeader(204) return } http.NotFound(w, r) } func atoiDefault(s string, d int) int { n, err := strconv.Atoi(s) if err != nil { return d } return n }