diff --git a/cmd/unified/main.go b/cmd/unified/main.go index f193d0e..e179ccb 100644 --- a/cmd/unified/main.go +++ b/cmd/unified/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net/http" "os" @@ -16,6 +17,7 @@ import ( // ← Passe diese Import-Pfade an dein go.mod an "git.send.nrw/sendnrw/decent-webui/internal/admin" + "git.send.nrw/sendnrw/decent-webui/internal/blobfs" "git.send.nrw/sendnrw/decent-webui/internal/filesvc" "git.send.nrw/sendnrw/decent-webui/internal/mesh" ) @@ -280,6 +282,73 @@ func fileRoutes(mux *http.ServeMux, store filesvc.MeshStore) { }) } +// apiFiles wires upload/download endpoints +func apiFiles(mux *http.ServeMux, store filesvc.MeshStore, blobs blobfs.Store, meshNode *mesh.Node) { + // Multipart-Upload + mux.HandleFunc("/api/v1/files/upload", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if err := r.ParseMultipartForm(128 << 20); err != nil { // 128MB + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad form"}) + return + } + fh, hdr, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"}) + return + } + defer fh.Close() + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + name = hdr.Filename + } + + it, err := store.Create(r.Context(), name) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + meta, err := blobs.Save(r.Context(), int64(it.ID), name, fh) + if err != nil { + _, _ = store.Delete(r.Context(), it.ID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + _ = meshNode.SyncNow(r.Context()) + writeJSON(w, http.StatusCreated, map[string]any{ + "file": it, + "blob": meta, + }) + }) + + // Download + mux.HandleFunc("/api/v1/files/", func(w http.ResponseWriter, r *http.Request) { + // /api/v1/files/{id}/download + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/files/"), "/") + if len(parts) != 2 || parts[1] != "download" { + http.NotFound(w, r) + return + } + id, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + rc, meta, err := blobs.Open(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + defer rc.Close() + w.Header().Set("Content-Type", meta.ContentType) + w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10)) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.Name)) + _, _ = io.Copy(w, rc) + }) +} + /*** Mesh <-> Store Mapping (falls Typen getrennt sind) ***/ func toMeshSnapshot(s filesvc.Snapshot) mesh.Snapshot { @@ -345,13 +414,15 @@ func main() { root := http.NewServeMux() // API (Bearer-Auth) + blobs := blobfs.New(getenvDefault("DATA_DIR", "./data")) apiMux := http.NewServeMux() fileRoutes(apiMux, st) + apiFiles(apiMux, st, blobs, mnode) root.Handle("/api/", authMiddleware(cfg.APIKey, apiMux)) // Admin-UI (optional BasicAuth via ADMIN_USER/ADMIN_PASS) adminRoot := http.NewServeMux() - admin.Register(adminRoot, admin.Deps{Store: st, Mesh: mnode}) + admin.Register(adminRoot, admin.Deps{Store: st, Mesh: mnode, Blob: blobs}) adminUser := os.Getenv("ADMIN_USER") adminPass := os.Getenv("ADMIN_PASS") if strings.TrimSpace(adminUser) != "" { diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 470bf2e..66bcb57 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -3,12 +3,15 @@ package admin import ( "context" _ "embed" + "fmt" "html/template" + "io" "net/http" "strconv" "strings" "time" + "git.send.nrw/sendnrw/decent-webui/internal/blobfs" "git.send.nrw/sendnrw/decent-webui/internal/filesvc" "git.send.nrw/sendnrw/decent-webui/internal/mesh" ) @@ -40,6 +43,7 @@ var ( type Deps struct { Store filesvc.MeshStore Mesh *mesh.Node + Blob blobfs.Store } // Register hängt alle /admin Routen ein. @@ -52,7 +56,6 @@ func Register(mux *http.ServeMux, d Deps) { // Partials mux.HandleFunc("/admin/items", func(w http.ResponseWriter, r *http.Request) { - // Liste rendern (Pagination optional via ?next=) nextQ := strings.TrimSpace(r.URL.Query().Get("next")) var nextID filesvc.ID if nextQ != "" { @@ -61,12 +64,96 @@ func Register(mux *http.ServeMux, d Deps) { } } items, nextOut, _ := d.Store.List(r.Context(), nextID, 100) + + type row struct { + ID int64 + Name string + UpdatedAt int64 + HasBlob bool + Size int64 + } + rows := make([]row, 0, len(items)) + for _, it := range items { + meta, ok, _ := d.Blob.Stat(r.Context(), int64(it.ID)) + rows = append(rows, row{ + ID: int64(it.ID), + Name: it.Name, + UpdatedAt: it.UpdatedAt, + HasBlob: ok, + Size: meta.Size, + }) + } + _ = tplItems.Execute(w, map[string]any{ - "Items": items, + "Items": rows, "Next": nextOut, }) }) + // Upload (multipart/form-data, Feldname "file", optional name-Override) + mux.HandleFunc("/admin/files/upload", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseMultipartForm(64 << 20); err != nil { // 64MB + http.Error(w, "bad form", http.StatusBadRequest) + return + } + fh, hdr, err := r.FormFile("file") + if err != nil { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + defer fh.Close() + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + name = hdr.Filename + } + + // 1) Metadatei anlegen (ID beziehen) + it, err := d.Store.Create(r.Context(), name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 2) Blob speichern + if _, err := d.Blob.Save(r.Context(), int64(it.ID), name, fh); err != nil { + // zurückrollen (Tombstone) + _, _ = d.Store.Delete(r.Context(), it.ID) + http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError) + return + } + _ = d.Mesh.SyncNow(r.Context()) // best-effort Push + http.Redirect(w, r, "/admin/items", http.StatusSeeOther) + }) + + // Download (Admin – BasicAuth schützt ggf.) + mux.HandleFunc("/admin/files/", func(w http.ResponseWriter, r *http.Request) { + // /admin/files/{id}/download + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/admin/files/"), "/") + if len(parts) != 2 || parts[1] != "download" { + http.NotFound(w, r) + return + } + id, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + rc, meta, err := d.Blob.Open(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + defer rc.Close() + w.Header().Set("Content-Type", meta.ContentType) + w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10)) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.Name)) + _, _ = io.Copy(w, rc) + }) + mux.HandleFunc("/admin/peers", func(w http.ResponseWriter, r *http.Request) { peers := d.Mesh.PeerList() _ = tplPeers.Execute(w, map[string]any{ diff --git a/internal/admin/tpl/partials_items.html b/internal/admin/tpl/partials_items.html index f97b1fe..630096a 100644 --- a/internal/admin/tpl/partials_items.html +++ b/internal/admin/tpl/partials_items.html @@ -1,10 +1,20 @@
| ID | Name | Updated | Aktionen | +ID | Name | Updated | Blob | Aktionen | {{ .ID }} | {{ .Name }} | {{ printf "%.19s" (timeRFC3339 .UpdatedAt) }} | ++ {{ if .HasBlob }} + vorhanden + {{ .Size }} B + {{ else }} + fehlt + {{ end }} + | + {{ if .HasBlob }} + Download + {{ end }} | {{ else }} -
|---|---|---|---|---|---|---|---|---|
| Keine Dateien vorhanden. | ||||||||
| Keine Dateien vorhanden. | ||||||||