Files
2025-09-29 23:04:46 +02:00

362 lines
9.7 KiB
Go
Raw Permalink 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) {
nextID := filesvc.ID(strings.TrimSpace(r.URL.Query().Get("next")))
items, nextOut, _ := d.Store.List(r.Context(), nextID, 100)
type row struct {
ID string
Name string
UpdatedAt int64
HasBlob bool
Size int64
Owner string
OwnerActive bool
}
rows := make([]row, 0, len(items))
peers := d.Mesh.PeerList()
ttl := 2 * time.Minute
if cfg := d.Mesh.Config(); cfg.PeerTTL > 0 {
ttl = cfg.PeerTTL
}
for _, it := range items {
meta, ok, _ := d.Blob.Stat(r.Context(), it.ID)
rows = append(rows, row{
ID: it.ID,
Name: it.Name,
UpdatedAt: it.UpdatedAt,
HasBlob: ok,
Size: meta.Size,
Owner: it.Owner,
OwnerActive: isOwnerActive(it.Owner, peers, ttl),
})
}
_ = tplItems.Execute(w, map[string]any{
"Items": rows,
"Next": nextOut,
})
})
mux.HandleFunc("/admin/items/takeover", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if id != "" {
// Nur zulassen, wenn Owner tatsächlich offline ist
it, err := d.Store.Get(r.Context(), filesvc.ID(id))
if err == nil {
peers := d.Mesh.PeerList()
ttl := 2 * time.Minute
if cfg := d.Mesh.Config(); cfg.PeerTTL > 0 {
ttl = cfg.PeerTTL
}
if !isOwnerActive(it.Owner, peers, ttl) {
// eigene URL aus PeerList ermitteln
self := ""
for _, p := range peers {
if p.Self {
self = p.URL
break
}
}
if self == "" {
self = "unknown-self"
}
if _, err := d.Store.TakeoverOwner(r.Context(), filesvc.ID(id), self); err == nil {
_ = d.Mesh.SyncNow(r.Context())
}
}
}
}
renderItemsPartial(w, r, d)
})
// 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(), (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 := parts[0]
if strings.TrimSpace(id) == "" {
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
}
id := strings.TrimSpace(r.FormValue("id"))
newName := strings.TrimSpace(r.FormValue("name"))
if id != "" && 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
}
id := strings.TrimSpace(r.FormValue("id"))
if id != "" {
_, _ = d.Store.Delete(r.Context(), filesvc.ID(id))
_ = d.Blob.Delete(r.Context(), id)
_ = 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 string
Name string
UpdatedAt int64
HasBlob bool
Size int64
Owner string
OwnerActive bool
}
nextID := filesvc.ID(strings.TrimSpace(r.URL.Query().Get("next")))
items, nextOut, _ := d.Store.List(r.Context(), nextID, 100)
peers := d.Mesh.PeerList()
ttl := 2 * time.Minute
rows := make([]row, 0, len(items))
for _, it := range items {
meta, ok, _ := d.Blob.Stat(r.Context(), (it.ID))
rows = append(rows, row{
ID: (it.ID),
Name: it.Name,
UpdatedAt: it.UpdatedAt,
HasBlob: ok,
Size: meta.Size,
Owner: it.Owner,
OwnerActive: isOwnerActive(it.Owner, peers, ttl),
})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tplItems.Execute(w, map[string]any{
"Items": rows,
"Next": nextOut,
})
}
func isOwnerActive(owner string, peers []mesh.Peer, ttl time.Duration) bool {
if strings.TrimSpace(owner) == "" {
return true
}
cutoff := time.Now().Add(-ttl)
for _, p := range peers {
if strings.TrimSpace(p.URL) == strings.TrimSpace(owner) {
// Self ist immer aktiv, sonst nach LastSeen
if p.Self {
return true
}
if p.LastSeen.IsZero() {
return false
}
return p.LastSeen.After(cutoff)
}
}
return false
}