adapter

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2026 License: AGPL-3.0 Imports: 30 Imported by: 0

Documentation

Overview

Package adapter contains the media bounded context's outbound adapters: PhotoRepository (Postgres), LocalPhotoStore and S3PhotoStore (the two domain.PhotoStore implementations), StoreResolver, and the shared upload-validation/EXIF helpers every PhotoStore backend uses.

The canonical "photo" table shape

PhotoRepository's SQL is schema-parameterized the same way Nestorage's own consolidation (NSTR-119) parameterizes every app-owned table: every identifier is UNQUALIFIED — "photo", never "<app>.photo" — and resolved through the caller's own connection search_path (set via nestcore/db's WithSearchPath). This is the opposite convention identity/adapter uses (which schema-qualifies "identity." explicitly, because the identity schema's NAME is fixed and known at compile time); a "photo" table's schema is NOT known here, since it lives in whichever application's own schema is calling — Nestova's, Nestorage's, or any future consumer's.

Every consumer therefore owns and migrates its OWN "photo" table, built to this EXACT shape (column names, types, and constraint names), so PhotoRepository's queries and constraint-violation mapping work identically regardless of which schema they run against:

CREATE TABLE photo (
    id               uuid        PRIMARY KEY,
    household_id     uuid        NOT NULL REFERENCES identity.household(id) ON DELETE CASCADE,
    storage_ref      text        NOT NULL,
    storage_backend  text        NOT NULL
        CONSTRAINT photo_storage_backend_check CHECK (storage_backend IN ('local', 's3')),
    content_sha256   text
        CONSTRAINT photo_content_sha256_format
        CHECK (content_sha256 IS NULL OR content_sha256 ~ '^[0-9a-f]{64}$'),
    size_bytes       bigint      NOT NULL,
    content_type     text        NOT NULL,
    taken_at         timestamptz,
    uploaded_by      uuid,
    created_at       timestamptz NOT NULL DEFAULT now(),
    -- Tenant consistency: an uploader must belong to the photo's OWN
    -- household, never another one identity itself would happily
    -- allow a bare uploaded_by REFERENCES identity.member(id) to miss.
    -- Targets identity.member's member_household_id_id_uniq (see that
    -- migration's own doc for the composite-FK pattern this mirrors);
    -- MATCH SIMPLE (Postgres's default) skips the check entirely when
    -- uploaded_by IS NULL, so an anonymous/system upload is unaffected.
    CONSTRAINT photo_uploaded_by_fkey FOREIGN KEY (household_id, uploaded_by)
        REFERENCES identity.member (household_id, id) ON DELETE SET NULL (uploaded_by)
);
CREATE UNIQUE INDEX photo_household_content_hash_uniq
    ON photo (household_id, content_sha256)
    WHERE content_sha256 IS NOT NULL;
CREATE INDEX photo_household_id_created_at_idx ON photo (household_id, created_at);
CREATE INDEX photo_storage_backend_id_idx ON photo (storage_backend, id);

The two plain (non-unique) indexes above match this package's own query shapes — ListByHousehold's household_id-filtered, created_at-ordered scan and ListByBackend/ListAllStorageRefs' storage_backend-filtered, id-ordered scan — so every consumer's migration should include them, though PhotoRepository has no way to enforce that the way it enforces the unique index (a missing plain index only costs query performance, never correctness).

household_id's foreign key must be declared inline (unnamed) so Postgres auto-names it photo_household_id_fkey — the name PhotoRepository's constraint mapping matches against (see constraints.go) — and cascades deletes, mirroring identity.member's own cascade from identity.household (a household delete must not be blocked by surviving photo rows). uploaded_by's foreign key, by contrast, is EXPLICITLY named photo_uploaded_by_fkey (constraints.go matches the same name either way) because it must be the composite (household_id, uploaded_by) form above, not a plain single-column reference — Postgres does not auto-name a multi-column FK usefully, so this one has to be spelled out. It also sets NULL only, not CASCADE, on delete: Photo.UploadedBy is documented as "nilled (not deleted) if that member is removed so the photo survives", and ON DELETE SET NULL (uploaded_by) is what makes that true — a plain CASCADE on this column would delete the photo along with its uploader, which is exactly the outcome that doc line rules out.

storage_backend's CHECK restricts it to domain.StorageBackend's own known values, and content_sha256's CHECK enforces the same 64-character lowercase-hex-sha256 shape domain.Photo.Validate and PhotoRepository.MigrateStorageBackend's own argument validation expect — both mirror Nestova's original photo_storage_backend_check and photo_content_sha256_format constraints, so a row that violates either can only originate from a bypass of this package's own write paths, not from an ordinarily-configured deployment. content_sha256 is nullable (a legacy photo predating content-hash dedup never matches a duplicate check); the partial unique index's WHERE clause is what makes that safe. An application is free to add its OWN extra columns (e.g. a presentation-layer caption or an app-specific foreign key) beyond this shape — PhotoRepository's queries name every column explicitly and never SELECT *, so additional columns never interfere.

Compatibility

PhotoStore and PhotoRepository (media/domain) are shared API consumed by independently versioned binaries — additive-only, mirroring identity's own rule (see identity/migrate's package doc). A new capability arrives as a new, narrowly-scoped interface (ObjectLister, ObjectExister, RawObjectWriter) a caller type-asserts for, never as a widened method set on PhotoStore or PhotoRepository themselves. The canonical table shape above is likewise additive-only from this package's side: a future column this package starts reading would be a breaking change for every consumer's existing migration, so any such change ships as a new, separately-typed capability instead.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildStorageKey

func BuildStorageKey(householdID identity.HouseholdID, class domain.PhotoClass, sum, ext string) (string, error)

BuildStorageKey exposes buildStorageKey's shared class-namespaced, content-addressed key formula to a storage migrator/verifier, which must derive the EXACT SAME key S3PhotoStore.Put would have produced for a photo's household/class/content-hash/content-type, without duplicating the formula or re-running Put's full upload-validation pipeline a second time (the bytes were already validated once, at original upload — see RawObjectWriter's doc).

func ExtensionForContentType

func ExtensionForContentType(contentType string) (ext string, ok bool)

ExtensionForContentType returns the stored-object file extension acceptedTypes maps contentType to, and ok=false when contentType is not one of the three accepted image types — the same lookup Put uses internally, exposed for a storage migrator, which must derive a row's canonical storage key (via BuildStorageKey) from its already server-verified content_type column without re-sniffing the bytes.

Types

type ExifReader

type ExifReader struct{}

ExifReader is a domain.ExifReader that reads the EXIF capture time from a photo's bytes using the xor-gate/goexif2 fork. EXIF parsing runs over untrusted upload bytes, so TakenAt is hardened to never crash on malformed input.

func NewExifReader

func NewExifReader() ExifReader

NewExifReader returns an ExifReader.

func (ExifReader) Scrub

func (ExifReader) Scrub(data []byte, orientation int) ([]byte, error)

Scrub removes every EXIF tag from a JPEG's bytes, never just the GPS IFD.

Deliberate, documented tradeoff: goexif2 is read-only, and a surgical "remove only the GPS IFD, keep everything else" rewrite would mean hand-editing a TIFF directory structure in place (patching an IFD's entry count and byte offsets after deleting entries) — real work, and still leaves every OTHER EXIF field (camera make/model/serial number, software version, etc.) on a photo whose privacy motivation is not limited to GPS. Stripping the WHOLE EXIF APP1 segment is simpler, strictly more private, and is what this function does for the common case (Orientation already 1 or 0): stripJPEGExif below removes the "Exif\0\0"-signed APP1 segment at the byte level, leaving every other JPEG segment (and the entropy-coded scan data) untouched — no re-compression, no quality loss.

The one piece of EXIF metadata that visibly matters for a STORED photo is Orientation: a camera held sideways writes upright pixel data and just declares Orientation != 1 rather than rotating the pixels itself, so a blind whole-segment strip would make the stored photo display sideways (nothing left to tell a viewer to rotate it). When orientation is anything other than 1 (already upright) or 0 (unknown — nothing to correct), Scrub instead fully decodes the image and re-encodes it with the orientation baked into the pixel data (reencodeUpright below) — heavier (one JPEG re-compression pass, at reencodeQuality) but only for the minority of uploads that actually need rotating, and it also strips ALL metadata as a side effect, since Go's jpeg.Encode never writes EXIF.

func (ExifReader) TakenAt

func (ExifReader) TakenAt(r domain.RandomAccessReader) (taken *time.Time)

TakenAt returns the photo's EXIF capture time normalized to UTC, or nil when the image carries no usable EXIF date. A missing tag, an undecodable EXIF block, or a panic deep in the parser on malformed input is not an error — the photo is simply stored without a taken_at. The recover guard ensures a crafted image cannot crash the caller through the third-party parser. r must support random access because EXIF/TIFF fields are addressed by absolute byte offsets; goexif2 seeks directly to the EXIF/TIFF segment it locates rather than buffering the whole input up front, though on an image carrying no EXIF block at all it may scan sequentially through the entire reader looking for one. Passing a domain.PhotoReader straight through (as PhotoService does) still avoids a separate in-memory buffering step either way.

func (ExifReader) TakenAtAndOrientation

func (ExifReader) TakenAtAndOrientation(data []byte) (taken *time.Time, orientation int)

TakenAtAndOrientation returns the EXIF capture time (UTC) and the Orientation tag from a JPEG's already-buffered bytes, or (nil, 0) when the bytes carry no decodable EXIF at all. Hardened against a crafted image the same way TakenAt is: a panic deep in the third-party parser on malformed input is recovered, never propagated.

Capture time comes from DateTimeOriginal ONLY — no DateTime/ DateTimeDigitized fallback, unlike TakenAt (which prefers DateTimeOriginal but falls back to DateTime): a caller using this stricter variant typically has a freshness/provenance requirement (e.g. gating that a photo was just taken with a camera) that a DateTime fallback would undermine.

No EXIF OffsetTimeOriginal (tag 0x9011) handling is attempted: goexif2 v1.1.0's internal tag table only loads tag ids it explicitly maps, so OffsetTimeOriginal is unreachable through the library's public API. A naive (offset-less) EXIF timestamp is therefore interpreted in the server's own local time zone — exactly what goexif2's own DateTime() already falls back to (it also honors a Canon MakerNote timezone field when present, which this function keeps by reusing the same tag lookup DateTime() performs). A caller relying on this must be a single-timezone deployment where the server's own local timezone IS the correct interpretation.

type LocalPhotoStore

type LocalPhotoStore struct {
	// contains filtered or unexported fields
}

LocalPhotoStore is a domain.PhotoStore backed by the local filesystem. Photos are content-addressed (sha256) under MEDIA_ROOT/households/<household>/<class>/<aa>/<hash>.<ext> (see classKeyPrefix), so identical bytes uploaded for the same domain.PhotoClass de-duplicate on disk, a ref never collides across households, and — the reason the class segment exists — bytes uploaded under one class can never collide with, or be resolved as, another class's bytes even if the content happens to be byte-identical.

It is constructor-injected and swappable for an object-store adapter.

func NewLocalPhotoStore

func NewLocalPhotoStore(root string, maxUploadBytes int64) (*LocalPhotoStore, error)

NewLocalPhotoStore returns a store rooted at root (created if missing), rejecting a blank root or a non-positive size cap.

func (*LocalPhotoStore) Delete

Delete removes a stored photo; a missing file is not an error (idempotent).

func (*LocalPhotoStore) Open

Open streams a stored photo's bytes; ErrPhotoNotFound when the ref is unknown. The returned *os.File natively satisfies domain.PhotoReader (Read, ReadAt, Seek, Close), which lets EXIF extraction read directly from disk instead of first buffering the file into memory.

func (*LocalPhotoStore) Put

Put streams r to a staging file under root via validateAndStage — never buffering the whole upload in memory, sniffing the true content type from the bytes themselves (the caller never supplies one; it is not trusted), cross-validating that the bytes actually decode as that type — then atomically renames the staged file into its content-addressed, class-namespaced home (see buildStorageKey). Any rejection — including an unregistered class — removes the staging file, leaving no partial upload behind.

func (*LocalPhotoStore) SupportsDirectURL

func (s *LocalPhotoStore) SupportsDirectURL() bool

SupportsDirectURL always reports false: LocalPhotoStore's URL never returns a browser-navigable locator (see URL's own doc), so no caller may redirect a client to it.

func (*LocalPhotoStore) URL

URL confirms ref resolves to a stored object and returns ref's own string as a stable, non-navigable locator, or ErrPhotoNotFound when ref is unknown; ttl is ignored (see the domain.PhotoStore.URL doc for why a local backend cannot honestly return a browser-navigable URL from ref alone).

type PhotoRepository

type PhotoRepository struct {
	// contains filtered or unexported fields
}

PhotoRepository is the pgx-backed domain.PhotoRepository. Every query names the "photo" table UNQUALIFIED, resolved through the caller's own connection search_path — see the package doc for the canonical table shape this requires each consumer's own migration to provide. UUIDs are passed and scanned as text, matching nestcore/identity's adapter convention (no pgx UUID codec registration).

func NewPhotoRepository

func NewPhotoRepository(dbtx db.TX, backend domain.StorageBackend) *PhotoRepository

NewPhotoRepository constructs the repository with an injected query executor, bound to backend — the SAME domain.StorageBackend the composition root selected for the running domain.PhotoStore: Create writes backend into every row's storage_backend column itself, never relying on the column's DEFAULT, so the column always reflects which backend genuinely wrote the bytes. Panics on a nil dbtx or an invalid backend.

func (*PhotoRepository) Create

func (r *PhotoRepository) Create(ctx context.Context, photo *domain.Photo) error

Create inserts a photo and populates its created_at, mapping an unknown household to identity.ErrHouseholdNotFound, an unknown uploader to identity.ErrMemberNotFound, and a content hash that collides with another household photo to domain.ErrDuplicatePhoto.

storage_backend is written from r.backend (the repository's own configured backend — see NewPhotoRepository's doc), NOT from photo.StorageBackend: which backend actually wrote the bytes is a fact this repository instance already knows, not something the caller supplies, so Create also stamps the value back onto photo.StorageBackend on success (mirroring how it populates photo.CreatedAt).

func (*PhotoRepository) Delete

func (r *PhotoRepository) Delete(ctx context.Context, id domain.PhotoID) error

Delete removes the photo, returning domain.ErrPhotoNotFound when the id is unknown.

func (*PhotoRepository) ExistsByStorageRef

func (r *PhotoRepository) ExistsByStorageRef(ctx context.Context, ref domain.StorageRef, backend domain.StorageBackend) (bool, error)

ExistsByStorageRef reports whether any photo row STAMPED WITH backend currently references ref.

func (*PhotoRepository) FindByContentHash

func (r *PhotoRepository) FindByContentHash(ctx context.Context, householdID identity.HouseholdID, hash string) (*domain.Photo, error)

FindByContentHash returns the household's photo carrying the given content hash, or domain.ErrPhotoNotFound when none matches — the expected "not a duplicate" outcome for a genuinely new upload, not an exceptional one. hash must be non-blank; a blank hash can never match (a stored content_sha256 is always a 64-character lowercase hex sha256), so this short-circuits to ErrPhotoNotFound rather than issuing a query.

func (*PhotoRepository) Get

Get returns the photo, or domain.ErrPhotoNotFound.

func (*PhotoRepository) ListAllStorageRefs

func (r *PhotoRepository) ListAllStorageRefs(ctx context.Context, backend domain.StorageBackend) ([]domain.StorageRef, error)

ListAllStorageRefs returns the StorageRef of every photo row stamped with backend, across every household, or an empty slice when there are none.

func (*PhotoRepository) ListByBackend

func (r *PhotoRepository) ListByBackend(ctx context.Context, backend domain.StorageBackend, afterID domain.PhotoID, limit int) ([]*domain.Photo, error)

ListByBackend returns up to limit photo rows stamped with backend, ordered by id ascending, whose id is strictly greater than afterID. Returns an error for a non-positive limit rather than issuing a query LIMIT 0 (silently empty) or a negative LIMIT (a Postgres error the caller would otherwise have to decode).

func (*PhotoRepository) ListByHousehold

func (r *PhotoRepository) ListByHousehold(ctx context.Context, householdID identity.HouseholdID) ([]*domain.Photo, error)

ListByHousehold returns the household's photos ordered by creation time (id as the tiebreaker for rows sharing an identical created_at), or an empty slice when none exist.

func (*PhotoRepository) MigrateStorageBackend

func (r *PhotoRepository) MigrateStorageBackend(ctx context.Context, id domain.PhotoID, newRef domain.StorageRef, newBackend domain.StorageBackend, contentHash string) (bool, error)

MigrateStorageBackend flips a local-backend photo row onto newBackend, writing newRef as its new storage_ref and, ONLY when the row's content_sha256 is currently NULL, backfilling it with contentHash. A non-blank contentHash must be a well-formed content hash (a blank one is the legitimate "nothing to backfill" case, mapped to SQL NULL by nullableText and left alone by the COALESCE below) — both arguments are validated here because this method is the one write path the shared EXIF/upload validation pipeline never runs through: a bad newBackend or contentHash would otherwise persist silently and only surface later, as a scan failure on this or any other row in the same ListByHousehold page (see PhotoRepository's own doc on ParseStorageBackend's boundary check).

type S3Params

type S3Params struct {
	// Endpoint is the S3-compatible API's base URL; blank targets real AWS
	// S3's regional default endpoint. A custom endpoint (MinIO/Garage on
	// the LAN, or Cloudflare R2) is a first-class target, not an
	// afterthought.
	Endpoint string
	// Region is required (AWS S3 needs a real one; most self-hosted
	// S3-compatible servers accept any non-empty value).
	Region string
	// Bucket is the single bucket every photo (every class) is stored
	// under.
	Bucket string
	// AccessKeyID / SecretAccessKey are optional static credentials; when
	// both are blank, the AWS SDK's default credential chain supplies
	// credentials instead.
	AccessKeyID     string
	SecretAccessKey string
	// UsePathStyle forces path-style bucket addressing, required by MinIO
	// and most self-hosted S3-compatible servers.
	UsePathStyle bool
	// PresignTTL is URL's applied default when a caller passes a
	// non-positive ttl.
	PresignTTL time.Duration
	// MaxUploadBytes caps a single photo upload, mirroring
	// LocalPhotoStore's identical cap — the same operator-configured limit
	// applies regardless of backend.
	MaxUploadBytes int64
}

S3Params configures NewS3PhotoStore. It mirrors an application's own S3 config field-for-field but is its own type: this adapter depends on configuration only through the composition root passing plain values in, never by importing any application's own config package directly (DIP).

type S3PhotoStore

type S3PhotoStore struct {
	// contains filtered or unexported fields
}

S3PhotoStore is a domain.PhotoStore (and domain.ObjectLister) backed by an S3-compatible object store — AWS S3, or a self-hosted MinIO/Garage endpoint on the LAN, or Cloudflare R2. Photos use the identical class-namespaced, content-addressed key layout LocalPhotoStore uses (see buildStorageKey) — StorageRef IS the S3 object key verbatim, so a photo's ref means the same thing regardless of which backend stored it.

Put stages every upload to a local temp file first (see validateAndStage), applying the EXACT same validation LocalPhotoStore.Put does, then uploads the validated file. Open buffers the complete object into memory, bounded by MaxUploadBytes (a GetObject response body is sequential-read only, but domain.PhotoReader needs ReadAt/Seek for EXIF extraction, and every genuinely-stored photo is already capped at MaxUploadBytes by Put). Delete never errors on a missing key (S3 DeleteObject is idempotent by design), mirroring LocalPhotoStore.Delete's identical contract.

func NewS3PhotoStore

func NewS3PhotoStore(ctx context.Context, params S3Params) (*S3PhotoStore, error)

NewS3PhotoStore builds an S3PhotoStore against params and verifies the configured bucket is reachable (HeadBucket) before returning, so a misconfigured endpoint, bucket, or credentials fails the boot loudly here rather than surfacing as an opaque error on a household's first photo upload.

func (*S3PhotoStore) Delete

func (s *S3PhotoStore) Delete(ctx context.Context, ref domain.StorageRef) error

Delete removes ref's object; a missing key is not an error (S3 DeleteObject is idempotent), mirroring LocalPhotoStore.Delete.

func (*S3PhotoStore) ListObjects

func (s *S3PhotoStore) ListObjects(ctx context.Context, class domain.PhotoClass) ([]domain.ObjectInfo, error)

ListObjects returns every object stored under class's namespace, across every household (domain.ObjectLister; a storage reaper's source of truth). The key layout (buildStorageKey: households/<household>/<class>/ ...) puts <household> BEFORE <class>, so no single S3 ListObjectsV2 Prefix can select "every household's objects of one class" directly — this lists the whole households/ tree and filters by class client-side (see keyBelongsToClass). For a single-household appliance's expected scale (not a multi-tenant SaaS bucket with millions of objects), a full-tree scan per reaper pass is an acceptable cost.

func (*S3PhotoStore) ObjectExists

func (s *S3PhotoStore) ObjectExists(ctx context.Context, ref domain.StorageRef) (bool, error)

ObjectExists reports whether ref is already stored (an S3 HeadObject, verbatim) without downloading it (domain.ObjectExister) — a storage migrator's idempotency check before uploading a content-addressed object a different row's migration may have already written at the same key (see PutAt's doc).

func (*S3PhotoStore) Open

Open fetches ref and buffers it fully into memory (see the type doc for why), returning domain.ErrPhotoNotFound when the key does not exist.

func (*S3PhotoStore) Put

Put validates and stages r exactly as LocalPhotoStore.Put does (see the type doc), then streams the staged file to S3 via the manager.Uploader (multipart for a large file, single PUT otherwise), requesting SSE-S3 (AES256) only against real AWS S3 (see the requestSSE field doc).

func (*S3PhotoStore) PutAt

func (s *S3PhotoStore) PutAt(ctx context.Context, ref domain.StorageRef, contentType string, r io.Reader) error

PutAt uploads r's bytes to ref verbatim (domain.RawObjectWriter) — no content sniffing, decode-validation, or hashing, since the caller has already done all of that once (see RawObjectWriter's doc for why this, not Put, is what a storage migrator calls). Mirrors Put's own SSE-S3/Cache-Control handling exactly, so an object the migrator writes is indistinguishable from one a normal upload would have produced at the same key.

func (*S3PhotoStore) SupportsDirectURL

func (s *S3PhotoStore) SupportsDirectURL() bool

SupportsDirectURL always reports true: S3PhotoStore's URL returns a real, browser-navigable presigned GET a caller may safely redirect a client to.

func (*S3PhotoStore) URL

URL confirms ref exists (HeadObject — presigning alone never verifies existence, and the port's contract requires ErrPhotoNotFound for an unknown ref, mirroring LocalPhotoStore.URL's os.Stat check) and returns a presigned GET URL valid for ttl, or s.presignTTL when ttl is non-positive.

type StoreResolver

type StoreResolver struct {
	// contains filtered or unexported fields
}

StoreResolver is the map-backed domain.PhotoStoreResolver: a fixed set of PhotoStore instances, one per StorageBackend the composition root actually constructed — see that port's doc for why reads resolve by a row's own persisted backend rather than the deployment's current write target.

func NewStoreResolver

func NewStoreResolver(stores map[domain.StorageBackend]domain.PhotoStore) *StoreResolver

NewStoreResolver constructs a StoreResolver over stores, panicking if stores is empty or contains a nil entry, or an entry keyed by an invalid StorageBackend — every registered store must be genuinely usable, since Resolve hands the caller whatever is registered without a second check. stores is copied defensively so the caller's map cannot be mutated out from under the resolver after construction.

func (*StoreResolver) Resolve

func (r *StoreResolver) Resolve(backend domain.StorageBackend) (domain.PhotoStore, error)

Resolve returns the PhotoStore registered for backend, or domain.ErrStoreNotConfigured when this deployment never constructed one for it.

Jump to

Keyboard shortcuts

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