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) }