Architecture
Single-instance design, layers and dependency direction, the data model, upload and access data flows, the outbox fence, periodic tasks, and why rclone is integrated in-process.
Single-instance design
ImgYoung is deliberately designed as a single instance: one process, one data directory.
- The metadata database is one SQLite file (
imgyoung.dbin the data directory, in WAL mode). Writes queue on a single connection and reads use a pool; the driver is the pure-Gomodernc.org/sqlite. - The task queue lives in the same database: asynchronous work runs on River, whose job tables sit in the same SQLite file as the business tables, so no separate queue service is needed.
- All other state is local: the upload concurrency gate, per-user upload rate limits, login rate limiting, and access activity aggregation live in process memory, and the variant cache lives on local disk. There is no Redis.
- One static binary: everything is built with
CGO_ENABLED=0, and the admin console's frontend assets and the OpenAPI document are compiled into the binary.
As a result, ImgYoung does not support several processes sharing one data directory, nor horizontal scaling. The path to high availability is replacing the database with PostgreSQL (River supports PostgreSQL itself, so the task queue moves along with it) and moving the in-process shared state out at that point; the current code only implements SQLite.
Layers and dependency direction
http ──→ core ──→ infra
│ └────→ store
└─────────────→ infra| Layer | Directory | Responsibility |
|---|---|---|
| store | store/ | The public storage engine: the driver interface, the rclone implementation, and the failover primitives. See Using it as a Go library |
| infra | internal/infra/ | Infrastructure and outbound adapters: config (bootstrap and runtime settings), db (schema, queries, migrations, sqlc-generated code), secret (master key and encryption), id (short IDs), imaging (image processing), cdn, urlfetch (fetching for URL imports), observability (metrics), version |
| core | internal/core/ | Business core: channel (channels and health checks), replication (replication, reconciliation, draining, CDN purge), upload, variants (on-demand processing), dedup, lifecycle, reliability (backup and restore), auth, audit, referer, usage, alert |
| http | internal/http/ | Inbound HTTP: publicapi (uploads and image access), s3api (the S3 protocol surface), adminapi (admin console), middleware, httpx, router |
| Composition root | cmd/imgyoung/ | build.go constructs the object graph by hand in the order infrastructure → services → HTTP, serve.go orchestrates startup and shutdown with oklog/run, and loops.go holds the periodic loops |
store and infra are two parallel leaves: store depends on no internal/ package, and infra depends on neither
core, store, nor http; core depends on both. The dependency direction is enforced by golangci-lint's depguard rules,
and nothing merges while CI is red:
| Rule | Must not import | Why |
|---|---|---|
store/** | Any package under internal/ | store is a public, reusable storage engine and cannot depend on application code |
internal/infra/** | internal/core, store, internal/http | infra is the infrastructure leaf |
internal/core/** | internal/http | The business core knows nothing about transport and can be tested without HTTP |
internal/http/** | store | Channel selection and failover belong to core; the transport layer only uses the results core gives it |
Two more conventions:
- Interfaces are defined by their consumers: each package declares small interfaces with only the methods it uses
(such as
upload.ChannelSelectorandpublicapi.BlobChannels), satisfied implicitly by concrete types.store/driver.Driveris the only first-class abstraction. - Identity is passed explicitly: middleware resolves the authenticated principal into the request context, and the handler takes it out and passes it to core as an argument; core never reads identity from the context, and per-user isolation is enforced in core.
HTTP handlers and River workers are thin adapters that translate an external trigger into a service call. Periodic
tasks are Run(ctx, wait) loops owned by each service and orchestrated by the composition root; there is no separate scheduler layer.
Data model
At the heart of the data model is the separation of physical images from upload references:
images (references) ──→ blobs (physical images) ──→ blob_channels (replicas per channel) ──→ channels
N : 1 1 : N N : 1blobsare the bytes actually stored. Every upload creates a new blob; after background deduplication merges byte-identical blobs, one blob can be referenced by several images. A blob records thesha256of the sanitized bytes, the perceptual hashphash, the object key inside channelsstorage_key(of the form{short_id}_{first 8 hex chars of sha256}), MIME type, size, dimensions, the reference countref_count, and the deletion markers.imagesare the references created by uploads; the public 10-character short ID is the primary key. Owner, source token, S3 object key alias, expiry time and access activity, the cold-image label, and cleanup exemption all live on the reference, not on the blob.blob_channelsrecords a blob's replica on a channel and doubles as the state of its replication task:pending → uploading → done / failed, plus the attempt count and the last error.
Keeping the two layers apart puts each concern in its place: merging duplicates just repoints references at the surviving blob; TTL, cold images, and quotas are computed per reference; replicas, replication, and physical deletion work per blob.
Reference counting: every change to ref_count is an atomic UPDATE … SET ref_count = ref_count ± 1. Deleting a
reference and decrementing the count happen in the same transaction. A blob whose count reaches zero is marked for
deletion with a physical deletion time based on the runtime setting delete_grace_minutes. When that time comes, the
cleanup task first removes its variant cache, then deletes the business rows in one transaction while re-checking the
deletion condition (if the blob was referenced again in the meantime, the deletion is abandoned), and finally queues an
object deletion job for every channel holding a replica.
The remaining tables:
| Table | Contents |
|---|---|
channels | Channels: rclone backend name, parameter JSON encrypted with the master key (AES-GCM), priority, health flag and latest check result, whether encrypted, whether direct links are allowed, the being-cleaned-up flag; the creation time created_at (which, together with the channel ID, feeds key derivation for encrypted channels and never changes) and the binding time bound_at (see reconciliation GC) |
variants | Index of the variant cache: one row per blob and processing parameter set; the bytes live on local disk and are evicted LRU by last access |
settings | Runtime settings in a single-row wide table; each column's DEFAULT is the factory value. See Configuration |
users, tokens, sessions | Users and quotas, upload tokens (hashes only), admin sessions (only a hash of the session id plus its bound CSRF token) |
idempotency_keys | Upload idempotency keys, scoped per user |
dedup_candidates | Near-duplicate candidate pairs, the work queue for manual decisions |
referer_whitelist, audit_log, alerts | The hotlink protection allowlist, audit log of admin actions, and alert records |
master_key_fingerprint | A single row holding a fingerprint derived from the master key (it cannot be reversed into the key); the master key is checked against it at every start, and a missing or mismatched key refuses to start. See Master key |
The schema is defined by declarative SQL under internal/infra/db/schema/, one file per table, with a comment on every
table and every column. Versioned migrations are generated from it by Atlas, embedded in the binary, and applied in
order at startup; applied versions are recorded in the schema_revisions table, so upgrading across several versions
also applies every migration in between.
Migrations cannot be downgraded: if the database records a migration version this binary does not know (meaning a newer binary has already migrated it), the process refuses to start, and the error names the latest migration version in the database and the latest one this binary knows. The only way back is "old binary + a snapshot taken before the upgrade"; see Upgrading.
Upload data flow
Data plane uploads, admin console uploads, URL imports, and S3 PutObject differ only in where the bytes come from; once the bytes are in hand they all go through the same pipeline. The upload path does not deduplicate: every upload creates a new blob and deduplication happens in the background, which keeps the upload path a straight line with no branches.
Entry and guards: authenticate (upload token, session, or S3 signature) and pass two gates: the per-user upload
rate limit (upload_rate_per_min) and the global upload concurrency gate (max_concurrent_uploads, which returns 429
immediately when full). The image bytes are then read fully into memory; no upload of any size is written to a
temporary file. The per-image limit max_upload_mb applies to the image itself: a file exactly at the limit is
accepted, and anything larger returns 413. A multipart request body is additionally capped at "per-image limit + 64 KiB",
and only three parts are read — file, exif_policy, and ttl. exif_policy and ttl are taken only from form
fields (never from URL query parameters), at most 1 KiB each, or the request gets a 400.
Idempotency: if Idempotency-Key is present and the same user has already used that key, the existing image is returned.
Format and pixel budget: the real format is decided by the magic bytes at the start of the file, and only JPEG,
PNG, GIF, and WebP are accepted. Only the image header is read to get the dimensions, and they are cross-checked
against the sniffed format; if width × height exceeds max_pixels_mp the upload is rejected without a full decode.
Decoded memory depends on resolution, not on file size, so this gate has to come before decoding.
Quota pre-check: quota is charged in full per reference; exceeding the user's quota returns 403 and nothing is written to channels.
Sanitizing: with the EXIF policy strip, metadata is removed (JPEG, PNG, WebP; GIF has no standard metadata segment and passes through unchanged).
For JPEG, a minimal Exif segment holding only the orientation is added back, so photos that rely on the orientation
tag to display upright do not end up rotated; GPS, camera model, timestamps, and every other field are still removed,
and nothing is added back when the orientation is the default.
Hashing: one full decode computes the perceptual hash (pHash), then SHA-256 is computed over the bytes that will be
stored. Sanitizing happens before hashing, so sha256 always matches the bytes actually stored in channels.
Writing to channels: the object key is {short_id}_{first 8 hex chars of sha256}; embedding the short ID is the
last resort for rebuilding from storage if the metadata database is lost entirely. failover.Write writes to the
healthy channels in priority order until min_success copies succeed, with a 10-minute timeout per write, moving on to
the next channel on failure.
Recording: one transaction writes the blob, the reference, a replica row for every active channel (done for the
ones written, pending for the rest), and the idempotency key.
Queuing replication: replication jobs are queued for the pending channels and the upload returns. If queuing
fails, it is only logged; reconciliation picks it up.
The whole upload happens in memory without temporary files. Peak memory is roughly "concurrency × max(per-image size limit, decoded memory at the pixel budget)", so lowering these settings adapts ImgYoung to small machines. If the channel write succeeds but recording fails, an orphan object is left behind; reconciliation GC reclaims it, and the channel write is never rolled back.
Access data flow
GET / HEAD /i/{id} (and the S3 object key alias /o/{user_id}/{key}; the admin console preview
/api/admin/images/{id}/thumb uses the same pipeline but is never counted as an access):
- Hotlink protection middleware allows the request or returns 403 based on the Referer allowlist (admin console previews skip this step).
- The reference is looked up; if it cannot be found, 404 is returned. A reference past its expiry time is treated as missing right away, without waiting for the cleanup task to reclaim it, and an access never renews it.
- Processing parameters are validated: a
woutside the width presets or an explicitformatoutside the allowed formats returns 400 (GIF originals are never processed, so both parameters are ignored for them). - Access is counted: only GET requests on the public endpoints add to the in-memory activity, flushed to the database
in batches every
activity_flush_seconds; HEAD requests, requests with invalid parameters, and admin console previews are not counted. - On-demand processing kicks in when the image needs resizing or converting: a
wparameter, an explicitformat, or no format with a browser that accepts WebP, WebP allowed by the settings, and an original that is not WebP already. GIFs are always served as originals. A variant cache hit is returned directly; on a miss the image is processed within theprocess_workersconcurrency limit and cached, with concurrent requests for the same parameters merged into one processing run. When processing is busy or fails, the original is served instead, withCache-Control: no-store. A HEAD request fetches or generates the variant the same way; it just returns no body. - Otherwise the original is served. Candidate channels are those that are healthy, not being cleaned up, and hold a
donereplica of the image, in priority order. Inredirectaccess mode, if a candidate allows direct links, is not encrypted, and has a public URL configured, the response is a 302 to that channel, sent withCache-Control: no-store(the target changes with channel health and configuration, so it must not be cached). A HEAD request gets the response headers only and no channel object is read; otherwisefailover.Openproxies the read, switching to the next channel as soon as one cannot be read.
Caching depends on the endpoint:
| Endpoint | Cache-Control | Why |
|---|---|---|
/i/{id} | public, max-age=31536000, immutable | Content behind a short ID never changes; the URL is the version |
/o/{user_id}/{key} | public, no-cache | Re-uploading the same key repoints the alias at a new image; caches may store the response but must revalidate with the ETag before each use, getting 304 when unchanged |
On both endpoints originals and variants carry a strong ETag (the content hash for originals, the hash plus processing
parameters for variants), and a request whose If-None-Match matches it gets a 304. The 304 carries the ETag,
Cache-Control, and Vary the 200 response would have, so caches can keep using their stored copy. Responses whose
format was negotiated via Accept carry Vary: Accept; a fallback original is no-store and never answered with a 304.
If a database lookup fails, the response is a plain-text 500 that can be retried.
The outbox fence: business tables are the single source of truth
Asynchronous replication runs on River, but River jobs are only idempotent triggers. The single source of truth for
replica state is the business table blob_channels:
- A replication job means "make sure blob X has a replica on channel Y". The worker reads the business table first: if
the replica is already
doneit returns; before copying it setsuploading; on failure it goes back topendingand River retries; if the last attempt still fails it becomesfailed; on success it becomesdone. - Jobs are deduplicated by (blob, channel), counting only unfinished jobs, so the same replica never appears in the queue twice.
- Reconciliation runs at startup and every 10 minutes after that: it queues drain jobs for channels being cleaned
up, seeds missing replica rows for every channel, resets
failedreplicas on healthy channels topending, and then pages through everypending/uploadingreplica with a primary-key cursor and queues it again; replicas turningdoneduring the scan cannot shift later pages and cause rows to be skipped.
So even if River's job tables are lost or corrupted, the queue can be rebuilt completely from the business table; a job
that was running when the process was killed is rescheduled by River once it has been stuck in the running state for
16 minutes. When a channel is created, pending replica rows are seeded for every existing blob, and the same
machinery brings them in line, with no separate backfill logic.
The other asynchronous jobs follow the same principle: deleting objects from a channel (delete_object), draining a
channel being cleaned up (drain_channel: it first deletes the replica objects row by row, so its progress is simply the
remaining replica rows; then deletes the system objects under _imgyoung/ on that channel, metadata snapshots included,
along with deep check leftovers, leaving objects written by other programs alone; and finally deletes the channel record), and purging CDN caches
(cdn_purge). All of them are idempotent and retried by River with backoff: orphans left by an object deletion that
finally fails are reclaimed by reconciliation GC; the first time a drain job cannot delete an object, it writes the
reason into the channel's check result and raises a drain_failed alert, then keeps retrying with backoff — once its
attempts run out, reconciliation queues it again, so it never stops (the job's own timeout or a shutdown cancellation
does not count as a failure); the way out is Remove now on the channel in the admin console (remote objects are kept). When a CDN purge finally
fails, the edge cache expires once the CDN's own cache lifetime runs out.
Periodic tasks
Periodic tasks are loops owned by each service and started and stopped by the composition root. For intervals that come from runtime settings, a change in the admin console reschedules the current wait immediately instead of waiting for the old interval to finish.
| Task | Interval | What it does |
|---|---|---|
| Health check | health_check_seconds (default 60 seconds) | Retry loading channels that previously failed to load, then light checks on all channels in parallel; an unhealthy channel whose light check passes recovers only after a deep check confirms it. See Multi-store failover |
| Alert evaluation | Same as the health check | Unhealthy channels, failed replication, channel usage over the threshold, failed drains, failed backups; can be pushed to a webhook |
| Lifecycle cleanup | cleanup_sweep_seconds (default 60 seconds) | Reclaim expired references → physically delete blobs past their grace period → label cold images → reclaim expired idempotency keys → reclaim audit entries past retention |
| Activity flush | activity_flush_seconds (default 60 seconds) | Write public access times and counts to the database in batches; variants' last-access times are flushed on the same interval |
| Deduplication | dedup_scan_seconds (default 300 seconds) | Merge exact duplicates (same SHA-256) automatically, then scan for pHash near-duplicates and queue them for manual decisions |
| Usage report | usage_report_seconds (default 300 seconds) | Compute storage usage per channel and update metrics |
| Metadata database backup | backup_interval_hours (default 24 hours) | Take one consistent snapshot with VACUUM INTO and write it to _imgyoung/backup/ on every channel that is healthy and not being cleaned up, each channel keeping its own backup_keep copies; if any channel is not written, the round counts as failed (backup_failed alert) and the channels that were written keep the snapshot. At startup, a backup runs right away if any healthy channel lacks a snapshot within the backup interval. See Backup and restore |
| Auth maintenance | 1 minute / 1 hour (fixed) | Write back token last-used times in batches / reclaim expired sessions |
| Replication reconciliation | 10 minutes (fixed) | See the previous section |
| Reconciliation GC | 24 hours (fixed) | List every healthy channel and compare with the replica records in the database in both directions: reclaim orphan objects, and reset phantom replicas to pending to be replicated again. Criteria below |
Reconciliation GC criteria
What reconciliation GC handles (the system directory _imgyoung/ and the light check key are excluded from the comparison):
- Orphans: an object is reclaimed only if all of these hold — its key has this service's object key format
{short_id}_{first 8 hex chars of sha256}; the database has no replica record of it on that channel in any state; its modification time is not earlier than the channel's binding time; and it is past the 48-hour grace period (which covers uploads already written to the channel but not yet recorded). Every other object without a record — files written by other programs, objects in an old bucket attached by a new instance during disaster recovery before the metadata is restored, or objects written to the channel before a snapshot restore that the restored database has no record of — is only counted, never reclaimed, and appears asforeignin the reconciliation log. - Deep check leftovers:
.imgyoung-probe-temporary objects left when a deep check fails to delete them are deleted once past the 48-hour grace period, regardless of when they were written, and are not counted in the reconciliation's object and byte totals. - Phantoms: replicas recorded as
donebut missing from the channel are reset topendingand queued for replication again.
The binding time (channels.bound_at) marks when the channel record started to correspond to its current storage
location; this service only writes objects there after that moment:
- A new channel's binding time equals its creation time. When a new instance attaches an old bucket, every object already in it is older and is only counted, never reclaimed.
- Editing a channel in the admin console resets it to the time of the edit if the backend name or any configuration parameter changes; typing the same password again counts as a change (rclone encodes passwords with a random IV). Changing only the name, priority, or direct-link switch does not reset it, and neither does a token refresh write-back.
- After a restore from a snapshot, every channel is reset to the time of the first start after the restore: before
swapping the snapshot in, a marker file
imgyoung.db.restoredis written to the data directory; after migrations and before the master key is loaded, the marker triggers the reset and is deleted on success, with the log line换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收. Objects uploaded after the snapshot, which the restored database has no record of, are therefore never deleted as orphans. The marker is written when the staged file<data dir>/imgyoung.db.restoreis swapped in, and a restore from the admin console stages exactly that file; a manual restore must also put the snapshot there and then start, not overwriteimgyoung.dbdirectly, otherwise no marker is written and the binding times are not reset.
created_at does not change along with the binding time: together with the channel ID it feeds key derivation for encrypted channels.
Two instances must not share a channel path
Reconciliation only trusts this database's replica records: objects another instance writes to the same path have the right key format but no record here, so once past the grace period they are deleted as orphans.
Shutdown
Shutdown (on SIGINT / SIGTERM, or a restart requested after restoring a backup snapshot in the admin console) happens
in order: new requests stop being accepted first, and in-flight requests get at most 6 seconds before the remaining
connections are closed; then the background loops and in-flight replication jobs are cancelled and the replication
subsystem gets at most 2 seconds, while each loop writes out data still held in memory before it exits; finally the
channel drivers and the database are closed. The two stages total 8 seconds, within Docker's default 10-second kill
timeout, and the sample configurations under deploy/ leave extra headroom (stop_grace_period: 15s in compose,
TimeoutStopSec=15s for systemd). Cancelled replication jobs keep their state in the business table and are queued
again by reconciliation on the next start.
Technology choices
Every choice serves the same premise: a single instance, a single static binary, and no external services needed to
deploy apart from the storage channels. Exact dependency versions are whatever the repository's go.mod says.
Language and build
- Go: compiles to a single static binary with a small Docker image; I/O orchestration such as multi-channel writes, asynchronous replication, health checks, and the task queue is straightforward with goroutines; the ecosystem for object storage, HTTP, and metrics is mature, and simple syntax and fast builds keep the bar for contributors low. The deciding factor is that rclone itself is written in Go, and only Go can call its backends directly in-process; see Why rclone is integrated in-process.
CGO_ENABLED=0throughout: without a C toolchain or system libraries, the release archives can be cross-compiled for Linux, macOS, and Windows on two architectures each, and shipped in a distroless image holding nothing but the binary. Every dependency below is pure Go or works without cgo. All builds use-tags nodynamic: by default the WebP library tries to load the system libwebp at runtime, and only with that path switched off is the Linux binary free of the dynamic linker.
Dependencies
| Area | Choice | Why |
|---|---|---|
| HTTP routing | chi | Handlers are plain standard-library http.Handlers with no private concepts; uploads (streaming request reads) and image proxying (streaming response writes) both work directly with io.Reader / io.Writer, so a thin router fits best |
| SQLite driver | modernc.org/sqlite | A pure-Go implementation; no cgo |
| Database access | sqlc | Queries are written in SQL and compiled into type-safe Go code. No ORM: an image host's queries are all simple, exact lookups, so an ORM's abstraction buys nothing |
| Migrations | Atlas | The declarative schema is the single source of truth; during development Atlas diffs it into versioned migrations, and at runtime its pure-Go executor applies them on startup, so deployments need no migration tool |
| Task queue | River | A persistent task queue with retries, backoff, and concurrency control built in; on its SQLite driver the job tables share a database with the business tables, so there is no extra service. Jobs are only idempotent triggers and state lives in the business tables; see the outbox fence |
| Periodic tasks | A Run(ctx, wait) loop owned by each service | Periodic tasks only need "run every so often", with the interval taken from runtime settings and rescheduled the moment it changes; no cron library |
| Wiring | Assembled by hand in cmd/imgyoung/build.go | The set of components is fixed and their dependencies are static, so calling constructors in order is enough: searchable, navigable, with no reflection or runtime container. No DI framework |
| Lifecycle | oklog/run | HTTP, River, and each background loop are actors; when any of them exits, the whole group shuts down in order. Harder to get wrong than hand-rolled WaitGroups and channels |
| Architecture guard | depguard (built into golangci-lint) | The direction of dependencies between layers is a lint rule enforced by CI, not a matter of discipline; see Layers and dependency direction |
| Perceptual hash | goimagehash | Pure Go; takes the decoded image.Image, so it plugs straight into the upload's decode step |
| Image codecs | Standard library, golang.org/x/image, gen2brain/webp | None needs cgo; gen2brain/webp encodes WebP by running libwebp in a pure-Go WebAssembly runtime |
| Cryptography | Standard-library AES-256-GCM and HKDF; argon2id from golang.org/x/crypto | Encrypting channel configuration and credentials, deriving channel keys and S3 secrets, and hashing login passwords respectively. Encryption of channel data itself is left to rclone crypt |
| Rate limiting | golang.org/x/time/rate | Upload rate limits and login throttling are both in-process token buckets |
| Logging and metrics | log/slog, Prometheus client_golang | The de facto standards in their areas |
| Short IDs | jaevor/go-nanoid | Public short IDs come from a mature implementation; internal primary keys are SQLite auto-increment rowids and never exposed |
The project is released under the MIT license: the same as rclone, with the lowest barrier for contributors, and a self-hosted tool has no realistic concern about cloud vendors reselling it as a hosted service.
What is deliberately left out
- Redis: it has no place in a single instance. What needs atomicity, such as idempotency keys and reference counts, relies on SQLite constraints and atomic updates; rate limits are in-process token buckets; the variant cache lives on local disk. In a single instance the in-process versions are strictly better than Redis: no network round trips, no serialization, no extra service to run. It only becomes worth reconsidering when moving to multiple instances.
- Configuration files and configuration libraries: environment variables only get the process started; all other
runtime settings live in the database's
settingstable and are edited in the admin console, so "where does this value come from" always has exactly one answer. See Configuration. - ORMs, DI frameworks, cron libraries: see the table above for why.
SQLite and versioned migrations
SQLite is a single file with no external dependencies, and a consistent snapshot is just one more file, which suits self-hosting best and fits the single-instance design. Versioned migrations have been there from day one: a long-running self-hosted instance goes through many version upgrades, and without versioned migrations the schema would quickly get out of hand. Migrations only go forward; see Data model.
Two upgrade seams
Only two choices are expected to be replaced; everything else is fixed. Go's thin libraries all follow the shape of the standard library, so replacing one would be mechanical work, and reserving flexibility for it would buy nothing.
- Image processing engine: the single entry point for on-demand processing is
Processor.Process(src, params)ininternal/infra/imaging. The current implementation is pure Go: the standard library decodes JPEG / PNG / GIF, gen2brain/webp encodes WebP, output is limited to WebP, JPEG, and PNG, and GIFs are stored and served as-is without processing. AVIF and HEIF are not included: they see little use, and their encoders (libaom and the like) are heavy. More formats or faster processing (an implementation based on libvips, for example) would mean replacing this processor. - Database: currently SQLite. The schema is defined in declarative SQL, migrations are generated by Atlas, and query code is generated by sqlc; moving to PostgreSQL means regenerating the query code and migrations for that dialect and switching River to its PostgreSQL driver.
Each seam currently has a single implementation, and no interface type has been defined for either in advance.
Why rclone is integrated in-process
The storage layer follows one rule: rclone only stores bytes in some backend, retrieves, inspects, lists, and deletes
them. Primary/backup switching, replication, reference counting, deduplication, TTL, and health decisions are all
ImgYoung's own code, separated from rclone by the store/driver.Driver interface.
The integration imports rclone's fs and backend packages directly and builds an fs.Fs in-process from a parameter
map, instead of calling the rclone command line, librclone's RPC interface, or an rclone config file:
- Streaming, no temporary files: uploads write in-memory bytes straight through
fs.Fs.Put, and reads stream the object straight into the HTTP response. - Configuration stays in ImgYoung's own database: channel parameters are stored in the
channelstable encrypted with the master key and decrypted into a map for the backend at runtime. The parameters contain credentials, so they are never logged and never carried in error chains. - One token refresher: rclone refreshes OAuth tokens itself and hands them to the application through the parameter write-back hook, which encrypts and stores them immediately; the application runs no refresh loop of its own.
- One handle for everything: reads, writes, checks, listing, and deletion all go through the same
fs.Fs, with one set of error mappings (missing objects always becomedriver.ErrNotFound). - Channel encryption with no extra code: encrypted channels stack rclone's crypt layer on top of the backend, and the upload and access paths stay unchanged; crypt is a public format, so with the password the data can be decrypted by standard rclone on its own. See Channel encryption.
- Configuration exportable as-is: the admin console can export an rclone configuration file for every channel, with
exactly the parameters the driver hands to the backend, plus a crypt remote for encrypted channels. It is generated by
ConfigFileinstore/rclone, which shares its parameter handling with driver construction, so standard rclone can read and write the channel's data without ImgYoung. See Driver interface and rclone implementation. - Tests need no cloud account: rclone's
localandmemorybackends let the whole storage path run as integration tests on one machine.
Registering every rclone backend (backend/all) means any storage rclone supports can be used as a channel directly,
and rclone upgrades bring new backends with them; the cost is a binary on the order of 100 MB. This relies on rclone's
internal APIs, which make no stability promise, so go.mod pins the rclone version and upgrades are done by hand with the full test suite.
The Driver interface does not assume rclone exists, which is also the way out if rclone's internal APIs change:
swap in another Driver implementation and the business code stays the same. Splitting the interface and the rclone
implementation into separate packages also lets Go projects outside the application reuse this storage engine on its
own; see Using it as a Go library.