Documentation
¶
Overview ¶
Package files is the engine's content-addressable file store (FILES-V1 core, FILES-V2 backends): the real source of the file_ref the XLSX consumer reads.
Layout: a blob is keyed by the SHA-256 of its content as
<tenant>/<aa>/<bb>/<sha256-hex>
where aa/bb are the first two bytes of the hash (a fan-out so one directory never holds millions of entries). Identical content hashes to the same key, so dedup is free WITHIN a tenant; the tenant prefix gives physical isolation (one tenant's bytes never share a directory with another's). File METADATA (id, sha256, size, content_type, original_name) lives in a per-tenant Postgres table — the blob in storage, the row in the tenant schema, the same isolation model the rest of the engine uses. Metadata is AUTHORITATIVE in the DB; storage only moves bytes.
FILES-V2 splits the Store (tenancy + metadata + OWASP upload validation + hashing + dedup — identical everywhere) from the Backend (byte storage): the LocalBackend serves from disk inside the binary (http.ServeContent — Range/ETag/sendfile; signed access via engine-minted short-lived HMAC tokens), and the S3Backend targets any S3-compatible provider by config (R2, Spaces, MinIO, AWS), serving via short-lived presigned URL + 302 by default (the engine authorizes but never proxies the bytes) or via proxy mode.
Index ¶
- Constants
- Variables
- func BackendName(b Backend) string
- func DeleteHandler(store *Store) http.HandlerFunc
- func DownloadHandler(store *Store) http.HandlerFunc
- func EnsureMetaTable(ctx context.Context, pool *pgxpool.Pool, tenant string) error
- func IsByteServingPath(method, path string) bool
- func MintDownloadToken(secret []byte, tenant, fileID, role string, ttl time.Duration) (string, error)
- func NewMemStore() metaStore
- func NewPGStore(pool *pgxpool.Pool) metaStore
- func ProcessUpload(store *Store, maxBytes int64, tenantID string, w http.ResponseWriter, ...)
- func SanitizeName(name string) string
- func ServeStored(store *Store, tenantID, id string, w http.ResponseWriter, r *http.Request)
- func SignedServeHandler(store *Store, secret []byte, allowRead func(role string) bool) http.HandlerFunc
- func SignedURLHandler(store *Store, secret []byte, ttl time.Duration) http.HandlerFunc
- func UploadHandler(store *Store, maxBytes int64) http.HandlerFunc
- func VerifyDownloadToken(secret []byte, token string) (tenant, fileID, role string, err error)
- type Backend
- type LocalBackend
- func (l *LocalBackend) Delete(_ context.Context, key string) error
- func (l *LocalBackend) Get(_ context.Context, key string) (io.ReadSeekCloser, error)
- func (l *LocalBackend) List(_ context.Context, prefix string) ([]ObjectInfo, error)
- func (l *LocalBackend) Put(ctx context.Context, key string, r io.Reader, _ PutOptions) error
- func (l *LocalBackend) PutFile(_ context.Context, key, srcPath string) error
- func (l *LocalBackend) Root() string
- func (l *LocalBackend) Serve(w http.ResponseWriter, r *http.Request, key string, info ServeInfo) error
- func (l *LocalBackend) SignedURL(context.Context, string, time.Duration) (string, error)
- func (l *LocalBackend) StagingDir(tenant string) (string, error)
- func (l *LocalBackend) Stat(_ context.Context, key string) (ObjectInfo, error)
- type Meta
- type ObjectInfo
- type PutMeta
- type PutOptions
- type S3Backend
- func (b *S3Backend) Close() error
- func (b *S3Backend) Delete(ctx context.Context, key string) error
- func (b *S3Backend) Get(ctx context.Context, key string) (io.ReadSeekCloser, error)
- func (b *S3Backend) List(ctx context.Context, prefix string) ([]ObjectInfo, error)
- func (b *S3Backend) Put(ctx context.Context, key string, r io.Reader, opts PutOptions) error
- func (b *S3Backend) Serve(w http.ResponseWriter, r *http.Request, key string, info ServeInfo) error
- func (b *S3Backend) SignedURL(ctx context.Context, key string, expiry time.Duration) (string, error)
- func (b *S3Backend) Stat(ctx context.Context, key string) (ObjectInfo, error)
- type S3Config
- type S3ServeMode
- type ServeInfo
- type Store
- func (s *Store) Backend() Backend
- func (s *Store) Delete(ctx context.Context, tenant, id string) error
- func (s *Store) Exists(ctx context.Context, tenant, sha string) (bool, error)
- func (s *Store) Get(ctx context.Context, tenant, id string) (io.ReadCloser, Meta, error)
- func (s *Store) ListMeta(ctx context.Context, tenant string, limit, offset int) ([]Meta, int, error)
- func (s *Store) Put(ctx context.Context, tenant string, r io.Reader, pm PutMeta) (Meta, error)
- func (s *Store) Serve(w http.ResponseWriter, r *http.Request, tenant, id string) error
- func (s *Store) SignedURL(ctx context.Context, tenant, id string, ttl time.Duration) (string, error)
- func (s *Store) Stat(ctx context.Context, tenant, id string) (Meta, error)
- func (s *Store) URL(ctx context.Context, tenant, id string) (string, error)
- type StoreOption
- type UploadPolicy
- type VFS
Constants ¶
const DefaultMaxUploadBytes int64 = 256 << 20 // 256 MiB
DefaultMaxUploadBytes caps a single upload body. It is a disk-exhaustion guard, not a memory one (uploads stream to disk in 64 KiB chunks), so it is generous; override via the engine's APPXIMO_FILES_MAX_BYTES.
const DefaultSignedURLTTL = 180 * time.Second
DefaultSignedURLTTL is how long an engine-minted download token (and an S3 presigned URL) stays valid. Short by design (OWASP / the PocketBase pattern: a protected-file URL is a capability — it must expire fast); configurable via APPXIMO_FILES_TOKEN_TTL.
const SignedPathPrefix = "/files/signed"
SignedPathPrefix is where the engine serves signed-token downloads (GET /files/signed/{token}). It sits OUTSIDE /api on purpose: the token IS the credential, so the route skips JWT (pkg/auth.skipJWT) and the response cache (a blob must never be buffered), while still flowing through the tenant + rate-limit middleware.
Variables ¶
var DefaultAllowedExtensions = []string{
"jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "ico", "bmp", "tiff", "heic",
"pdf", "txt", "md", "csv", "json", "xml", "yaml", "yml",
"xlsx", "xls", "docx", "doc", "pptx", "ppt", "odt", "ods", "odp", "rtf",
"zip", "gz", "tgz", "tar", "7z", "rar",
"mp3", "wav", "ogg", "m4a", "flac", "mp4", "webm", "mov", "avi", "mkv",
"woff", "woff2", "ttf", "otf", "bin", "dat", "parquet",
}
DefaultAllowedExtensions is the out-of-the-box upload ALLOWLIST (OWASP: an allowlist, never a denylist — an extension not listed here is rejected, so .php/.exe/.sh are unrepresentable rather than enumerated). Operators extend or replace it via APPXIMO_FILES_ALLOWED_EXT; the single value "*" disables the check. A file with NO extension is always accepted: it cannot be double-click-executed, is stored under a hash key, and is served with attachment + nosniff.
var ErrBadToken = errors.New("files: invalid download token")
ErrBadToken covers every download-token failure — malformed, bad signature, expired, wrong claim shape. Handlers map it to 404 (not 403): an invalid token must be indistinguishable from a nonexistent file (anti-fingerprinting).
var ErrNotFound = errors.New("files: not found")
ErrNotFound is returned by Get/Delete when no file with the given id exists for the tenant (a missing metadata row, a tenant with no files table yet, or a metadata row whose blob is absent). HTTP handlers map it to 404.
var ErrSignedURLUnsupported = errors.New("files: backend does not mint signed URLs")
ErrSignedURLUnsupported means the backend cannot mint storage-level signed URLs; the caller falls back to the engine's own signed-token URL.
var ErrUploadRejected = errors.New("files: upload rejected")
ErrUploadRejected wraps every upload-validation failure (extension outside the allowlist, magic-byte mismatch). HTTP handlers map it to 422 with the wrapped detail; programmatic callers errors.Is against it.
Functions ¶
func BackendName ¶
BackendName names the driver behind b ("local", "s3", or "custom") — an informational label for operational UIs, never a behavioral switch.
func DeleteHandler ¶
func DeleteHandler(store *Store) http.HandlerFunc
DeleteHandler removes a stored file (metadata row + blob when unreferenced). RBAC has already required the `delete` action on the "files" resource.
func DownloadHandler ¶
func DownloadHandler(store *Store) http.HandlerFunc
DownloadHandler serves a stored file through the backend's strategy: the local driver proxies with http.ServeContent (Range/206, strong ETag/304, sendfile zero-copy); the S3 driver 302-redirects to a short-lived presigned URL (default) or proxies. It is deliberately served OUTSIDE the response cache (the cache middleware bypasses /api/files/… GETs) — a binary blob is unbounded and must never be buffered or cached in RAM.
func EnsureMetaTable ¶
EnsureMetaTable creates the tenant's files metadata table idempotently — the SAME DDL the store runs lazily on first upload. Exported (FILES-LINK-S1) so the migration engine can guarantee the table exists BEFORE adding a file field's foreign key to it (a schema may declare a `file` field for a tenant that has never uploaded anything).
func IsByteServingPath ¶
IsByteServingPath reports whether (method, path) is one of the routes that serve raw file BYTES — the complete set:
GET /api/files/{id} (tenant download)
GET /files/signed/{token} (signed-token download)
GET /admin/tenants/{id}/files/{fid}/download (Studio manager download)
These routes are routed AROUND the response-compression wrapper (FILES-BENCH finding: chi Compress's writer lacks io.ReaderFrom, which suppressed sendfile zero-copy on the shipped path — and compressing binary blobs was counterproductive anyway). The JSON file routes (upload result, /url, the manager listing) deliberately do NOT match — they stay compressible like the rest of the API.
func MintDownloadToken ¶
func MintDownloadToken(secret []byte, tenant, fileID, role string, ttl time.Duration) (string, error)
MintDownloadToken signs a short-lived, single-file download capability. role is the minting caller's RBAC role, re-checked against `read files` at serve time (a role whose grant was meanwhile revoked does not outlive the policy).
func NewMemStore ¶
func NewMemStore() metaStore
NewMemStore returns the in-memory metadata store — for TESTS and ephemeral tooling only (nothing survives the process). Exported so other packages' tests can build a files.Store without Postgres; production always uses NewPGStore.
func NewPGStore ¶
NewPGStore builds the Postgres-backed metadata store. The pool may be the engine's shared pool or a worker-owned pool.
func ProcessUpload ¶
func ProcessUpload(store *Store, maxBytes int64, tenantID string, w http.ResponseWriter, r *http.Request)
ProcessUpload is the shared multipart-upload core: it streams the "file" part of r into the store for tenantID and writes the HTTP result (201 with the handle; 413/422/400 on the engine's real rejections). UploadHandler (tenant from Host) and the platform-admin manager route (tenant from the authenticated admin path) both delegate here, so EVERY ingestion surface pays the identical OWASP validation and returns the identical errors.
func SanitizeName ¶
SanitizeName reduces a client filename to safe METADATA at rest (OWASP): the basename only (no path components), no control characters or header-breaking quotes, no leading dots (no dotfiles), ".." collapsed, capped length. It is never used to build a storage path (keys are content hashes) — this guards every OTHER consumer of the stored name (headers, UIs, exports). Empty in → empty out (a nameless upload is legitimate; serve-time falls back to "download").
func ServeStored ¶
ServeStored is the shared serve tail: the backend Serve strategy (local ServeContent proxy / S3 presigned 302) with the 404/500 mapping. Exported so the platform-admin manager route serves through the IDENTICAL path as the tenant download routes.
func SignedServeHandler ¶
func SignedServeHandler(store *Store, secret []byte, allowRead func(role string) bool) http.HandlerFunc
SignedServeHandler serves GET /files/signed/{token}: verifies the HMAC token (signature, expiry, claim shape), that the token's tenant matches the request's tenant (a token minted for tenant A is useless on tenant B's host), and that the embedded role STILL may read files (a revoked grant does not outlive the policy) — then streams through the same backend Serve as the authenticated download. EVERY failure is a uniform 404 (never 403): an invalid token must be indistinguishable from a missing file (anti-fingerprinting, OWASP).
func SignedURLHandler ¶
SignedURLHandler mints a short-lived signed download URL for a file the caller may read (tenant + JWT + RBAC `read files` already enforced by the chain). S3 backends answer with a NATIVE presigned URL (Signature v4, the bucket serves the bytes); the local backend answers with an engine-token URL (/files/signed/{token}, HMAC-signed, validated + RBAC-rechecked at serve). Response: {"url","expires_in"}.
func UploadHandler ¶
func UploadHandler(store *Store, maxBytes int64) http.HandlerFunc
UploadHandler streams a multipart upload to the store and returns the file handle. It runs AFTER the engine middleware chain (tenant → JWT → RBAC for the "files" resource), so it re-implements none of that — it only reads the resolved tenant and streams. The body is read with r.MultipartReader (no in-memory form parse); the OWASP validation (extension allowlist + magic bytes + size cap + name sanitization) happens inside Store.Put, and a rejected upload surfaces as 422 with the reason.
Types ¶
type Backend ¶
type Backend interface {
// Put streams r to storage under key. opts carries the object headers a
// storage service can persist (content type / disposition / cache-control);
// the local driver ignores them (headers come from the DB row at serve time).
Put(ctx context.Context, key string, r io.Reader, opts PutOptions) error
// Get opens the blob for reading. The reader supports Seek (Range serving).
// ErrNotFound if the key does not exist.
Get(ctx context.Context, key string) (io.ReadSeekCloser, error)
// Delete removes the blob. Deleting an absent key is a no-op (the Store has
// already committed the metadata delete; a torn state must not resurrect it).
Delete(ctx context.Context, key string) error
// Stat describes the blob, or ErrNotFound. The Store uses it for dedup
// (does this content already exist?).
Stat(ctx context.Context, key string) (ObjectInfo, error)
// List enumerates the blobs under a key prefix (an operational/GC surface,
// not a request-path one — listing for clients is a metadata query).
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
// Serve writes the blob as an HTTP response honoring Range / conditional
// headers. The local driver proxies via http.ServeContent (206, ETag,
// sendfile); the S3 driver redirects to a short-lived presigned URL by
// default (the FILES-V1 contract: authorize, never proxy the bytes) or
// proxies through ServeContent in "proxy" mode. info carries the
// authoritative metadata from the Store's DB row.
Serve(w http.ResponseWriter, r *http.Request, key string, info ServeInfo) error
// SignedURL returns a URL that grants access to the blob for expiry, minted
// by the STORAGE (S3 presigned, Signature v4). The local driver returns
// ErrSignedURLUnsupported: its signed access is an engine-minted HMAC token
// URL (pkg/files/token.go), built at the HTTP layer because only a request
// knows the tenant's public origin.
SignedURL(ctx context.Context, key string, expiry time.Duration) (string, error)
}
Backend is the thin, swappable blob-storage contract under the Store (FILES-V2, the PocketBase pattern: one owned interface, interchangeable drivers). Two drivers exist: LocalBackend (direct disk — os.File + http.ServeContent, the measured optimum for a VPS) and S3Backend (gocloud.dev s3blob — any S3-compatible provider via config: R2, Spaces, MinIO, AWS).
A Backend stores BYTES under validated CAS keys; everything above it — metadata (authoritative in the tenant's Postgres files table), tenancy checks, upload validation, hashing, dedup refcounts — is the Store's job and identical across drivers. That split is what makes the drivers truly interchangeable: the same conformance test suite passes on both.
Keys are always "<tenant>/<aa>/<bb>/<sha256-hex>" (see blobKey): every component is either a validated tenant id or content-hash hex, so no key ever carries client input — path traversal is structurally impossible.
type LocalBackend ¶
type LocalBackend struct {
// contains filtered or unexported fields
}
LocalBackend is the direct-disk driver: blobs at <root>/<key> (the FILES-V1 CAS layout, byte-compatible with existing deployments). It deliberately does NOT go through gocloud's fileblob: the storage-investigation benchmark showed the local ceiling IS the standard library (http.ServeContent over an *os.File → Range + ETag + sendfile zero-copy, ~24× less CPU than a manual copy path), and FILES-V1's atomic temp+rename write path is already the PocketBase end-state (they replaced gocloud with an owned implementation; we simply never take the detour for the driver that has nothing to gain from it).
func NewLocalBackend ¶
func NewLocalBackend(root string) *LocalBackend
NewLocalBackend builds the disk driver rooted at root (created lazily on the first write, so an engine that never stores a file touches no disk).
func (*LocalBackend) Delete ¶
func (l *LocalBackend) Delete(_ context.Context, key string) error
Delete removes the blob; an absent key is a no-op (see Backend.Delete).
func (*LocalBackend) Get ¶
func (l *LocalBackend) Get(_ context.Context, key string) (io.ReadSeekCloser, error)
Get opens the blob; *os.File is the ReadSeekCloser (Range serving native).
func (*LocalBackend) List ¶
func (l *LocalBackend) List(_ context.Context, prefix string) ([]ObjectInfo, error)
List walks the blobs under prefix (tenant or tenant/aa[/bb]).
func (*LocalBackend) Put ¶
func (l *LocalBackend) Put(ctx context.Context, key string, r io.Reader, _ PutOptions) error
Put streams r into the blob path through a temp file + rename (never a partial blob at the final path, even on a mid-stream failure).
func (*LocalBackend) PutFile ¶
func (l *LocalBackend) PutFile(_ context.Context, key, srcPath string) error
PutFile adopts a staged temp file as the blob via atomic rename — the zero- copy fast path the Store prefers when the backend offers it.
func (*LocalBackend) Root ¶
func (l *LocalBackend) Root() string
Root returns the base directory (operational introspection/logging).
func (*LocalBackend) Serve ¶
func (l *LocalBackend) Serve(w http.ResponseWriter, r *http.Request, key string, info ServeInfo) error
Serve proxies the blob with http.ServeContent: Range (206, video seeking), If-None-Match/If-Modified-Since (via the strong content-hash ETag), and — because the ReadSeeker is a real *os.File — the kernel sendfile zero-copy path. This IS the local ceiling; nothing custom to build (measured).
func (*LocalBackend) SignedURL ¶
SignedURL: local blobs are served BY the engine, so the signed handle is the engine's own short-lived token URL, minted at the HTTP layer (it needs the request's tenant origin). The backend signals that with this sentinel.
func (*LocalBackend) StagingDir ¶
func (l *LocalBackend) StagingDir(tenant string) (string, error)
StagingDir places upload staging INSIDE the tenant's directory so the final rename in PutFile is same-filesystem atomic (the FILES-V1 guarantee).
func (*LocalBackend) Stat ¶
func (l *LocalBackend) Stat(_ context.Context, key string) (ObjectInfo, error)
Stat describes the blob. The ETag is the content hash (the key's last segment) — strong by construction in a CAS.
type Meta ¶
type Meta struct {
ID string `json:"id"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
OriginalName string `json:"original_name"`
CreatedAt time.Time `json:"created_at"`
}
Meta is the stored record of one file: its id (the tenant-scoped handle clients and the file_ref use), the content hash, size, and the client descriptors.
type ObjectInfo ¶
ObjectInfo describes one stored blob.
type PutMeta ¶
PutMeta carries the client-supplied descriptors of an upload. NEITHER field is ever used to build a path on disk — the blob path is the content hash, so a hostile OriginalName like "../../etc/passwd" is inert metadata (see the path traversal guarantee in Local.Put).
type PutOptions ¶
type PutOptions struct {
ContentType string
ContentDisposition string
CacheControl string
// Size is the exact content length when known (uploads are staged and
// hashed before the backend Put, so it always is). -1 means unknown.
Size int64
}
PutOptions are object attributes a storage service persists with the blob and replays on direct (presigned) GETs.
type S3Backend ¶
type S3Backend struct {
// contains filtered or unexported fields
}
S3Backend stores blobs in any S3-compatible bucket via gocloud.dev/blob (the staged approach the storage investigation recommended: a proven portable driver first; an owned client only if binary weight is MEASURED as a problem — the PocketBase trajectory). Large uploads go through the SDK's transfer manager (automatic multipart); reads are ranged (the gocloud Reader seeks lazily, so ServeContent never downloads more than requested).
func NewS3Backend ¶
NewS3Backend opens the bucket. It fails loud on missing config — a misconfigured file store must stop boot, not surface as runtime 500s.
func (*S3Backend) Get ¶
Get opens a seekable reader over the object (seeks translate to ranged GETs lazily — no full download).
func (*S3Backend) Put ¶
Put streams r to the bucket. gocloud's writer hands large bodies to the S3 transfer manager (automatic multipart upload), so a 5 GiB object streams in parts without ever being buffered whole.
func (*S3Backend) Serve ¶
Serve delivers the object per the configured mode: redirect (302 to a short-lived presigned URL — the engine authorized, the bucket serves) or proxy (ServeContent over the lazy-seeking bucket reader — Range/ETag from the engine, bucket never exposed).
type S3Config ¶
type S3Config struct {
Bucket string
Endpoint string // empty ⇒ AWS S3 (the SDK default resolution)
Region string // empty ⇒ "auto" (R2's spelling; harmless elsewhere)
AccessKey string
SecretKey string
// ForcePathStyle addresses the bucket as <endpoint>/<bucket> instead of
// <bucket>.<endpoint> — required by MinIO, harmless on R2.
ForcePathStyle bool
// Prefix namespaces every key inside the bucket (default "tenants/"), so a
// shared bucket can carry other content without collision.
Prefix string
// ServeMode: redirect (default) or proxy — see S3ServeMode.
ServeMode S3ServeMode
}
S3Config is the PROVIDER-AGNOSTIC S3 configuration: endpoint + region + credentials + bucket + path-style covers Cloudflare R2 (the recommended default: $0 egress), DigitalOcean Spaces, self-hosted MinIO and AWS S3 — switching provider is a config change, never a code change.
type S3ServeMode ¶
type S3ServeMode string
S3ServeMode picks how GET /api/files/{id} delivers S3-stored bytes.
const ( // S3ServeRedirect (default) answers with a 302 to a short-lived presigned // URL: the engine AUTHORIZES (tenant + JWT + RBAC ran before the redirect) // but never proxies the bytes — the FILES-V1 contract. The client egresses // straight from the bucket (with R2 that egress is $0), and the engine is // never the bandwidth bottleneck. S3ServeRedirect S3ServeMode = "redirect" // S3ServeProxy streams the bytes THROUGH the engine (ServeContent over the // bucket reader): uniform headers, Range honored by the engine, and the // bucket never exposed to clients at all — at the cost of the bytes // transiting the engine (PocketBase's always-proxy trade-off). Pick it when // the bucket must stay fully private or clients cannot follow redirects. S3ServeProxy S3ServeMode = "proxy" )
type ServeInfo ¶
type ServeInfo struct {
ContentType string
ETag string // strong ETag: the content hash, quoted
ModTime time.Time
Filename string // already header-sanitized (safeFilename)
Size int64
}
ServeInfo is the authoritative response metadata for Backend.Serve, taken from the Store's DB row (never from storage or client input).
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the engine's file store (FILES-V2): the tenancy, metadata, upload validation, hashing and dedup logic — everything that must be IDENTICAL across storage drivers — over a swappable Backend that only moves bytes. It implements the FILES-V1 VFS contract, so every existing consumer (the upload/download routes, the XLSX worker) works unchanged on any backend.
func NewLocal ¶
NewLocal builds the disk-backed store (the FILES-V1 constructor, kept as the back-compat spelling: same signature, same on-disk layout, now via the Backend seam). root is created lazily on the first upload.
func NewStore ¶
func NewStore(b Backend, ms metaStore, opts ...StoreOption) *Store
NewStore builds the file store over an explicit backend.
func (*Store) Delete ¶
Delete removes the metadata row and, when no other row references the same blob (dedup), the blob itself. ErrNotFound if the id is unknown.
func (*Store) Get ¶
Get opens the file's content and returns it with its metadata. The reader supports Seek. ErrNotFound if the id is unknown to the tenant OR the metadata row exists but its blob is gone (a torn state, surfaced as not-found, never a 500).
func (*Store) ListMeta ¶
func (s *Store) ListMeta(ctx context.Context, tenant string, limit, offset int) ([]Meta, int, error)
ListMeta returns a page of the tenant's file metadata (newest first) and the total count — a METADATA query (the authoritative Postgres table), never a storage List call. It is the browse surface for operational UIs (the Studio files manager); a tenant with no files yet lists as empty.
func (*Store) Put ¶
Put streams r through the OWASP upload checks (extension allowlist + magic bytes on the first 512 bytes — the client Content-Type is never trusted), hashes as it streams to a staging file (never buffering the body in RAM), then commits the blob to the backend under its content-hash key — or reuses an existing identical blob (dedup). The client name is stored SANITIZED, as metadata only. Every Put yields a fresh metadata row (a new id), even over a deduplicated blob.
func (*Store) Serve ¶
Serve writes the file as an HTTP response through the backend's serving strategy (local: ServeContent proxy — Range/ETag/sendfile; S3: presigned 302 redirect, or proxy mode). Headers come from the DB row (authoritative), never from storage or the client.
func (*Store) SignedURL ¶
func (s *Store) SignedURL(ctx context.Context, tenant, id string, ttl time.Duration) (string, error)
SignedURL asks the BACKEND for a storage-minted URL valid for ttl (S3 presigned). ErrSignedURLUnsupported means the caller must mint the engine's own token URL instead (local backend).
type StoreOption ¶
type StoreOption func(*Store)
StoreOption configures NewStore.
func WithUploadPolicy ¶
func WithUploadPolicy(p UploadPolicy) StoreOption
WithUploadPolicy replaces the default upload-validation policy.
type UploadPolicy ¶
type UploadPolicy struct {
// contains filtered or unexported fields
}
UploadPolicy is the compiled upload-validation config: built once at boot, applied to every Put (all ingestion paths, not just HTTP).
func NewUploadPolicy ¶
func NewUploadPolicy(exts []string) UploadPolicy
NewUploadPolicy compiles an extension allowlist (entries with or without the leading dot, case-insensitive). nil/empty → DefaultAllowedExtensions; a single "*" allows every extension (magic-byte and declared-type checks still apply).
func (UploadPolicy) Validate ¶
func (p UploadPolicy) Validate(name, declaredCT string, head []byte) (string, error)
Validate applies the OWASP upload checks to one upload and returns the content type to STORE (the authoritative one — never blindly the client's):
- extension allowlist (on the sanitized name),
- magic bytes vs extension (http.DetectContentType over the first 512 bytes; conclusive disagreement rejects),
- magic bytes vs DECLARED type (a declared image/video/audio/pdf/zip whose content conclusively sniffs outside that family rejects).
The stored type is the declared one when it survives the checks (it is often more specific than the sniff — e.g. the openxml type over application/zip), else the sniffed type.
type VFS ¶
type VFS interface {
// Put streams r to storage (hashing as it streams — never buffering the whole
// body), records the metadata, and returns the stored Meta with its assigned
// id. Identical content is de-duplicated: the blob is written once, but each
// Put yields a distinct id (a new metadata row referencing the shared blob).
Put(ctx context.Context, tenantID string, r io.Reader, meta PutMeta) (Meta, error)
// Get opens the file's content for reading and returns its Meta. The caller
// MUST Close the returned reader. ErrNotFound if the id is unknown to tenant.
Get(ctx context.Context, tenantID, fileID string) (io.ReadCloser, Meta, error)
// Delete removes the metadata row and, when no other row references the same
// blob (dedup), the blob itself. ErrNotFound if the id is unknown.
Delete(ctx context.Context, tenantID, fileID string) error
// Exists reports whether the tenant already has a file with this content hash
// (a metadata-level check used to short-circuit a re-upload).
Exists(ctx context.Context, tenantID, sha256 string) (bool, error)
// URL returns a handle to fetch the file. Local returns the internal blob path
// (engine-only); S3 returns a short-lived presigned URL the client fetches
// directly (the engine never proxies the bytes).
URL(ctx context.Context, tenantID, fileID string) (string, error)
}
VFS is the file-store contract shared by the Local (disk CAS) and S3 backends. Every method is tenant-scoped — a file id is only meaningful within its tenant, so there is no cross-tenant handle.