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" ) /*** Templates einbetten ***/ //go:embed tpl/layout.html var layoutHTML string //go:embed tpl/partials_items.html var itemsPartialHTML string //go:embed tpl/partials_peers.html var peersPartialHTML string var ( tplLayout = template.Must(template.New("layout").Parse(layoutHTML)) tplItems = template.Must(template.New("items").Funcs(template.FuncMap{ "timeRFC3339": func(unixNano int64) string { if unixNano == 0 { return "" } return time.Unix(0, unixNano).UTC().Format(time.RFC3339) }, }).Parse(itemsPartialHTML)) tplPeers = template.Must(template.New("peers").Parse(peersPartialHTML)) ) type Deps struct { Store filesvc.MeshStore Mesh *mesh.Node Blob blobfs.Store } // Register hängt alle /admin Routen ein. // Auth liegt optional VOR Register (BasicAuth-Middleware), siehe main.go. func Register(mux *http.ServeMux, d Deps) { // Dashboard mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { renderLayout(w, r, "Files", "/admin/items") }) // Partials mux.HandleFunc("/admin/items", func(w http.ResponseWriter, r *http.Request) { nextQ := strings.TrimSpace(r.URL.Query().Get("next")) var nextID filesvc.ID if nextQ != "" { if n, err := strconv.ParseInt(nextQ, 10, 64); err == nil { nextID = filesvc.ID(n) } } 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": 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", 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 } // 1) lokal versuchen if rc, meta, err := d.Blob.Open(r.Context(), id); err == nil { 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) return } // 2) Remote über Mesh holen rrc, name, _, _, err := d.Mesh.FetchBlobAny(r.Context(), id) if err != nil { http.NotFound(w, r) return } defer rrc.Close() // 3) lokal cachen (Save konsumiert den Stream) if _, err := d.Blob.Save(r.Context(), id, name, rrc); err != nil { http.Error(w, "cache failed: "+err.Error(), http.StatusInternalServerError) return } // 4) aus lokalem Store ausliefern (saubere Größe/CT) lrc, meta, err := d.Blob.Open(r.Context(), id) if err != nil { http.Error(w, "open failed", http.StatusInternalServerError) return } defer lrc.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, lrc) }) mux.HandleFunc("/admin/peers", func(w http.ResponseWriter, r *http.Request) { peers := d.Mesh.PeerList() _ = tplPeers.Execute(w, map[string]any{ "Peers": peers, "Now": time.Now(), }) }) // CREATE mux.HandleFunc("/admin/items/create", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } name := strings.TrimSpace(r.FormValue("name")) if name != "" { _, _ = d.Store.Create(r.Context(), name) _ = d.Mesh.SyncNow(r.Context()) } // statt Redirect: renderItemsPartial(w, r, d) }) // RENAME mux.HandleFunc("/admin/items/rename", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } idStr := r.FormValue("id") newName := strings.TrimSpace(r.FormValue("name")) if id, err := strconv.ParseInt(idStr, 10, 64); err == nil && newName != "" { _, _ = d.Store.Rename(r.Context(), filesvc.ID(id), newName) _ = d.Mesh.SyncNow(r.Context()) } renderItemsPartial(w, r, d) }) // DELETE mux.HandleFunc("/admin/items/delete", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } id64, err := strconv.ParseInt(r.FormValue("id"), 10, 64) if err == nil { _, _ = d.Store.Delete(r.Context(), filesvc.ID(id64)) _ = d.Blob.Delete(r.Context(), id64) // Blob wirklich löschen _ = d.Mesh.SyncNow(r.Context()) } renderItemsPartial(w, r, d) }) mux.HandleFunc("/admin/mesh/syncnow", func(w http.ResponseWriter, r *http.Request) { _ = d.Mesh.SyncNow(context.Background()) http.Redirect(w, r, "/admin/peers", http.StatusSeeOther) }) } func renderLayout(w http.ResponseWriter, _ *http.Request, active string, initial string) { _ = tplLayout.Execute(w, map[string]any{ "Active": active, "Init": initial, // initialer HTMX Swap-Endpunkt }) } /*** Optional: einfache BasicAuth (siehe main.go) ***/ func BasicAuth(user, pass string, next http.Handler) http.Handler { if strings.TrimSpace(user) == "" { return next // deaktiviert } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() if !ok || u != user || p != pass { w.Header().Set("WWW-Authenticate", `Basic realm="admin"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } // rebuild & render items partial for HTMX swaps func renderItemsPartial(w http.ResponseWriter, r *http.Request, d Deps) { type row struct { ID int64 Name string UpdatedAt int64 HasBlob bool Size int64 } nextQ := strings.TrimSpace(r.URL.Query().Get("next")) var nextID filesvc.ID if nextQ != "" { if n, err := strconv.ParseInt(nextQ, 10, 64); err == nil { nextID = filesvc.ID(n) } } items, nextOut, _ := d.Store.List(r.Context(), nextID, 100) 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, }) } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = tplItems.Execute(w, map[string]any{ "Items": rows, "Next": nextOut, }) }