All checks were successful
release-tag / release-image (push) Successful in 1m32s
288 lines
7.9 KiB
Go
288 lines
7.9 KiB
Go
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
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
|
||
_ = 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(), (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
|
||
}
|
||
|
||
nextID := filesvc.ID(strings.TrimSpace(r.URL.Query().Get("next")))
|
||
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(), (it.ID))
|
||
rows = append(rows, row{
|
||
ID: (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,
|
||
})
|
||
}
|