Files
decent-webui/internal/blobfs/blobfs.go
jbergner baedff9e9d
All checks were successful
release-tag / release-image (push) Successful in 1m32s
Umstellung auf uuid
2025-09-27 21:42:32 +02:00

174 lines
4.0 KiB
Go

package blobfs
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"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 string, filename string, r io.Reader) (Meta, error)
Open(ctx context.Context, id string) (io.ReadSeekCloser, Meta, error)
Stat(ctx context.Context, id string) (Meta, bool, error)
Delete(ctx context.Context, id string) error
}
type FS struct{ root string }
func New(root string) *FS { return &FS{root: root} }
func (fs *FS) dir(id string) string { return filepath.Join(fs.root, "files", sanitizeID(id)) }
func (fs *FS) metaPath(id string) string { return filepath.Join(fs.dir(id), "meta.json") }
func (fs *FS) blobPath(id string, name string) string {
return filepath.Join(fs.dir(id), "blob"+safeExt(name))
}
func sanitizeID(id string) string {
// nur 0-9a-zA-Z- zulassen; Rest mit '_' ersetzen
b := make([]rune, 0, len(id))
for _, r := range id {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '-' {
b = append(b, r)
} else {
b = append(b, '_')
}
}
return string(b)
}
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 string, 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 string) (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 string) (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 string) 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)
}