This commit is contained in:
2025-09-27 13:37:40 +02:00
parent e701583410
commit d7877046cd
4 changed files with 351 additions and 11 deletions

View File

@@ -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{

View File

@@ -1,10 +1,20 @@
<div class="row">
<div style="flex: 1 1 360px">
<form hx-post="/admin/items/create" hx-target="#items" hx-get="/admin/items" hx-swap="outerHTML">
<label>Neue Datei</label>
<label>Neue leere Datei (nur Metadaten)</label>
<div style="display:flex; gap:8px; margin-top:6px">
<input type="text" name="name" placeholder="z.B. notes.txt" required>
<button class="btn btn-primary" type="submit">Anlegen</button>
<button class="btn" type="submit">Anlegen</button>
</div>
</form>
</div>
<div style="flex: 1 1 360px">
<form action="/admin/files/upload" method="post" enctype="multipart/form-data">
<label>Datei hochladen</label>
<div style="display:flex; gap:8px; margin-top:6px">
<input type="file" name="file" required>
<input type="text" name="name" placeholder="Name (optional)">
<button class="btn btn-primary" type="submit">Upload</button>
</div>
</form>
</div>
@@ -14,7 +24,7 @@
<table>
<thead>
<tr>
<th>ID</th><th>Name</th><th>Updated</th><th style="width:200px">Aktionen</th>
<th>ID</th><th>Name</th><th>Updated</th><th>Blob</th><th style="width:260px">Aktionen</th>
</tr>
</thead>
<tbody>
@@ -23,25 +33,36 @@
<td>{{ .ID }}</td>
<td>{{ .Name }}</td>
<td><small class="muted">{{ printf "%.19s" (timeRFC3339 .UpdatedAt) }}</small></td>
<td>
{{ if .HasBlob }}
<span class="pill">vorhanden</span>
<small class="muted">{{ .Size }} B</small>
{{ else }}
<span class="pill" style="background:#3a0b0b;border-color:#5b1a1a;color:#fbb;">fehlt</span>
{{ end }}
</td>
<td>
<form style="display:inline-flex; gap:6px"
hx-post="/admin/items/rename"
hx-target="#items" hx-get="/admin/items" hx-swap="outerHTML">
<input type="hidden" name="id" value="{{ .ID }}">
<input type="text" name="name" placeholder="Neuer Name">
<button class="btn" type="submit" title="Umbenennen">Rename</button>
<button class="btn" type="submit">Rename</button>
</form>
<form style="display:inline"
hx-post="/admin/items/delete"
hx-target="#items" hx-get="/admin/items" hx-swap="outerHTML"
onsubmit="return confirm('Wirklich löschen?');">
onsubmit="return confirm('Wirklich löschen (inkl. Blob)?');">
<input type="hidden" name="id" value="{{ .ID }}">
<button class="btn" type="submit" title="Löschen">Delete</button>
<button class="btn" type="submit">Delete</button>
</form>
{{ if .HasBlob }}
<a class="btn" href="/admin/files/{{ .ID }}/download">Download</a>
{{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="4" class="muted">Keine Dateien vorhanden.</td></tr>
<tr><td colspan="5" class="muted">Keine Dateien vorhanden.</td></tr>
{{ end }}
</tbody>
</table>
@@ -54,4 +75,4 @@
<span class="pill">next={{ .Next }}</span>
</div>
{{ end }}
</div>
</div>