All checks were successful
release-tag / release-image (push) Successful in 1m32s
174 lines
4.0 KiB
Go
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)
|
|
}
|