fix1
This commit is contained in:
@@ -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) != "" {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
161
internal/blobfs/blobfs.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user