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

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -16,6 +17,7 @@ import (
// ← Passe diese Import-Pfade an dein go.mod an
"git.send.nrw/sendnrw/decent-webui/internal/admin"
"git.send.nrw/sendnrw/decent-webui/internal/blobfs"
"git.send.nrw/sendnrw/decent-webui/internal/filesvc"
"git.send.nrw/sendnrw/decent-webui/internal/mesh"
)
@@ -280,6 +282,73 @@ func fileRoutes(mux *http.ServeMux, store filesvc.MeshStore) {
})
}
// apiFiles wires upload/download endpoints
func apiFiles(mux *http.ServeMux, store filesvc.MeshStore, blobs blobfs.Store, meshNode *mesh.Node) {
// Multipart-Upload
mux.HandleFunc("/api/v1/files/upload", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(128 << 20); err != nil { // 128MB
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad form"})
return
}
fh, hdr, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"})
return
}
defer fh.Close()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
name = hdr.Filename
}
it, err := store.Create(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
meta, err := blobs.Save(r.Context(), int64(it.ID), name, fh)
if err != nil {
_, _ = store.Delete(r.Context(), it.ID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
_ = meshNode.SyncNow(r.Context())
writeJSON(w, http.StatusCreated, map[string]any{
"file": it,
"blob": meta,
})
})
// Download
mux.HandleFunc("/api/v1/files/", func(w http.ResponseWriter, r *http.Request) {
// /api/v1/files/{id}/download
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/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 := blobs.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)
})
}
/*** Mesh <-> Store Mapping (falls Typen getrennt sind) ***/
func toMeshSnapshot(s filesvc.Snapshot) mesh.Snapshot {
@@ -345,13 +414,15 @@ func main() {
root := http.NewServeMux()
// API (Bearer-Auth)
blobs := blobfs.New(getenvDefault("DATA_DIR", "./data"))
apiMux := http.NewServeMux()
fileRoutes(apiMux, st)
apiFiles(apiMux, st, blobs, mnode)
root.Handle("/api/", authMiddleware(cfg.APIKey, apiMux))
// Admin-UI (optional BasicAuth via ADMIN_USER/ADMIN_PASS)
adminRoot := http.NewServeMux()
admin.Register(adminRoot, admin.Deps{Store: st, Mesh: mnode})
admin.Register(adminRoot, admin.Deps{Store: st, Mesh: mnode, Blob: blobs})
adminUser := os.Getenv("ADMIN_USER")
adminPass := os.Getenv("ADMIN_PASS")
if strings.TrimSpace(adminUser) != "" {

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>

161
internal/blobfs/blobfs.go Normal file
View File

@@ -0,0 +1,161 @@
package blobfs
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
)
type Meta struct {
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
SHA256 string `json:"sha256"`
}
type Store interface {
Save(ctx context.Context, id int64, filename string, r io.Reader) (Meta, error)
Open(ctx context.Context, id int64) (io.ReadSeekCloser, Meta, error)
Stat(ctx context.Context, id int64) (Meta, bool, error)
Delete(ctx context.Context, id int64) error
}
type FS struct{ root string }
func New(root string) *FS { return &FS{root: root} }
func (fs *FS) dir(id int64) string { return filepath.Join(fs.root, "files", fmt.Sprintf("%d", id)) }
func (fs *FS) metaPath(id int64) string { return filepath.Join(fs.dir(id), "meta.json") }
func (fs *FS) blobPath(id int64, name string) string {
return filepath.Join(fs.dir(id), "blob"+safeExt(name))
}
func safeExt(name string) string {
ext := filepath.Ext(name)
if len(ext) > 16 { // unrealistisch lange Exts beschneiden
ext = ext[:16]
}
return ext
}
func (fs *FS) Save(_ context.Context, id int64, filename string, r io.Reader) (Meta, error) {
if strings.TrimSpace(filename) == "" {
return Meta{}, errors.New("filename required")
}
if err := os.MkdirAll(fs.dir(id), 0o755); err != nil {
return Meta{}, err
}
tmp := filepath.Join(fs.dir(id), "blob.tmp")
out, err := os.Create(tmp)
if err != nil {
return Meta{}, err
}
defer out.Close()
hasher := sha256.New()
tee := io.TeeReader(r, hasher)
// Content-Type sniffen: ersten 512 Bytes puffern
buf := make([]byte, 512)
n, _ := io.ReadFull(tee, buf)
if n > 0 {
if _, err := out.Write(buf[:n]); err != nil {
return Meta{}, err
}
}
// Rest kopieren
size := int64(n)
written, err := io.Copy(out, tee)
if err != nil {
return Meta{}, err
}
size += written
// Hash
sum := hex.EncodeToString(hasher.Sum(nil))
ct := http.DetectContentType(buf[:n])
if ct == "application/octet-stream" {
// Versuch über Dateiendung
if ext := filepath.Ext(filename); ext != "" {
if byExt := mime.TypeByExtension(ext); byExt != "" {
ct = byExt
}
}
}
// finaler Ort
final := fs.blobPath(id, filename)
if err := os.Rename(tmp, final); err != nil {
return Meta{}, err
}
meta := Meta{Name: filename, Size: size, ContentType: ct, SHA256: sum}
if err := writeJSON(fs.metaPath(id), meta); err != nil {
return Meta{}, err
}
return meta, nil
}
func (fs *FS) Open(_ context.Context, id int64) (io.ReadSeekCloser, Meta, error) {
meta, ok, err := fs.Stat(context.Background(), id)
if err != nil {
return nil, Meta{}, err
}
if !ok {
return nil, Meta{}, os.ErrNotExist
}
f, err := os.Open(fs.blobPath(id, meta.Name))
return f, meta, err
}
func (fs *FS) Stat(_ context.Context, id int64) (Meta, bool, error) {
b, err := os.ReadFile(fs.metaPath(id))
if err != nil {
if os.IsNotExist(err) {
return Meta{}, false, nil
}
return Meta{}, false, err
}
var m Meta
if err := json.Unmarshal(b, &m); err != nil {
return Meta{}, false, err
}
// Größe aus FS gegenprüfen (falls manipuliert)
info, err := os.Stat(fs.blobPath(id, m.Name))
if err == nil {
m.Size = info.Size()
}
return m, true, nil
}
func (fs *FS) Delete(_ context.Context, id int64) error {
return os.RemoveAll(fs.dir(id))
}
func writeJSON(path string, v any) error {
tmp := path + ".tmp"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(tmp)
if err != nil {
return err
}
if err := json.NewEncoder(f).Encode(v); err != nil {
f.Close()
return err
}
f.Close()
return os.Rename(tmp, path)
}