Files
decent-webui/internal/admin/admin.go
jbergner 7d0f4befe1
All checks were successful
release-tag / release-image (push) Successful in 1m28s
update
2025-09-27 14:16:15 +02:00

260 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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
}
// 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(),
})
})
// Actions (HTMX POSTs)
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()) // prompt push (best effort)
}
// Nach Aktion Items partial zurückgeben (HTMX swap)
http.Redirect(w, r, "/admin/items", http.StatusSeeOther)
})
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())
}
http.Redirect(w, r, "/admin/items", http.StatusSeeOther)
})
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
}
if id, err := strconv.ParseInt(r.FormValue("id"), 10, 64); err == nil {
_, _ = d.Store.Delete(r.Context(), filesvc.ID(id))
_ = d.Blob.Delete(r.Context(), int64(id))
_ = d.Mesh.SyncNow(r.Context())
}
http.Redirect(w, r, "/admin/items", http.StatusSeeOther)
})
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)
})
}