files

package
v0.1.16 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 34 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
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

func BackendName(b Backend) string

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

func EnsureMetaTable(ctx context.Context, pool *pgxpool.Pool, tenant string) error

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

func IsByteServingPath(method, path string) bool

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

func NewPGStore(pool *pgxpool.Pool) metaStore

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

func SanitizeName(name string) string

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

func ServeStored(store *Store, tenantID, id string, w http.ResponseWriter, r *http.Request)

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

func SignedURLHandler(store *Store, secret []byte, ttl time.Duration) http.HandlerFunc

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.

func VerifyDownloadToken

func VerifyDownloadToken(secret []byte, token string) (tenant, fileID, role string, err error)

VerifyDownloadToken validates signature (HS256 pinned — alg confusion rejected), expiry, and claim shape. Any failure is ErrBadToken.

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

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

type ObjectInfo struct {
	Key         string
	Size        int64
	ContentType string
	ModTime     time.Time
	ETag        string
}

ObjectInfo describes one stored blob.

type PutMeta

type PutMeta struct {
	ContentType  string
	OriginalName string
}

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

func NewS3Backend(ctx context.Context, cfg S3Config) (*S3Backend, error)

NewS3Backend opens the bucket. It fails loud on missing config — a misconfigured file store must stop boot, not surface as runtime 500s.

func (*S3Backend) Close

func (b *S3Backend) Close() error

Close releases the bucket's connections.

func (*S3Backend) Delete

func (b *S3Backend) Delete(ctx context.Context, key string) error

Delete removes the object; an absent key is a no-op.

func (*S3Backend) Get

func (b *S3Backend) Get(ctx context.Context, key string) (io.ReadSeekCloser, error)

Get opens a seekable reader over the object (seeks translate to ranged GETs lazily — no full download).

func (*S3Backend) List

func (b *S3Backend) List(ctx context.Context, prefix string) ([]ObjectInfo, error)

List enumerates the objects under prefix.

func (*S3Backend) Put

func (b *S3Backend) Put(ctx context.Context, key string, r io.Reader, opts PutOptions) error

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

func (b *S3Backend) Serve(w http.ResponseWriter, r *http.Request, key string, info ServeInfo) error

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

func (*S3Backend) SignedURL

func (b *S3Backend) SignedURL(ctx context.Context, key string, expiry time.Duration) (string, error)

SignedURL mints a native presigned GET (Signature v4) valid for expiry.

func (*S3Backend) Stat

func (b *S3Backend) Stat(ctx context.Context, key string) (ObjectInfo, error)

Stat describes the object, or ErrNotFound.

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

func NewLocal(root string, store metaStore) *Store

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

func (s *Store) Backend() Backend

Backend exposes the underlying driver (wiring/logging introspection).

func (*Store) Delete

func (s *Store) Delete(ctx context.Context, tenant, id string) 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.

func (*Store) Exists

func (s *Store) Exists(ctx context.Context, tenant, sha string) (bool, error)

Exists reports whether the tenant already stored content with this hash.

func (*Store) Get

func (s *Store) Get(ctx context.Context, tenant, id string) (io.ReadCloser, Meta, error)

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

func (s *Store) Put(ctx context.Context, tenant string, r io.Reader, pm PutMeta) (Meta, error)

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

func (s *Store) Serve(w http.ResponseWriter, r *http.Request, tenant, id string) error

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

func (*Store) Stat

func (s *Store) Stat(ctx context.Context, tenant, id string) (Meta, error)

Stat returns the metadata row (the authoritative record) for id.

func (*Store) URL

func (s *Store) URL(ctx context.Context, tenant, id string) (string, error)

URL returns an engine-internal handle: the on-disk blob path for the local backend (FILES-V1 behavior, kept), or a storage presigned URL (default TTL) for backends that mint them. Client-facing signed access goes through SignedURL / the /api/files/{id}/url route instead.

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

  1. extension allowlist (on the sanitized name),
  2. magic bytes vs extension (http.DetectContentType over the first 512 bytes; conclusive disagreement rejects),
  3. 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL