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 @@
- +
- + +
+
+
+
+
+ +
+ + +
@@ -14,7 +24,7 @@ - + @@ -23,25 +33,36 @@ + {{ else }} - + {{ end }}
IDNameUpdatedAktionenIDNameUpdatedBlobAktionen
{{ .ID }} {{ .Name }} {{ printf "%.19s" (timeRFC3339 .UpdatedAt) }} + {{ if .HasBlob }} + vorhanden + {{ .Size }} B + {{ else }} + fehlt + {{ end }} +
- +
+ onsubmit="return confirm('Wirklich löschen (inkl. Blob)?');"> - +
+ {{ if .HasBlob }} + Download + {{ end }}
Keine Dateien vorhanden.
Keine Dateien vorhanden.
@@ -54,4 +75,4 @@ next={{ .Next }}
{{ end }} - + \ No newline at end of file diff --git a/internal/blobfs/blobfs.go b/internal/blobfs/blobfs.go new file mode 100644 index 0000000..ee5bf7e --- /dev/null +++ b/internal/blobfs/blobfs.go @@ -0,0 +1,161 @@ +package blobfs + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" +) + +type Meta struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` + SHA256 string `json:"sha256"` +} + +type Store interface { + Save(ctx context.Context, id int64, filename string, r io.Reader) (Meta, error) + Open(ctx context.Context, id int64) (io.ReadSeekCloser, Meta, error) + Stat(ctx context.Context, id int64) (Meta, bool, error) + Delete(ctx context.Context, id int64) error +} + +type FS struct{ root string } + +func New(root string) *FS { return &FS{root: root} } + +func (fs *FS) dir(id int64) string { return filepath.Join(fs.root, "files", fmt.Sprintf("%d", id)) } +func (fs *FS) metaPath(id int64) string { return filepath.Join(fs.dir(id), "meta.json") } +func (fs *FS) blobPath(id int64, name string) string { + return filepath.Join(fs.dir(id), "blob"+safeExt(name)) +} + +func safeExt(name string) string { + ext := filepath.Ext(name) + if len(ext) > 16 { // unrealistisch lange Exts beschneiden + ext = ext[:16] + } + return ext +} + +func (fs *FS) Save(_ context.Context, id int64, filename string, r io.Reader) (Meta, error) { + if strings.TrimSpace(filename) == "" { + return Meta{}, errors.New("filename required") + } + if err := os.MkdirAll(fs.dir(id), 0o755); err != nil { + return Meta{}, err + } + tmp := filepath.Join(fs.dir(id), "blob.tmp") + out, err := os.Create(tmp) + if err != nil { + return Meta{}, err + } + defer out.Close() + + hasher := sha256.New() + tee := io.TeeReader(r, hasher) + + // Content-Type sniffen: ersten 512 Bytes puffern + buf := make([]byte, 512) + n, _ := io.ReadFull(tee, buf) + if n > 0 { + if _, err := out.Write(buf[:n]); err != nil { + return Meta{}, err + } + } + // Rest kopieren + size := int64(n) + written, err := io.Copy(out, tee) + if err != nil { + return Meta{}, err + } + size += written + + // Hash + sum := hex.EncodeToString(hasher.Sum(nil)) + + ct := http.DetectContentType(buf[:n]) + if ct == "application/octet-stream" { + // Versuch über Dateiendung + if ext := filepath.Ext(filename); ext != "" { + if byExt := mime.TypeByExtension(ext); byExt != "" { + ct = byExt + } + } + } + + // finaler Ort + final := fs.blobPath(id, filename) + if err := os.Rename(tmp, final); err != nil { + return Meta{}, err + } + + meta := Meta{Name: filename, Size: size, ContentType: ct, SHA256: sum} + if err := writeJSON(fs.metaPath(id), meta); err != nil { + return Meta{}, err + } + + return meta, nil +} + +func (fs *FS) Open(_ context.Context, id int64) (io.ReadSeekCloser, Meta, error) { + meta, ok, err := fs.Stat(context.Background(), id) + if err != nil { + return nil, Meta{}, err + } + if !ok { + return nil, Meta{}, os.ErrNotExist + } + f, err := os.Open(fs.blobPath(id, meta.Name)) + return f, meta, err +} + +func (fs *FS) Stat(_ context.Context, id int64) (Meta, bool, error) { + b, err := os.ReadFile(fs.metaPath(id)) + if err != nil { + if os.IsNotExist(err) { + return Meta{}, false, nil + } + return Meta{}, false, err + } + var m Meta + if err := json.Unmarshal(b, &m); err != nil { + return Meta{}, false, err + } + // Größe aus FS gegenprüfen (falls manipuliert) + info, err := os.Stat(fs.blobPath(id, m.Name)) + if err == nil { + m.Size = info.Size() + } + return m, true, nil +} + +func (fs *FS) Delete(_ context.Context, id int64) error { + return os.RemoveAll(fs.dir(id)) +} + +func writeJSON(path string, v any) error { + tmp := path + ".tmp" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(tmp) + if err != nil { + return err + } + if err := json.NewEncoder(f).Encode(v); err != nil { + f.Close() + return err + } + f.Close() + return os.Rename(tmp, path) +}