ObjectShare is a small self-hosted file sharing service written in Go. Files use UUID share links with per-file access controls; the uploading browser receives an HTTP-only owner token that permits sharing changes, rename, and deletion.
- Single-file and multiple-file, size-limited uploads with SHA-256 and SHA3-256 checksums
- Tabler UI with HTMX progressive enhancement and native-form fallbacks
- Administrator-managed site name, logo, header banner, favicon, tagline, and footer
- Filesystem, Cloudflare R2, AWS S3, Backblaze B2, Alibaba Cloud OSS, or Tencent Cloud COS object storage
- Direct-to-object-storage uploads that avoid reverse-proxy request-body limits
- PostgreSQL metadata with bounded connection pools
- Optional per-upload browser encryption and decryption with passphrase-protected account keys, encrypted backups, and per-file sharing keys
- Optional AES-256-GCM server-side encryption at rest
- Owner-only rename and permanent deletion
- Guest uploads, database-backed per-user storage quotas, and automatic guest/unpaid file retention
- Local paid plans with one price for upgraded storage, longer active-plan retention, and direct download links; optional Stripe/PayPal account top-ups
- Prepaid account-credit top-ups and credit-funded fixed-duration plans
- Password, Google, GitHub, and Discord login with separate user and administrator management interfaces
- Account bans and shadowbans with administrator controls and file-access enforcement
- Optional server-verified Turnstile protection and shared Redis or PostgreSQL request rate limits
- Optional Redis public plan catalog cache with PostgreSQL fallback
- Encrypted PostgreSQL-backed configuration with a dedicated administrator dashboard
- Modular outgoing email through SMTP, Alibaba Cloud Direct Mail, or AWS SES, with an administrator test-email action
- Plan and credit-top-up invoices, account-credit or gateway payment, private invoice pages, PDF exports, and queued payment confirmation emails with PDF attachments
- One-time administrator bootstrap through the web setup or CLI
- Graceful shutdown, health endpoints, secure response headers, and structured logs
- Multi-stage, non-root, read-only container image
- Docker Compose development/single-node deployment
- CI, vulnerability scanning, SBOM/provenance, and multi-architecture publishing to Docker Hub and GHCR
File links are unlisted by default; owners can restrict details and downloads to signed-in users, selected accounts, or themselves. Guest and standard-account downloads must start on that details page; a stable direct download URL works only while the owning account has an active plan that includes direct links. Put ObjectShare behind an authentication-aware reverse proxy if every file view must require authentication.
- Account credit, top-ups, and prepaid plan purchases
- Administrator configuration dashboard
- Administrator directory with account search, filters, and pagination
- Administrator invoice monitoring and receipt status
- Administrator site overview and launch configuration indicators
- Auto file deletion after days for guest and unpaid users
- Billing failure recovery with invoice and account navigation
- Billing overview with plan, credit, and invoice navigation
- CAPTCHA and API rate limiting
- Client-side encryption & decryption
- Custom branding support
- Direct-upload batch recovery without duplicate completed files
- Drag-and-drop uploads with file selection summaries and access guidance
- Email sending
- Encrypted sharing links and saved access guidance in the sharing workspace
- File deletion
- File download
- File sharing & permission
- File upload
- Hot configuration reload without a container restart
- In-place account theme switching that preserves the current workflow
- Invoice generation
- Multi-factor authentication with email codes, authenticator apps, and recovery codes
- Optional client-side encryption for each upload or batch
- Paid storage, retention, and direct-link plans
- Redis caching and shared request rate limits
- Searchable, paginated account file workspace
- Server-side encryption & decryption
- Single-file and multiple-file upload modes
- Third-party OAuth login support
- Unified workspace and administrator navigation
- Upload quota
- User ban & shadowban
- User management
HTMX is intentionally part of the frontend architecture. The native forms are accessibility and no-JavaScript fallbacks; login, account, and user-management interactions use HTMX progressive enhancement, and file-sharing permissions follow the same pattern.
- Alibaba Cloud OSS
- AWS S3
- Backblaze B2
- Cloudflare R2
- Tencent Cloud COS
- Google Cloud Storage
- Microsoft Azure Blob Storage
- Oracle Cloud Object Storage
The signed-in navigation separates the common tasks:
- Upload (
/): choose single or multiple files, browse or drag files onto the selector, review filenames and sizes, and choose access before uploading. Local validation includes encryption overhead; the server still enforces all limits. If a direct-upload batch is interrupted, keep the page open and choose Retry unfinished uploads. Completed files have links and are skipped on retry. The retry reuses the original uploads and encrypted bytes; it does not create another copy or require unlocking the encryption key again. Uploads whose authorization has expired must be selected for a new batch. Recovery state stays in page memory and is lost when you leave or reload the page. For proxied uploads, check My files before retrying an uncertain response. - My files (
/files): search your completed uploads by filename and filter by access level. Results are ordered newest first in pages of 25. Share opens permissions; Manage opens details, decryption, rename, and deletion. Encrypted recipients also need the file key, which owners can include in a link directly from Share or generate from the file page. - Billing (
/billing): see your current plan, credit balance, recent credit activity, and available top-up methods. Plans shows the currency price and credit equivalent for each fixed-duration purchase. Review invoice creates the invoice before you choose how to pay. Invoices & receipts keeps your purchase history and PDF downloads together. New prepaid plans do not renew automatically; existing gateway subscription controls remain available. If a browser payment fails, the error page keeps a route back to the invoice and billing overview. Check invoice status before retrying an uncertain payment. - My account (
/account): manage profile, email verification, login methods, appearance, and encryption keys. Existing billing and upload sections remain available here for compatibility.
Administrators also see Administration (/admin). Its overview links to user
management, the plan catalog, invoice monitoring, and configuration, with shared
navigation across those pages. Counts include registered users and moderation
states, completed files and their stored bytes, pending uploads, unexpired active
or trialing plan periods, paid/pending invoices, and paid invoices whose receipt
has not yet been accepted by the email provider. Counts are a database snapshot;
file bytes exclude pending uploads and provider overhead, and invoice counts are
not revenue totals.
Administration → Users (/admin/users) shows 25 accounts per page. Search by
name or email (case-insensitive literal text), or an exact account ID. Filters
include administrators, disabled accounts, bans, shadowbans, and email
verification. The account and storage totals cover all accounts even when the
list is filtered; storage includes pending uploads and deletion retries until
their records are removed. Enhanced management forms preserve the search and
page after updates or validation errors. Actions affecting your own administrator
account return through authentication; native forms return to the default user
directory. Existing account creation, moderation, roles, quota, retention
exemptions, credit adjustments, password resets, and deletion remain available.
Administration → Invoices (/admin/invoices) provides a read-only, paginated
monitor. Search by customer email, purchase name, exact invoice ID, or exact
payment reference; filter by pending payment, paid, or unsent receipt. An unsent
receipt can be queued or waiting for email configuration. A pending invoice can
have an expired payment window. Reconcile ambiguous payments with the provider
before making existing audited credit adjustments. This monitor does not grant
access to customers' file contents or change their invoice payment state.
The overview's launch indicators describe active configuration, not end-to-end health checks. Before launch, verify HTTPS, a real email delivery, gateway sandbox payments and signed webhooks, direct-upload CORS, and backup restoration. See the production checklist. Browser checks should cover desktop and narrow screens, light/dark themes, keyboard navigation, file selection, drag-and-drop, and encrypted upload/download/sharing with a second account.
In My account → Client-side encryption, choose a separate encryption passphrase of at least 16 characters, confirm it, and select Generate account key. The browser generates a random 256-bit account key and downloads an encrypted key backup. Save that backup and the passphrase safely, preferably in separate places. The account key is independent of login credentials, including OAuth: login password changes, email edits, and administrator password resets leave it unchanged.
Client-side encryption is optional for every signed-in upload. On Upload, select Encrypt files in my browser to encrypt the selected file or every file in a batch, then enter your encryption passphrase to unlock the key for that page. The option is unchecked by default. Leave it unchecked to upload without client-side encryption; no key setup or passphrase is needed, even when the account already has a key. Creating a key does not require encryption on future uploads. Bearer API and native form uploads may also omit encryption metadata. Supplied metadata still requires a valid account key and matching ciphertext length. Server-side encryption, if enabled, applies independently. Existing files, keys, backups, and guest uploads retain their current behavior; there is no automatic re-encryption or decryption of existing files. Encrypted browser uploads require JavaScript and HTTPS (or localhost for development). Encryption failure never falls back to an unencrypted upload. Without JavaScript, the native form uploads without client-side encryption.
On the file page, use Decrypt & download. Owners enter their encryption passphrase; the browser retrieves the wrapped account key, decrypts it locally, and derives the file key. Alternatively, choose Encrypted key backup and enter its passphrase to recover the key locally. Neither the passphrase nor the unwrapped account/file keys are sent to the server or saved to browser storage. They are used in page memory; enter the passphrase again on another page/device. The server stores only the wrapped account key, public encryption metadata, and file ciphertext. Keep database and object backups together: file metadata is necessary for decryption as well as the key. There is deliberately no key replacement/reset operation; losing both the passphrase and any usable unwrapped key makes the encrypted contents unrecoverable. The encrypted backup alone cannot bypass a lost passphrase.
To share, set the existing access permissions and use Create sharing link with file
key on the file page. Copy the generated link to the recipient. The URL's #key=
fragment contains only that file's derived key; browsers do not send the fragment in
HTTP requests, and the file page removes it from the address bar after loading it.
The ordinary file link does not include a decryption key. Never share your account
key, backup, or passphrase with recipients. Signed-in/selected recipients must log in
and reopen the complete sharing link. Private files remain owner-only. Permission
revocation blocks subsequent downloads but cannot revoke keys, ciphertext, or plaintext
already received. A forwarded key link also permits decryption wherever its recipient
can obtain the ciphertext and metadata.
File contents are encrypted; filenames, owners, sharing policies, approximate sizes,
timestamps, and encryption metadata remain visible to the application. Displayed quota
usage, size limits, and proxied-upload checksums describe ciphertext (before any optional
server encryption layer). Ciphertext adds a 16-byte authentication tag per chunk, so a
plaintext file exactly at the configured limit needs to be slightly smaller to fit.
Native/API downloads return ciphertext with a .objectshare suffix. Client-encrypted
files use the file page for browser decryption; existing plan-controlled direct download
URLs can deliver ciphertext but do not provide a plaintext browser download.
Encryption processes 1 MiB plaintext chunks. The browser assembles ciphertext and the verified download as Blobs, so available browser memory/storage still limits very large files; plaintext downloads are only offered after every chunk authenticates. Direct object-storage upload continues to avoid Cloudflare/proxy request-body limits. Optional server encryption can wrap client ciphertext on proxied uploads and retains its existing 128 MiB limit and single-operation capacity. HTTPS and trusted application/browser code are part of the security boundary: a compromised server serving altered JavaScript or an extension with page access can capture keys while the page is unlocked. This is not protection against such active compromise.
Version 1 wire format: GET /account/encryption returns the authenticated user's
user_id and vault (null before setup). CSRF-protected POST /account/encryption
creates the vault once; a duplicate returns 409. The vault fields are version: 1,
user_id, key_id, salt, iv, and wrapped_key. Binary fields use canonical,
unpadded base64url. key_id is SHA-256 of the random 32-byte account key. A wrapping
key uses PBKDF2-HMAC-SHA-256 with 600,000 iterations and a random 16-byte salt. The
account key is wrapped using AES-256-GCM with a random 12-byte IV and a 128-bit tag;
AAD is UTF-8 objectshare-vault-v1:<user_id>:<key_id>.
Each client-encrypted upload supplies client_encryption as a JSON string containing
{"version":1,"key_id":"...","salt":"...","size":123}. salt is a new random 32-byte
value for each file and size is the plaintext byte count. Multipart requests repeat
that field once per file, in file order; direct single/batch JSON includes it in each
file descriptor and uses ciphertext size and application/octet-stream content type.
HKDF-SHA-256 derives a 32-byte AES file key from the account key, file salt, and UTF-8
info objectshare-file-v1. Encrypt chunks independently with AES-256-GCM: the 12-byte
IV is four zero bytes followed by the unsigned 64-bit big-endian chunk index (starting
at zero); AAD is the exact metadata string followed by : and the decimal index.
Store the concatenation of chunk ciphertexts, each followed by its 16-byte tag. Empty
files still contain one authenticated empty chunk. Authenticated metadata and indices
bind the file size, order, and final chunk. Preserve the exact metadata string in
backups and clients; do not reserialize it before decryption. The server validates
metadata structure, account key ownership, and ciphertext length, but cannot prove
that a malicious client actually encrypted the submitted bytes.
The implementation uses the browser's Web Cryptography API.
Its PBKDF2 work factor follows OWASP's PBKDF2 guidance.
Run node --test tests/client-encryption.test.cjs tests/upload-selection.test.cjs
for cryptographic round-trip, adversarial, and upload selection checks; go test -mod=mod ./... also runs them when Node is installed.
PostgreSQL migration/concurrency tests use an isolated schema when
OBJECTSHARE_TEST_POSTGRES_DSN is set to a disposable PostgreSQL instance.
Outgoing email is optional and disabled by default. Administrators select SMTP, Alibaba Cloud Direct Mail, or AWS SES under Configuration → Email delivery. Credentials use the existing encrypted PostgreSQL runtime document. Blank secret fields preserve their values; the adjacent clear checkboxes remove them. Save the configuration, which the saving replica activates immediately, then use Send test email to send a fixed message to your signed-in administrator account address using that replica's active settings. The action requires administrator access, same-origin checks, and JWT CSRF; when shared rate limiting is enabled, it permits three tests per administrator per configured rate-limit window. Provider acceptance does not prove inbox delivery.
The JSON options below belong inside the top-level email object in
config.json; nested paths such as smtp.host belong inside its smtp object.
All environment names have the prefix OBJECTSHARE_EMAIL_. JSON and environment
values seed PostgreSQL only on first initialization. Existing installations use
the dashboard; changing environment variables does not overwrite saved settings.
JSON path within email |
Environment suffix | Default | Purpose |
|---|---|---|---|
provider |
PROVIDER |
none |
none, smtp, alibaba, or ses |
from_address |
FROM_ADDRESS |
empty | Single sender mailbox; required when enabled |
from_name |
FROM_NAME |
empty | Optional sender name; at most 15 characters for Alibaba, 100 otherwise |
reply_to |
REPLY_TO |
empty | Optional single reply-to mailbox |
timeout |
TIMEOUT |
15s |
Total send timeout, 1s to 1m; includes credentials, connection, and delivery |
smtp.host |
SMTP_HOST |
empty | SMTP hostname or IP without a port |
smtp.port |
SMTP_PORT |
587 |
SMTP port; explicitly use 465 for implicit TLS |
smtp.tls_mode |
SMTP_TLS_MODE |
starttls |
starttls (required upgrade) or tls (implicit TLS); verified certificates |
smtp.username |
SMTP_USERNAME |
empty | AUTH PLAIN username; leave both credentials empty for a relay |
smtp.password |
SMTP_PASSWORD |
empty | Write-only SMTP password |
alibaba.region |
ALIBABA_REGION |
cn-hangzhou |
cn-hangzhou, ap-southeast-1, ap-southeast-2, us-east-1, or eu-central-1 |
alibaba.access_key_id |
ALIBABA_ACCESS_KEY_ID |
empty | Write-only RAM access key ID |
alibaba.access_key_secret |
ALIBABA_ACCESS_KEY_SECRET |
empty | Write-only RAM access key secret |
ses.region |
SES_REGION |
us-east-1 |
AWS SES region; standard AWS, GovCloud, and China endpoint domains |
ses.access_key_id |
SES_ACCESS_KEY_ID |
empty | Write-only AWS access key ID; optional with the AWS credential chain |
ses.secret_access_key |
SES_SECRET_ACCESS_KEY |
empty | Write-only AWS secret access key; must accompany the access key ID |
ses.session_token |
SES_SESSION_TOKEN |
empty | Optional write-only token for temporary explicit AWS credentials |
ses.configuration_set |
SES_CONFIGURATION_SET |
empty | Optional existing SES configuration set |
- SMTP: use a TLS-capable relay with a trusted certificate matching its host.
starttlsrequires STARTTLS before authentication or delivery;tlsstarts TLS immediately. AUTH PLAIN is supported over TLS. Set both username/password or leave both empty for a relay authorized by network policy. There is no plaintext fallback or certificate-verification bypass. Port defaults to 587; if omitted or zero in JSON,tlsselects 465. Compose explicitly defaults to 587, so set port 465 when selecting implicit TLS there. - Alibaba Cloud Direct Mail: verify the sender in the selected region and
give a RAM identity
dm:SingleSendMailpermission. The provider uses SingleSendMail with address type 1, the configured sender, optional reply-to, and tracking disabled. Regional HTTPS hosts follow Alibaba's endpoint table; requests use its documented RPC signature. - AWS SES: verify the sender identity in the chosen region and grant
ses:SendEmail. Sandbox accounts also need verified recipient identities (or SES mailbox simulator recipients); see SES sandbox requirements. The provider calls SES v2 SendEmail with SigV4 signing from the existing AWS SDK. Supply an independent email access-key pair, plus a session token for temporary credentials, or leave all three blank to use the AWS credential chain, including workload IAM roles. Role/SDK environment and shared credentials are deployment inputs, separate from the saved email settings; make them available inside the application container if using that chain. Storage credentials are never reused implicitly. Configuration sets must already exist in that region.
The Go email.Sender interface exposes Send(context.Context, email.Message) error;
email.New(ctx, cfg.Email) selects the configured transport. Messages support one
ASCII recipient, a UTF-8 subject (up to 100 characters), and text and/or HTML
bodies (up to 80 KiB each). SMTP creates MIME multipart alternatives when both
bodies are supplied. A disabled sender returns email.ErrDisabled. Calls have a
bounded total timeout and are not automatically retried or failed over because a
timeout may happen after acceptance. Messages can include up to five PDF attachments
(totaling at most 4 MiB). SMTP uses multipart/mixed with base64 attachments; SES
uses its MIME/raw message API for attachments. Alibaba attachment delivery uses
its official upload-capable Direct Mail SDK with the existing configured credentials;
the SDK authorizes a temporary provider-hosted file upload before sending. Allow
outbound HTTPS to Alibaba Open Platform and its returned OSS upload endpoint in
addition to Direct Mail. Ordinary Alibaba messages retain the existing signed API
transport. The SDK bounds each attachment delivery stage; cancellation is checked
before starting and the remaining deadline is divided across stages.
Paid invoices use a separate persistent delivery queue with a five-minute retry and lease interval. Normal message sends do not retry automatically. Bulk mailing and bounce processing remain outside this integration. Signup and email changes can send verification emails as described below; account login still uses JWTs.
cp .env.example .env
# Edit .env and replace the PostgreSQL, JWT, and settings encryption secrets.
docker compose config --quiet
docker compose up --build -dOpen http://localhost:8080 on the Docker host. The copied example sets
OBJECTSHARE_PORT=127.0.0.1:8080, keeping first-time setup on the host's loopback
interface. For a remote server, reach that address through an SSH tunnel while
creating the first administrator. Existing .env files keep their current port
binding; a bare value such as 8080 publishes on all host interfaces. Complete
administrator setup before intentionally exposing the application.
Compose uses PostgreSQL 18, Redis, and persistent local object, database, and
Redis volumes. Set OBJECTSHARE_REDIS_PASSWORD to a long random secret in .env;
old .env files still start with the development default objectshare-dev.
Redis is private to the Compose network and has no published host port. Stop it with
docker compose down; add --volumes only when you intentionally want to delete
all stored data. .dockerignore excludes .env and .env.* files at every
directory depth from the build context, while retaining the root .env.example.
Custom configuration files with other names must also be excluded if they contain
secrets. Build-context exclusion does not replace runtime secret storage.
The first visit redirects to the one-time setup page. Create the initial administrator there; after that, /setup is locked. Administrators configure the application from Configuration (/admin/settings) and manage accounts from Users. Public signup is enabled by default and creates normal users.
For HTTPS deployments, terminate TLS at a reverse proxy, enable secure cookies in the configuration dashboard, and save. The saving replica activates the change immediately and other replicas activate it at their next configuration reload. Back up both named volumes together so metadata, encrypted configuration, and objects remain consistent.
The host-side proxy can connect to 127.0.0.1:8080. A containerized proxy should
join the application's Docker network and connect to app:8080; its own loopback
address does not refer to the host or the application container. Set the public
HTTPS URLs in the dashboard before enabling OAuth, billing, and verification email.
Requirements: Go 1.27 and PostgreSQL 18 (PostgreSQL 17 is also supported).
Startup schema migrations run in a transaction under a PostgreSQL advisory lock.
Statement caching is disabled during migration and enabled for application queries
after commit, so repeated schema inspection can safely see added or changed columns.
If an older build stops with cached plan must not change result type (SQLSTATE
0A000) during migration, deploy a build containing this fix and restart ObjectShare
against the existing database; no manual table or data deletion is needed.
cp config.json.example config.json
# Edit the bootstrap database and secret settings.
go run . -config config.jsonObjectShare now keeps operational configuration in PostgreSQL. On the first start after this upgrade, it imports the existing JSON/environment values into one encrypted application_settings revision. Later starts load that database revision, so changing a legacy operational environment variable does not overwrite an administrator's dashboard changes. This one-time import preserves existing deployments; after it succeeds, manage application policy, OAuth, CAPTCHA, rate limits, storage providers, and object encryption at /admin/settings.
Bootstrap infrastructure and secrets remain file/environment-owned:
| Variable | Default | Purpose |
|---|---|---|
OBJECTSHARE_ADDRESS |
:8080 |
HTTP listen address |
OBJECTSHARE_READ_TIMEOUT, OBJECTSHARE_WRITE_TIMEOUT, OBJECTSHARE_IDLE_TIMEOUT, OBJECTSHARE_SHUTDOWN_TIMEOUT |
varies | HTTP server lifecycle timeouts |
OBJECTSHARE_DB_* |
varies | PostgreSQL connection and pool settings |
OBJECTSHARE_REDIS_* |
see below | Optional Redis connection, namespace, timeouts, and public plan cache; bootstrap settings requiring a restart |
OBJECTSHARE_JWT_SECRET |
none (required) | JWT HMAC signing secret, at least 32 random bytes |
OBJECTSHARE_JWT_LIFETIME |
12h |
JWT lifetime (5m to 24h) |
OBJECTSHARE_CONFIG_RELOAD_INTERVAL |
30s |
How often a replica checks PostgreSQL for a newer configuration revision and activates it without a restart; 0 disables polling and 1s to 24h are accepted |
OBJECTSHARE_SETTINGS_KEY |
JWT secret for upgrade compatibility | Independent key that encrypts the database configuration document; set it before the first import and keep it stable |
Generate separate JWT and settings secrets with openssl rand -base64 48, provide the same values to every replica, and keep the settings key with database backups. The fallback to the JWT secret exists only so an older deployment can upgrade without a new mandatory variable; a new deployment should always set an independent OBJECTSHARE_SETTINGS_KEY. Losing or changing that key makes the database configuration unreadable and startup fails closed. Rotating the JWT secret invalidates every issued JWT but does not affect database configuration when the independent settings key is configured.
The dashboard stores the entire operational document as authenticated AES-GCM ciphertext. Secret inputs are write-only: an empty field preserves its stored value, while an explicit checkbox clears it. A save validates the complete candidate before one optimistic, revision-checked database update; a stale admin page cannot overwrite a newer revision. Saving also activates the revision without a restart. The replica builds a complete new snapshot — storage clients, encryption, OAuth, CAPTCHA CSP, cookies, and proxy trust together — and swaps it in atomically, so no subsystem changes on its own and a request already in progress finishes against the configuration it started with. If the snapshot cannot be built, the stored revision stays saved, the replica keeps serving the previous snapshot, and the dashboard reports that this replica did not activate it; the reason is in the application log. Other replicas activate the stored revision at their next configuration reload (config_reload_interval / OBJECTSHARE_CONFIG_RELOAD_INTERVAL, default 30 seconds), or immediately when sent SIGHUP (docker compose kill -s HUP app). Bootstrap settings — listen address, database connection, JWT secret, settings key, server timeouts, and the reload interval itself — are not part of the document and still require a restart. Changing a storage provider, bucket, or filesystem path does not migrate existing objects, and changing the object-encryption key does not re-encrypt them; complete those data migrations separately before activating such changes.
The operational OBJECTSHARE_* variables retained in .env.example, Compose, and the full parser are compatibility seed inputs only. They are consulted when no database configuration row exists; the dashboard becomes authoritative once the row has been created. Existing config.json files remain valid and are not rewritten. After verifying the imported dashboard revision and restarting successfully, remove legacy provider, CAPTCHA, OAuth, and object-encryption secrets from the JSON/environment deployment inputs so those extra plaintext copies no longer remain available to the process.
Generate an object-encryption key separately with openssl rand -base64 32 and enter it through the write-only dashboard field. Losing or changing that key makes existing encrypted files unrecoverable. Encrypted objects are authenticated before download and are held in memory during encryption/decryption. To bound memory use, encrypted mode limits files to 128 MiB and permits one cryptographic operation per application replica at a time.
Normal users find uploads at /files, plan and payment controls at /billing, and profile, password, appearance, and encryption settings at /account. Existing account-page controls remain available. The light/dark theme choice is stored with the account, so it follows the user across browsers and is applied to every authenticated page. With HTMX available, the navigation theme toggle saves and applies that choice without navigating or replacing the page, preserving selected uploads and unsaved forms. If saving fails, the current theme stays in place and the toggle shows a retry message. The native form fallback still saves the preference and returns to My account. Administrators have a /admin overview, a read-only /admin/invoices monitor, and dedicated /admin/settings, /admin/plans, and /admin/users interfaces for configuration, the purchasable plan catalog, and account management. These routes enforce the administrator role server-side and cookie-authenticated changes require the signed JWT CSRF value. The final active administrator cannot be disabled, demoted, or deleted. Disabling an account, changing its role, or resetting its password increments the account token version so every earlier JWT is rejected. Manual retention-exemption and quota changes do not invalidate JWTs because request authorization reloads current account entitlements from PostgreSQL. Deleting an account keeps its existing shared files available and converts them to anonymous uploads; those files then follow the guest retention policy if it is enabled.
Under Configuration → Email verification, set Public site URL to the
browser-visible origin (for example, https://files.example.com) and configure
Outgoing email. Save; the saving replica activates it at once. Password signup then signs the
user in and sends a verification link; My account shows verification status and
lets the user resend it. Email changes clear verification and send a new link.
Delivery failures leave the account usable and unverified, with a retry message.
No email credentials or actual configuration values are changed by upgrading.
Administrators can independently enable Require verified email to purchase plans and Require verified email for account uploads. Both default to off. Enabling either requires a public site URL and an enabled email provider. These settings follow the existing encrypted database configuration and activation lifecycle.
- Purchase restrictions cover invoice creation and payment initiation through account credit or gateways, including the compatible purchase routes. Existing paid access, legacy renewals, payment settlement already in progress, wallet top-ups, and billing cancellation remain available.
- Upload restrictions cover authenticated multipart uploads (single and batch), direct upload authorization (single and batch), and direct upload completion. Completion rechecks the stored owner even when its upload token is used without a login JWT. Guest uploads retain their separate policy: disable Allow guest uploads as well to permit uploads only from verified accounts.
- Existing accounts and administrator-created accounts start unverified. No administrator bypass is applied to purchases or account uploads; administrators can still access Configuration and request verification from My account. New OAuth accounts start verified because creation already requires a provider verified email. Existing accounts are never merged or verified by matching an OAuth email; they can use the verification link.
- Links use 256-bit random tokens, store only a SHA-256 hash, expire after 24 hours, and are consumed atomically once. A confirmation POST with CSRF protection prevents mail scanners from consuming links on GET. A new link invalidates the previous one. A PostgreSQL cooldown permits at most one send attempt per account per minute, including across email changes and application replicas; configured request limits add protection. Failed or ambiguous sends retain their token and cooldown, since the provider may have accepted the email. Verification links never log a user in and cannot serve as authentication JWTs.
For first-import JSON, these options belong under auth.email_verification:
public_url, require_for_purchases, and require_for_uploads. The URL must be an
HTTP(S) origin without credentials, path, query, or fragment; secure cookies require
HTTPS. First-import environment equivalents are
OBJECTSHARE_EMAIL_VERIFICATION_PUBLIC_URL,
OBJECTSHARE_EMAIL_VERIFICATION_REQUIRE_FOR_PURCHASES, and
OBJECTSHARE_EMAIL_VERIFICATION_REQUIRE_FOR_UPLOADS. Once database configuration
exists, use /admin/settings; environment or seed JSON changes do not overwrite it.
Public signup is changed from the configuration dashboard. auth.jwt_secret and auth.token_lifetime remain bootstrap JSON settings and are intentionally not editable from the browser.
Open My account → Manage multi-factor authentication (/account/mfa) to
enable an optional second step for password and Google/GitHub/Discord OAuth
sign-ins. Choose one method per account:
- Authenticator app: Google Authenticator or another RFC 6238-compatible app. Choose Enter a setup key in the app, name the entry ObjectShare, select Time based, and enter the displayed key. The settings are SHA-1, six digits, and a 30-second period. Setup uses a manual key; it does not send secrets to an external QR-code service. Confirm the current code before enrollment completes.
- Email codes: requires a verified account email address and a working provider
in Administration → Settings → Email (the existing top-level
emailconfiguration). Enable signup email verification underauth.email_verificationwhen users need a way to verify their address. Email MFA uses that existing mail provider without additional configuration. An authenticator app avoids dependence on email delivery and mailbox security.
Initial enrollment requires the current password. OAuth-only accounts must sign in again and begin enrollment within five minutes. MFA becomes active only after the new factor is confirmed. Ten random, single-use recovery codes appear once; store them separately from the login password and authenticator. A recovery code can replace the second factor after the password or OAuth step. If both the factor and every recovery code are lost, the account cannot sign in; resetting its password does not disable MFA.
Disabling MFA or generating new recovery codes requires a current second-factor code or an unused recovery code. Each of these changes, including enrollment, increments the account token version and invalidates earlier JWTs. The current browser receives a replacement JWT. To switch methods, disable the current method and enroll again. Email MFA must be disabled before changing the account email, then re-enabled after the new address is verified; profile-only edits continue to work.
Challenges expire after five minutes, with one active challenge per account. Starting another challenge or sending another email has a one-minute account cooldown. Resending replaces the email code without extending the challenge or resetting failed attempts. Five failed codes lock verification for 15 minutes, including across new challenges and application replicas. These database limits remain active when configurable request rate limiting is disabled. When request rate limiting is enabled, MFA verification and management additionally allow 10 requests, and resend allows 5 requests, per configured rate-limit window. Email delivery errors preserve the cooldown because provider acceptance can be ambiguous. Existing MFA accounts can use recovery codes during an email outage.
Pending verification uses a purpose-restricted, signed JWT with a separate
audience and derived signing key that cannot authorize account or API requests.
Full access JWTs are issued only after the second step. Upgrade every replica
before offering MFA enrollment; older application versions do not enforce MFA.
Browser challenges use HttpOnly, SameSite=Strict cookies, with Secure and
__Host- naming when secure cookies are configured, plus CSRF and same-origin
checks on mutations. PostgreSQL row locks serialize code consumption and attempt
accounting; account status, email, and token version are rechecked at completion.
Authenticator codes permit one 30-second step of clock drift in either direction;
an accepted time step cannot be reused. Keep the server and authenticator clocks
synchronized.
Authenticator seeds are stored as AES-GCM ciphertext bound to the account.
Email and recovery codes are stored as context-bound HMAC hashes. This uses the
existing OBJECTSHARE_SETTINGS_KEY (top-level settings_key in bootstrap
config.json), with domain separation from runtime configuration encryption.
Keep this key stable, identical on every replica, and with database backups.
Losing or changing it also makes existing authenticator secrets and recovery-code
hashes unusable. The normal startup migration adds private MFA state without
enabling MFA or changing existing account data. No new configuration options or
dependencies are required.
For API clients, POST /api/v1/auth/login retains its existing response for
accounts without MFA. With MFA enabled, successful credentials return HTTP 202:
{"mfa_required":true,"challenge_token":"<signed challenge JWT>","method":"totp","expires_in":300,"message":""}Complete the second step using a code from the chosen method or a recovery code:
POST /api/v1/auth/mfa
Content-Type: application/json
{"challenge_token":"<signed challenge JWT>","code":"123456"}Success returns the usual access_token, token_type, and expires_in JSON.
Do not use challenge_token as an access token. Email clients may request a new
code with POST /api/v1/auth/mfa/resend and
{"challenge_token":"<signed challenge JWT>"}. These API flows do not set login
cookies. Invalid, expired, replayed, or locked verification returns HTTP 401;
cooldown and request-rate-limit rejections return HTTP 429. MFA enrollment and
management are available through the account web interface.
Administrators can set No ban, Banned, or Shadowbanned for each account
at /admin/users. Moderation is separate from the existing Active/Disabled control.
It is stored in PostgreSQL and applied to existing and future account-owned files.
Existing accounts migrate to No ban automatically; no configuration changes are needed.
- Banned: password and OAuth login are denied, existing browser and bearer JWTs
lose access, and owned files return a generic
404to all readers. - Shadowbanned: sign-in, uploads, and account features continue to work. Only the
signed-in owner can view, download, or manage their files. Everyone else, including
administrators and selected recipients, receives
404, regardless of sharing mode. Ordinary user JWTs remain valid when a shadowban is applied or removed. The account and login API do not disclose the moderation status to the user. - No ban: restores the saved sharing permissions without changing the account's Active/Disabled setting, role, quota, paid status, or credit balance. JWTs invalidated by a full ban remain invalid; the user must sign in again.
The admin form uses POST /admin/users/{id}/moderation with moderation_status
(banned, shadowbanned, or an empty string). It supports HTMX and native form
submission, requires administrator authorization, and enforces browser CSRF checks;
JWT bearer clients use the same endpoint. Administrators cannot moderate themselves
or remove the final available administrator. Shadowbanned administrators cannot use
administration routes. Access changes, moderation, and deletion serialize their
last-administrator checks in PostgreSQL.
Old owner cookies and direct-upload completion tokens cannot bypass moderation. Shadowbanned owners download through ObjectShare, without receiving new reusable storage download URLs. Storage URLs issued before moderation may continue to work until their existing expiry; already downloaded content cannot be recalled. An upload already authorized at object storage may still transfer bytes until its PUT URL expires, but a banned account cannot finalize it through ObjectShare. Guest uploads and newly registered accounts are independent of an account ban; disable guest uploads or public signup in runtime settings if needed for your policy.
Remove a ban or shadowban before deleting the account: account deletion intentionally makes retained uploads anonymous, so deletion is blocked while moderation is active. Moderation does not delete files, stop normal retention cleanup, cancel subscriptions, or alter invoices or billing records.
Administrators can configure Configuration → Custom branding at /admin/settings.
Set the site name, tagline, navigation logo, upload-page header banner, browser tab
icon (favicon), multiline footer message, and an optional footer link. The site
name appears in page titles and navigation on public, login, setup, account, and
administrator pages. The tagline also supplies the HTML page description. The
existing ObjectShare version and “Made with” heart / “by Cat” credit remain visible.
Saving the configuration activates the branding and its image Content Security
Policy together in the saving replica, and in every other replica at its next
configuration reload. These settings use the
same encrypted PostgreSQL runtime document, administrator authorization, CSRF
protection, and revision checks as other dashboard settings. No schema migration
is needed. Existing installations keep the ObjectShare name and no custom images
or messages until configured. An empty site name restores ObjectShare; empty
optional fields remove that customization. Clear both footer-link fields together.
Images must have stable, public HTTPS URLs, or root-relative paths such as
/branding/logo.png that your reverse proxy already serves. ObjectShare does not
upload, host, or fetch branding images. Use a transparent logo for both themes, a
wide banner, and a square PNG or ICO favicon. Browsers load these assets directly;
private share-page URLs and expiring presigned download URLs are unsuitable.
Only configured image origins are added to img-src; branding does not grant
permission to load scripts or styles from those origins. Footer links accept HTTPS URLs or root-relative paths. URLs
with credentials, fragments, or executable schemes are rejected. All text is
escaped plain text, not custom HTML, CSS, or JavaScript.
For first-import seeding only, the JSON keys belong in a top-level branding
object in config.json. Environment equivalents are shown below and passed
through by compose.yaml. After the database configuration row exists, edit the
dashboard; changing seed values does not replace database settings.
JSON key within branding |
Environment variable | Default / limit |
|---|---|---|
site_name |
OBJECTSHARE_BRANDING_SITE_NAME |
ObjectShare; 80 characters |
tagline |
OBJECTSHARE_BRANDING_TAGLINE |
Empty; 240 characters |
logo_url |
OBJECTSHARE_BRANDING_LOGO_URL |
Empty; 2048 bytes |
header_image_url |
OBJECTSHARE_BRANDING_HEADER_IMAGE_URL |
Empty; 2048 bytes |
favicon_url |
OBJECTSHARE_BRANDING_FAVICON_URL |
Empty; 2048 bytes |
footer_message |
OBJECTSHARE_BRANDING_FOOTER_MESSAGE |
Empty; 2000 characters; newlines preserved |
footer_link_text |
OBJECTSHARE_BRANDING_FOOTER_LINK_TEXT |
Empty; 80 characters; requires a URL |
footer_link_url |
OBJECTSHARE_BRANDING_FOOTER_LINK_URL |
Empty; 2048 bytes; requires link text |
Email sender identity remains independently configured under Outgoing email; branding does not change JWT identifiers, API paths, or payment-provider settings.
Guest uploads are enabled by default. A guest receives a random per-file owner token in an HTTP-only cookie, allowing that browser to rename or delete the file without creating an account. Disable Allow guest uploads in the configuration dashboard and save to require login for new uploads; existing unlisted download links and owner tokens continue to work.
Storage quota is an entitlement of an individual account. Its standard limit is stored in PostgreSQL as users.upload_quota_bytes; it is not selected by role and there is no guest-wide or server-wide quota setting. New accounts default to 0 (unlimited). Administrators can choose an initial quota when creating an account and change it later from /admin/users; the web form uses MiB while the database stores bytes. While a subscription is active, ObjectShare uses the larger of the standard account quota and the plan quota. The historical 0 value remains unlimited, so a finite paid plan never reduces a legacy unlimited account.
Remove the obsolete guest_quota_mib, user_quota_mib, admin_quota_mib, and panel_quota_mib JSON keys and the matching OBJECTSHARE_*_UPLOAD_QUOTA_MB environment variables when upgrading from an earlier quota implementation. ObjectShare rejects them instead of silently starting with different quota behavior.
Complete files and pending direct-upload reservations both consume that account's quota, preventing concurrent requests or multiple application replicas from overcommitting it. Reservations for the same account are serialized with a database row lock; unrelated accounts do not share a quota lock. Deleting a file, aborting a direct upload, or cleaning up an expired reservation releases its bytes. Lowering a quota below current usage blocks new reservations but does not delete existing files. Anonymous uploads are not charged to an account, so disable guest uploads when every stored object must be quota-controlled. Quotas limit stored capacity; use ingress rate limiting and, where appropriate, CAPTCHA separately to control request abuse.
The uploader offers explicit single-file and multiple-file modes. A batch accepts at most upload.max_files_per_batch files (default 10, range 1–100), with the configured per-file size limit applied independently. The legacy first-import environment setting is OBJECTSHARE_MAX_FILES_PER_BATCH. Proxied batches reserve and store each file and roll back earlier files if a later reservation or storage operation fails. Direct-to-storage batches obtain all short-lived, size/type-scoped authorizations with one server request and one CAPTCHA challenge, then verify each object before publishing it.
Plans are managed entirely in ObjectShare and work without an enabled payment gateway. Each plan has one price, quoted on an invoice and paid with account credit or an enabled gateway. Stripe and PayPal also support account-credit top-ups and are disabled by default. To accept top-ups, enable either or both in /admin/settings with one browser-visible public origin and the selected gateway credentials. Secrets are write-only in the administrator UI: blank preserves the encrypted database value, and the explicit clear control removes it. Gateway configuration is nested so additional providers can be added without changing the shared redirect and account-credit configuration:
"billing": {
"public_url": "https://share.example.com",
"credit_currency": "USD",
"min_top_up_credits": 5,
"max_top_up_credits": 1000,
"stripe": {
"enabled": true,
"secret_key": "sk_live_...",
"webhook_secret": "whsec_..."
},
"paypal": {
"enabled": false,
"environment": "sandbox",
"client_id": "",
"client_secret": "",
"webhook_id": ""
}
}Earlier Stripe-only documents with enabled, secret_key, and webhook_secret directly under billing are migrated in memory and remain valid. The legacy first-import environment variables are:
OBJECTSHARE_STRIPE_ENABLED=true
OBJECTSHARE_BILLING_PUBLIC_URL=https://share.example.com
OBJECTSHARE_BILLING_CREDIT_CURRENCY=USD
OBJECTSHARE_BILLING_MIN_TOP_UP_CREDITS=5
OBJECTSHARE_BILLING_MAX_TOP_UP_CREDITS=1000
OBJECTSHARE_STRIPE_SECRET_KEY=sk_live_...
OBJECTSHARE_STRIPE_WEBHOOK_SECRET=whsec_...
OBJECTSHARE_PAYPAL_ENABLED=false
OBJECTSHARE_PAYPAL_ENVIRONMENT=sandbox
OBJECTSHARE_PAYPAL_CLIENT_ID=
OBJECTSHARE_PAYPAL_CLIENT_SECRET=
OBJECTSHARE_PAYPAL_WEBHOOK_ID=Create plans directly at /admin/plans. Each plan defines its name, description, Price (1–1000000000 whole credits), Access duration (1–36500 days), storage quota, retention days, direct-link entitlement, availability, and sort order. The public price and the amount deducted from the account balance come from the same numeric value in PostgreSQL. No provider, external product/price/plan ID, display-only price, or separate credit price is configured for a plan. For example, a price of 10 and a duration of 30 days costs 10 credits and grants 30 days of access.
Existing numeric plan prices and durations retain their values using the original database columns. Historical display labels and provider mappings remain stored solely for compatibility with subscriptions created before this change. An older plan without a positive numeric price and duration stays in the administrator catalog but is unavailable for new purchases until those fields are set; ObjectShare does not guess an amount or duration from a text label.
Account credit is a PostgreSQL-backed prepaid wallet. One credit equals one whole unit of credit_currency; ObjectShare intentionally supports the common two-decimal currencies AUD, BRL, CAD, CHF, CNY, CZK, DKK, EUR, GBP, HKD, ILS, MXN, MYR, NOK, NZD, PHP, PLN, SEK, SGD, THB, and USD. Configure the permitted whole-credit top-up range in /admin/settings. Changing the currency affects future invoices only: existing credits and plan prices are not converted. A user chooses an amount and gateway from /billing (also available in /account); the server records the expected account, currency, and amount before redirecting to Stripe Checkout or PayPal Checkout. The balance changes only when a signed Stripe payment event or an authenticated PayPal capture response matches all of those stored values. Gateway payment identifiers and persistent, account-scoped purchase/adjustment request IDs prevent replays from changing the balance twice across replicas. The account ledger is append-only during the account lifetime and is removed if an administrator deletes that account. Administrators can make a signed positive or negative correction from /admin/users, and every correction requires a reason.
A plan purchase first creates an unpaid invoice. Paying that invoice atomically records payment and creates or extends access for its snapshotted duration. Account-credit payment also deducts the invoice price from the wallet; direct gateway payment leaves the wallet balance unchanged. Buying the same active local plan extends access from its current expiry; a different active plan cannot overlap it. The browser submits the plan ID and an account-scoped purchase request ID, never an authoritative amount. Insufficient balances and repeated requests cannot cause an extra debit. Access does not automatically renew; after it expires, the account returns to its standard entitlements and the user can buy a plan again. Administrator balance adjustments allow local plan purchases even when both top-up gateways are disabled.
Register https://your-origin.example/api/v1/billing/stripe/webhook in Stripe for checkout.session.completed and checkout.session.async_payment_succeeded. ObjectShare verifies the Stripe-Signature against the raw body with a five-minute tolerance. Checkout events settle a plan or top-up invoice only when mode=payment, payment_status=paid, and their metadata, currency, and total match a pending server-side top-up. Browser success pages never grant access or credit. Every replica activates an enabled gateway or a rotated secret at its next configuration reload.
For PayPal, create REST API credentials, first test with environment set to sandbox, then switch the credentials and environment to live for production. Register https://your-origin.example/api/v1/billing/paypal/webhook for PAYMENT.CAPTURE.COMPLETED and copy the webhook ID into ObjectShare. ObjectShare authenticates webhook signatures through PayPal's verification endpoint. PayPal returns an order token to a narrowly scoped endpoint; ObjectShare matches it to a pending top-up, captures the approved order over PayPal's authenticated API, and verifies the capture ID, custom ID, amount, and currency before settling the invoice. A top-up adds credit; a direct plan payment activates the plan without minting wallet credit. A later authenticated webhook is idempotent.
New external subscription checkouts are disabled. Existing subscriptions retain webhook updates and billing-management access so already purchased access and cancellation continue to work. For legacy renewals, also register Stripe invoice.paid (webhook API version 2024-06-20) or PayPal PAYMENT.SALE.COMPLETED before upgrading. Verified recurring receipts create paid local invoices, enqueue PDF confirmation emails, and extend access; subscription lifecycle events alone no longer extend a paid period. Stripe renewal invoices must have one recurring subscription line; prorated/multi-line adjustments require operator reconciliation. PayPal uses authenticated subscription details to associate the latest sale with its next billing time; older delayed sale receipts are recorded without extending access to an unrelated later period. For those legacy subscriptions only, retain the previously configured Stripe customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted events, or PayPal BILLING.SUBSCRIPTION.ACTIVATED, BILLING.SUBSCRIPTION.UPDATED, BILLING.SUBSCRIPTION.SUSPENDED, BILLING.SUBSCRIPTION.CANCELLED, BILLING.SUBSCRIPTION.EXPIRED, and BILLING.SUBSCRIPTION.PAYMENT.FAILED events. An active legacy subscription must end before the account can purchase a local plan.
At /plans, Review invoice creates a 24-hour quotation without deducting
credit or granting access. The invoice snapshots the plan name, description,
price, currency, access duration, quota, retention, direct-link benefits, and
recipient email. Form retries return the same invoice. /invoices lists the
signed-in user's invoices in pages of 25; /invoices/{id} shows the details and
payment actions, and /invoices/{id}/pdf downloads a server-generated PDF. All
three routes require account authentication; invoice reads and payments are
owner-scoped, and responses use private, no-store. Cookie mutations require
JWT CSRF protection. The existing /billing/credit/{id} form endpoint now
creates an invoice, so older open plan pages cannot bypass the review step.
An unpaid plan invoice can be paid in full from account credit or through any enabled payment gateway. Its gateway amount uses the existing conversion of one credit to one currency unit. A credit top-up also creates an invoice before checkout. Once gateway checkout starts, the invoice stays bound to that gateway and other overlapping plan payments are blocked. Reopening the invoice reuses the saved checkout URL. If the first gateway response is lost, creating the same checkout may be retried only within five minutes, within both providers' idempotency windows. The payment window does not invalidate a genuine delayed receipt. Canceled, failed, or ambiguous gateway attempts must be reconciled by an operator before releasing a reservation; do not clear a reservation while it can still be charged. ObjectShare does not automatically void or refund these attempts.
Payments, wallet ledger entries, invoice status, and plan activation commit in one PostgreSQL transaction. Repeated or concurrent receipts cannot charge the wallet or activate the plan twice. Later catalog edits do not alter an already issued invoice's price or the benefits granted by its settlement. Local access continues to be prepaid with no automatic wallet renewal. Administrator credit corrections remain audited ledger adjustments, rather than purchases.
Every newly paid invoice queues a confirmation email to its snapshotted recipient,
with the paid PDF attached. The background worker checks the database every ten
seconds, claims at most 20 messages per pass, and retries failures after five
minutes. Row leases with SKIP LOCKED support multiple replicas and crash recovery.
Email failure never reverses payment. Configure an outgoing provider under
/admin/settings and save to enable delivery; while email is
disabled, confirmations remain queued. Provider acceptance is displayed on the
invoice page. A crash after provider acceptance but before recording it can cause
a duplicate confirmation email; the purchase itself remains idempotent.
No invoice configuration keys are added: gateway/public-URL/currency settings stay
inside billing, and delivery settings stay inside email in the encrypted runtime
configuration (or first-import config.json/environment). Database tables and
columns migrate automatically. Historical completed purchases are not fabricated
into invoices because their original terms were not stored. Previously issued
pending top-ups gain an invoice when they settle after the upgrade. Invoices and
their queue state are removed with the account, matching the existing ledger policy.
PDFs embed the Go font and an openly licensed Noto Sans TC fallback for Chinese
and Japanese glyphs, and require no browser, external renderer, or runtime font
installation. Font sources and licensing are recorded in invoice/fonts/.
Characters outside the embedded fonts (including emoji) are represented explicitly
as [U+XXXX] code points in the PDF. The web invoice retains the original Unicode
text. PDFs are
purchase records with a unique invoice identifier and payment reference; tax,
VAT registration, postal billing addresses, and jurisdiction-specific fiscal
numbering are not configured by this feature.
ObjectShare does not automatically process provider-side refunds, disputes, or chargebacks. Resolve them at the provider and record the corresponding negative administrator adjustment so the local ledger remains auditable; balances may be negative, and a negative or insufficient balance cannot buy a prepaid plan.
When upgrading from an earlier release, finish or abandon outstanding gateway subscription checkouts before purchasing local access. If an old provider checkout settles after prepaid access was purchased, ObjectShare rejects the conflicting subscription update instead of overwriting the prepaid period. Reconcile that overlapping subscription at the provider; it is not automatically canceled or refunded.
An active plan raises a finite account quota to at least the plan quota. It applies its own retention window while active; 0 plan-retention days means no age-based deletion during the active subscription. When access ends, the account immediately returns to its standard quota and unpaid retention window, so old files can become eligible during the next sweep. Existing objects are not synchronously deleted merely because the plan ends. Direct-link plans enable GET /api/v1/download/{id} only while active; otherwise that URL redirects to the file details page, whose short-lived signed POST authorization prevents method-switch bypasses. If administrator-enforced download CAPTCHA is enabled, it continues to require the details-page flow even for a direct-link plan.
ObjectShare can permanently delete completed guest files and completed files owned by accounts without an active plan after separate administrator-defined numbers of days. Both policies default to 0 (disabled), so an upgrade never starts deleting existing data until an administrator deliberately enables retention. Configure Guest retention and Unpaid retention at /admin/settings and save; each replica's retention sweep uses the new policy as soon as that replica activates the revision. Active plans use their plan-specific retention days instead. /admin/users retains a manual retention exemption for complimentary or externally billed accounts; it is independent from billing gateways, quota, and direct-link access. Removing an exemption or ending a subscription can make older files immediately eligible at the next sweep.
The legacy first-import inputs are:
OBJECTSHARE_GUEST_RETENTION_DAYS=0
OBJECTSHARE_UNPAID_RETENTION_DAYS=0Their config.json equivalent is the top-level retention object with guest_days and unpaid_days. Values are whole days from 0 through 36500; 0 disables that category. Age is measured from the file's upload creation time. Pending upload authorizations keep their separate short expiry and are not treated as completed retained files.
Each replica performs a sweep at startup and then hourly; a full backlog batch schedules another sweep after one minute. PostgreSQL claims bounded batches with row locking and SKIP LOCKED, so replicas cooperate without intentionally processing the same live record. The object is deleted before its metadata; a storage failure releases the claim and keeps the share record for retry, while an interrupted or database-failed deletion is reclaimed later. Once deletion succeeds, the share URL and owner controls stop working. Back up data before enabling a shorter policy because automatic deletion is permanent.
PostgreSQL remains the durable source of truth. Redis is optional when running
the binary directly and enabled by default in Compose. Existing config.json
files need no migration: without redis.url, the app keeps using PostgreSQL for
request limits and reads the plan catalog directly.
| Data | With Redis configured | Correctness and outage behavior |
|---|---|---|
| Request rate-limit buckets | Atomic Lua decisions and expiring counters shared across replicas; no per-request PostgreSQL transaction | Uses Redis server time and hashed identities. Redis errors reject limited requests with HTTP 500; they never grant access or switch to independent PostgreSQL counters. |
| Public plan catalog | Cache on read, 30-second TTL by default; successful plan creation/edit invalidates it across replicas | Cache misses, malformed entries, and Redis failures read PostgreSQL. Fill tokens prevent an older query from resurrecting an invalidated entry. Checkout/invoice creation always reads current PostgreSQL prices and availability. |
| Accounts, permissions, moderation, JWT revocation, login lockouts, verification tokens, upload quotas/reservations, entitlements, invoices, payments, and configuration | PostgreSQL remains authoritative | No cache delay is introduced into these security or transaction boundaries. Configuration already has a per-process snapshot and revision polling. |
Redis options belong in the top-level redis object of config.json, or in
these environment variables. They are bootstrap settings read on every start,
not first-import runtime settings and not editable at /admin/settings.
| Environment variable | JSON field under redis |
Default | Meaning |
|---|---|---|---|
OBJECTSHARE_REDIS_URL |
url |
Empty for the binary; redis://redis:6379/0 in Compose |
Empty disables Redis. Supports redis:// and TLS rediss://, with optional URL credentials and database index. |
OBJECTSHARE_REDIS_PASSWORD |
password |
Empty for the binary; development fallback in Compose | When nonempty, overrides a password in the URL. Compose uses the same value to protect its Redis service. |
OBJECTSHARE_REDIS_KEY_PREFIX |
key_prefix |
objectshare: |
Unique namespace per installation; all replicas of that installation must share it. 1-128 characters, without spaces, tabs, newlines, or braces. |
OBJECTSHARE_REDIS_TIMEOUT |
timeout |
1s |
Per-operation timeout, from 1ms to 30s; mutating commands are not automatically retried. |
OBJECTSHARE_REDIS_PUBLIC_PLANS_TTL |
public_plans_ttl |
30s |
Catalog display lifetime, from 1ms to 5m; 0 disables catalog caching while retaining Redis rate limiting. |
Use a dedicated Redis instance or database and a different key prefix for each
ObjectShare installation. Use rediss:// for external Redis over untrusted
networks. The configured Redis endpoint is checked at startup and by
/health/ready; /health/live remains independent of backend availability.
Compose gives Redis an AOF volume, appendfsync everysec, a 128 MiB memory limit,
and noeviction, so memory pressure fails requests instead of silently deleting
active counters. Catalog entries and rate buckets expire automatically. Monitor
memory and increase the Redis --maxmemory value for a busy deployment. A crash
can lose approximately the last second of AOF writes; deleting the Redis volume
resets all counters. Redis is not a replacement for durable account lockouts.
If invalidation fails or a plan is changed directly in SQL, its displayed catalog
entry can stay stale until the existing TTL expires. The TTL starts before the
database read and is not extended by a slow cache fill. Financial operations use
the database regardless. To disable Redis in Compose, set
OBJECTSHARE_REDIS_URL= explicitly; the bundled Redis service still starts but
the app does not use it. To change backends or namespaces, stop all app replicas
and restart them together with the same settings. Counters are not migrated, so
the change starts a fresh rate-limit window; do not mix PostgreSQL and Redis
backends during a rolling deployment.
The Go suite exercises Redis Lua scripts against an in-process Redis test server,
including competing replicas, expiry, invalidation races, and outages. With
OBJECTSHARE_TEST_POSTGRES_DSN set, it also verifies real database fallback,
catalog invalidation, and authoritative invoice pricing despite a stale cache.
ObjectShare supports Cloudflare Turnstile on password and OAuth login, public sign-up, proxied and direct uploads, and downloads. CAPTCHA is disabled by default so an existing configuration continues to start without site-specific credentials. To protect every supported boundary, create a Turnstile widget for the public ObjectShare hostname and configure its provider, site key, write-only secret, exact hostname, and all four route switches in the administrator dashboard. The legacy first-import environment equivalents are:
OBJECTSHARE_CAPTCHA_PROVIDER=turnstile
OBJECTSHARE_CAPTCHA_SITE_KEY=your-public-site-key
OBJECTSHARE_CAPTCHA_SECRET_KEY=your-private-secret-key
OBJECTSHARE_CAPTCHA_EXPECTED_HOSTNAME=share.example.com
OBJECTSHARE_CAPTCHA_PROTECT_LOGIN=true
OBJECTSHARE_CAPTCHA_PROTECT_SIGNUP=true
OBJECTSHARE_CAPTCHA_PROTECT_UPLOAD=true
OBJECTSHARE_CAPTCHA_PROTECT_DOWNLOAD=trueOlder JSON configuration uses the top-level captcha object with provider, site_key, secret_key, expected_hostname, protect_login, protect_signup, protect_upload, and protect_download; it is imported once. Restrict the widget to the real hostname in Cloudflare and also set expected_hostname; ObjectShare validates both the returned hostname and the operation-specific Turnstile action. Tokens are verified server-side, rejected when missing, invalid, expired, replayed, or issued for a different action/hostname, and the protected operation fails closed if Siteverify is unavailable. Cloudflare documents that server verification is mandatory and that tokens are single-use with a five-minute lifetime in its server-side validation guide.
When download protection is on, the file page submits a POST after the challenge instead of exposing a challenge-free GET download; presigned object-storage redirects are issued only after verification. Direct upload protection applies when the application creates the short-lived upload authorization, before a browser receives the object-storage URL. The completion and abort calls remain bound to that pending upload's random owner token and are also covered by the general API limit.
Non-browser clients supply captcha_token in the JSON API-login or direct-upload authorization body. Multipart upload and form download clients may supply the standard cf-turnstile-response field or X-Captcha-Token header; CAPTCHA-protected downloads use POST /api/v1/download/{id}. A fresh token is required for every protected request.
Rate limiting is enabled by default and uses a fixed-window bucket shared by every application replica: Redis when configured, otherwise PostgreSQL. Authenticated requests are keyed to a SHA-256 hash of the user ID; unauthenticated requests use a hash of the direct client IP. Raw client identities are not stored in rate-limit keys or rows. Defaults are 120 requests per minute across /api/v1, plus route-specific limits of 10 login starts, 5 sign-ups, 20 upload starts, and 60 downloads per minute. Change these values in the dashboard. The legacy first-import variables are:
OBJECTSHARE_RATE_LIMIT_ENABLED=true
OBJECTSHARE_RATE_LIMIT_WINDOW=1m
OBJECTSHARE_RATE_LIMIT_API=120
OBJECTSHARE_RATE_LIMIT_LOGIN=10
OBJECTSHARE_RATE_LIMIT_SIGNUP=5
OBJECTSHARE_RATE_LIMIT_UPLOAD=20
OBJECTSHARE_RATE_LIMIT_DOWNLOAD=60Older JSON configuration uses the top-level rate_limit object as enabled, window, api_limit, login_limit, signup_limit, upload_limit, and download_limit. A limit of 0 disables that scope; the window may be from one second to 24 hours. Rejected requests return HTTP 429, Retry-After, X-RateLimit-Limit, and X-RateLimit-Scope. This application control complements—not replaces—connection, bandwidth, and request-body limits at the public reverse proxy.
Forwarded IP headers are ignored unless the TCP peer belongs to a trusted proxy CIDR configured in the dashboard. The legacy seed is OBJECTSHARE_TRUSTED_PROXY_CIDRS, a comma-separated list; its older JSON equivalent is rate_limit.trusted_proxy_cidrs, an array. ObjectShare walks X-Forwarded-For from the trusted side and selects the first untrusted address. Do not add broad public networks merely to make a header work; an incorrect trust boundary lets clients choose their own limiter key.
OAuth providers are optional and disabled by default. In the dashboard, set the public URL to the exact browser-visible origin (for example, https://share.example.com) and enter a provider's client ID and write-only secret. The legacy first-import variables are:
OBJECTSHARE_PUBLIC_URL=https://share.example.com
OBJECTSHARE_SECURE_COOKIES=true
OBJECTSHARE_GOOGLE_OAUTH_ENABLED=true
OBJECTSHARE_GOOGLE_OAUTH_CLIENT_ID=your-google-client-id
OBJECTSHARE_GOOGLE_OAUTH_CLIENT_SECRET=your-google-client-secret
OBJECTSHARE_GITHUB_OAUTH_ENABLED=true
OBJECTSHARE_GITHUB_OAUTH_CLIENT_ID=your-github-client-id
OBJECTSHARE_GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret
OBJECTSHARE_DISCORD_OAUTH_ENABLED=true
OBJECTSHARE_DISCORD_OAUTH_CLIENT_ID=your-discord-application-id
OBJECTSHARE_DISCORD_OAUTH_CLIENT_SECRET=your-discord-client-secretRegister these exact callback URLs with the providers you enable:
- Google:
https://share.example.com/oauth/google/callback - GitHub:
https://share.example.com/oauth/github/callback - Discord:
https://share.example.com/oauth/discord/callback
Create the credentials as a Google web application, a GitHub OAuth App, or a Discord application. See Google's web-server OAuth setup, GitHub's OAuth App creation guide, and Discord's OAuth2 documentation. For Discord, add the callback under OAuth2 > Redirects in the Developer Portal; ObjectShare requests only the identify and email scopes and does not require a bot.
For older JSON configuration, the equivalent settings belong under auth.oauth: public_url, then the enabled, client_id, and client_secret fields under google, github, or discord. public_url must be an HTTPS origin without a path, query, or fragment; plain HTTP is accepted only for localhost and loopback development addresses. HTTPS OAuth configuration also requires secure cookies. When deployed behind a reverse proxy, configure the public URL, not the container's internal address.
OAuth uses the authorization-code flow with a fresh signed state value and PKCE challenge for every attempt. ObjectShare requests only identity/profile scopes, accepts only a provider's stable account ID plus verified email, does not store provider access or refresh tokens, and issues the same hardened ObjectShare JWT used by password login.
A new verified OAuth identity creates a normal user only while public signup is enabled. If its email already belongs to an ObjectShare account, automatic email-based merging is refused: log in with the existing password and link Google, GitHub, or Discord from My account. OAuth-only users can set a password there. ObjectShare also prevents removing the final login method. Disabling public signup does not stop already-linked identities from signing in.
Account authentication uses signed HS256 JWTs only; there is no server-side login-session table. Tokens require the ObjectShare issuer and audience plus sub, jti, iat, nbf, exp, role, token-version, and CSRF claims. Browser login stores the JWT in an HttpOnly, SameSite=Strict cookie (and a Secure __Host- cookie when OBJECTSHARE_SECURE_COOKIES=true). Cookie-authenticated mutations require the CSRF value embedded in the signed token. Passwords are hashed with Argon2id, and login attempts are throttled after repeated failures.
API clients can exchange credentials for a bearer JWT and revoke it on logout:
curl -sS -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"your password"}'
curl -i -X POST http://localhost:8080/api/v1/auth/logout \
-H 'Authorization: Bearer <access_token>'Logout stores only a SHA-256 hash of the JWT ID until that token expires. Requests also reload the account and reject revoked JWTs, disabled/deleted users, stale token versions, or role mismatches. Bearer tokens take precedence over cookies and are never returned in a cookie by the API login endpoint.
To bootstrap the initial administrator from the CLI instead of the web page, provide the password through a mounted/readable file so it does not appear in shell history or the process list:
object-share -config config.json -create-admin \
-admin-email admin@example.com \
-admin-name "Site administrator" \
-admin-password-file /run/secrets/objectshare_admin_passwordFor Compose, read the password without echoing it and pipe it to the one-off command:
read -rsp "Administrator password: " OBJECTSHARE_BOOTSTRAP_PASSWORD && echo
printf '%s' "$OBJECTSHARE_BOOTSTRAP_PASSWORD" | docker compose run --rm -T \
app -create-admin -admin-email admin@example.com \
-admin-name "Site administrator" \
-admin-password-stdin
unset OBJECTSHARE_BOOTSTRAP_PASSWORDThe CLI bootstrap is intentionally one-time and refuses to create an administrator after one already exists. Further administrators must be created by an authenticated administrator. OBJECTSHARE_ADMIN_PASSWORD is also accepted for automation, but a password file or secret mount is preferred.
Open Sharing & permissions from a file's details page, or Share beside an upload in My files or My account. The dedicated owner page is GET /file/{id}/sharing; recipients use the existing /file/{id} link. The page includes a copy-link button and a native form enhanced with HTMX. A saved-access summary states who can currently download. Unsaved permission edits disable link creation and copying until saved; selected-account fields appear when that access option is chosen.
For a client-encrypted file, enter your separate encryption passphrase on the sharing page and select Create sharing link, then Copy link. You can also unlock with your encrypted key backup. The browser derives only this file's key and places it in the URL fragment; the account key and passphrase are never sent to recipients or the server. A plain file-page address remains available but cannot decrypt the file by itself. Wrong passphrases do not produce a link, and leaving the page clears the generated link. Recipients still need the saved access permissions; private files remain private even when someone has the key.
| Access option | Who can view details and download? |
|---|---|
Anyone with the link (link) |
Anyone who has the unlisted UUID link. |
Signed-in users (signed_in) |
Any active account authenticated by JWT. |
Selected accounts (selected) |
Only the active accounts selected by the owner, authenticated by JWT. |
Private (private) |
Only the owner. |
The owner always retains access and is the only person who can change sharing, rename, or delete. Administrators receive no implicit file-access override. For guest uploads, the original browser's owner cookie remains the ownership credential; losing or expiring that cookie loses management access and access to private guest files. For account uploads, the owning account can manage files across devices using its JWT.
Selected-account sharing accepts up to 50 existing active account email addresses, separated by commas, semicolons, or newlines. Permissions store account IDs, so changing an email does not remove access and recreating a deleted email does not inherit access. Recipients must log in and then open the file link. Send the link yourself; saving permissions does not send invitations. Saving another access option clears the selected-account list. Unauthorized requests receive a generic 404 without the file's metadata or recipient list.
The upload form lets you select link, signed_in, or private before any file is published. To share with selected accounts without an initial public window, upload privately, then add recipients on the sharing page. The same choice applies to every file in a browser batch. Multipart API uploads accept the share_mode form field; direct-upload authorization accepts share_mode in its JSON file object (each entry in files for /api/v1/uploads/direct/batch). Omitted values preserve the existing link default; invalid values are rejected. Completion cannot override the policy saved at authorization.
Owners can also submit a URL-encoded POST /file/{id}/sharing with share_mode and, for selected, recipients. Account API clients use their bearer JWT; browser submissions require CSRF protection as well as ownership. For example:
curl -i -X POST https://share.example.com/file/FILE_UUID/sharing \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'share_mode=selected' \
--data-urlencode 'recipients=reader@example.com'Permissions are stored in PostgreSQL on file_lists (share_mode and share_user_ids) and updated atomically. Startup migration adds the columns, preserving existing files as link; no new configuration option is required. Existing download CAPTCHA, rate limits, and paid direct-link entitlements still apply and never override file permissions. File details, permission pages, and download responses use Cache-Control: private, no-store.
Restricted downloads stream through the application to recheck authorization on every new request. Permission changes do not recall downloaded copies, interrupt requests already authorized, or revoke storage URLs issued while the file was shared with anyone; those URLs remain valid until their existing expiry. Keep object-storage buckets private, since public bucket access bypasses application authorization.
All five object-storage providers use private buckets and the S3 API. When server-side encryption is disabled, JavaScript-enabled browsers upload directly to a short-lived URL bound to one object key, exact size, and content type. ObjectShare creates a pending database record first, then verifies the stored object's size and content type before publishing its share page. Expired or aborted pending uploads are removed. Cancellation and expiry cleanup claim unfinished uploads in the database before deleting objects, so a stale request cannot delete an upload that has already completed. Failed object deletions remain reserved against the owner's quota and are retried during cleanup triggered by subsequent uploads. Only authorization and completion requests pass through ObjectShare, so a reverse proxy or CDN in front of the app does not carry the file body.
Files shared with anyone use short-lived presigned download URLs unless ObjectShare server-side encryption is enabled. Signed-in, selected-account, and private downloads stream through ObjectShare after authorization on each request; provision application bandwidth and proxy download timeouts accordingly. The direct path cannot provide application-verified SHA checksums because ObjectShare never receives the file bytes; the details page labels those checksums as unavailable. Server-side encryption and direct upload are mutually exclusive because that encryption runs on the server. Client-side encryption supports direct uploads: browsers send ciphertext with the same size/type-bound authorization and finalize checks. Client-encrypted downloads stream through ObjectShare for same-origin browser decryption and access checks, including files shared with anyone.
Grant the configured identity only read, write, and delete access to the selected bucket. Do not grant account-wide bucket administration. Direct uploads require a bucket CORS rule allowing the exact public ObjectShare origin, the PUT method, and the Content-Type header. The S3-style equivalent is:
[
{
"AllowedOrigins": ["https://share.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3600
}
]Use the provider console's equivalent fields when it does not accept S3 CORS JSON directly. Add a separate localhost origin for local browser testing. Avoid wildcard origins for private buckets.
Presigned download timeouts default to 10m; upload timeouts default to 1h. Configure them per provider in the dashboard. The legacy first-import variables are OBJECTSHARE_<PROVIDER>_PRESIGN_TIMEOUT and OBJECTSHARE_<PROVIDER>_UPLOAD_PRESIGN_TIMEOUT, replacing <PROVIDER> with R2, S3, B2, OSS, or COS. Both support a maximum of 168h. Each direct object upload is a single PUT and is capped at 5 GiB; the UI can upload several such files as a batch, but larger individual objects require S3 multipart-object upload support, which ObjectShare does not currently implement.
Select Cloudflare R2 in the dashboard and provide the bucket, account ID, write-only credentials, region, and timeouts. Older deployments can seed those fields once with:
OBJECTSHARE_R2_BUCKET_NAMEOBJECTSHARE_R2_ACCOUNT_IDOBJECTSHARE_R2_ACCESS_KEY_IDOBJECTSHARE_R2_SECRET_ACCESS_KEYOBJECTSHARE_R2_PRESIGN_TIMEOUT(default10m, maximum168h)OBJECTSHARE_R2_UPLOAD_PRESIGN_TIMEOUT(default1h, maximum168h)
An account ID produces the standard https://<account-id>.r2.cloudflarestorage.com endpoint. OBJECTSHARE_R2_ENDPOINT remains available for an explicit HTTPS endpoint. See Cloudflare's R2 limits and upload guidance.
Select Amazon S3 / S3-compatible in the dashboard and provide the bucket and region. The legacy seeds are OBJECTSHARE_STORAGE_SERVICE=s3, OBJECTSHARE_S3_BUCKET_NAME, and OBJECTSHARE_S3_REGION. Authentication uses the AWS SDK default credential chain when the access-key fields are empty, so IAM roles and workload credentials are preferred. For explicit temporary credentials, also enter a write-only session token.
OBJECTSHARE_S3_ENDPOINT is optional and supports HTTPS S3-compatible endpoints. Set OBJECTSHARE_S3_USE_PATH_STYLE=true only when that endpoint requires path-style addressing; native S3 uses virtual-hosted style by default.
See AWS's Go v2 presigned upload example and S3 CORS guide.
Select Backblaze B2 in the dashboard and provide its bucket, region, and write-only credentials. The legacy seeds are OBJECTSHARE_STORAGE_SERVICE=b2, OBJECTSHARE_B2_BUCKET_NAME, OBJECTSHARE_B2_REGION, OBJECTSHARE_B2_ACCESS_KEY_ID, and OBJECTSHARE_B2_SECRET_ACCESS_KEY. The endpoint defaults to https://s3.<region>.backblazeb2.com; override it only when necessary. Use a bucket-restricted B2 application key, not the master application key.
See Backblaze's S3-compatible API endpoint guide and CORS rules.
Select Alibaba OSS in the dashboard and provide its bucket, region, and write-only credentials. The matching legacy seeds use the OBJECTSHARE_OSS_* prefix. The AWS-SDK-compatible endpoint defaults to https://s3.oss-<region>.aliyuncs.com; an override can select another S3-compatible OSS service endpoint, such as https://s3.oss-<region>-internal.aliyuncs.com. OSS requires virtual-hosted-style requests, so path style is not offered. Alibaba's bucket-bound CNAME mode is not an S3 service endpoint and is not accepted here. New OSS users accessing buckets in Chinese mainland regions should confirm their account's current endpoint eligibility before deployment.
See Alibaba Cloud's AWS SDK compatibility guide, region endpoints, and CORS guide.
Select Tencent COS in the dashboard and provide its bucket, region, and write-only credentials. The matching legacy seeds use the OBJECTSHARE_COS_* prefix. Use the full bucket name including its APPID suffix, such as objectshare-1250000000. The endpoint defaults to https://cos.<region>.myqcloud.com; override it only when needed. Current COS buckets use virtual-hosted-style requests.
See Tencent Cloud's S3-compatible configuration guide and AWS SDK for Go v2 compatibility example.
See the production-readiness acceptance checklist for product and deployment release gates, the available local evidence, and checks that still require a browser or staging environment.
- Put the service behind HTTPS and enable secure cookies.
- Set stable, independent, high-entropy JWT and database-settings keys on every replica; rotate the JWT only when intentionally invalidating all tokens and never change the settings key without a supported re-encryption migration.
- Disable public signup if accounts should be invitation-only.
- Use a long, unique PostgreSQL password and TLS (
ssl_mode=requireor stronger) for external databases. - Keep the database private; only publish the application port.
- Use an object-storage provider for horizontally scaled deployments. Filesystem storage is intended for a single application replica.
- Persist and back up object storage and PostgreSQL consistently.
- Enable Turnstile on login, sign-up, upload, and download with production keys and an exact expected hostname.
- Tune ObjectShare's shared request limits, configure trusted proxy CIDRs precisely, and retain ingress connection/body rate limits.
- Keep bootstrap secrets in a secret manager; object-storage and object-encryption secrets are encrypted in PostgreSQL and remain write-only in the dashboard.
- Monitor
/health/livefor process health and/health/readyfor database readiness. - Test upgrades and restores in a staging environment before production rollout.
Follow the backup and restore runbook to capture the database, objects, and encryption settings together, rehearse recovery in isolation, and reconcile billing and authentication state before returning a restored site to service.
.github/workflows/release.yml builds downloadable Linux archives for AMD64 v1/v3, ARM64, and RISC-V. Builds are retained as workflow artifacts for pushes, pull requests, and manual runs; published GitHub Releases also receive the archives and SHA-256/SHA3-256 checksum files.
.github/workflows/container-publish.yml publishes linux/amd64 and linux/arm64 images to GHCR. Every push to main updates ghcr.io/<owner>/<repository>:dev for development testing; published GitHub Releases produce versioned images, and manual runs produce the edge tag. Docker Hub publishing is optional for releases and manual runs; configure all three settings below to enable it:
- Variable
DOCKERHUB_USERNAME: Docker Hub account or organization - Variable
DOCKERHUB_IMAGE: full image name, for exampleacme/objectshare - Secret
DOCKERHUB_TOKEN: a Docker Hub access token with push access
Pushes to main publish the dev tag to GHCR only. For releases and manual runs, if any Docker Hub setting is absent, that login and image target are skipped and the workflow publishes to GHCR only. GHCR uses the workflow-scoped GITHUB_TOKEN; no additional secret is needed. Published images include BuildKit provenance and an SBOM. Package visibility is managed from the repository's Packages settings.
.github/workflows/workflow_runs_clean_up.yml runs daily (or manually), deletes runs older than seven days, and always retains the newest run for each workflow.
go mod verify
go test -race ./...
go vet ./...
go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...CI also verifies formatting and builds the container. Dependency and action updates are proposed weekly by Dependabot.
PostgreSQL integration tests are skipped unless OBJECTSHARE_TEST_POSTGRES_DSN is set. Point it at a disposable test database, never the production database, using a role that can create schemas. Run go test -count=1 -run TestPostgres -v ./db (with -mod=mod if your ignored vendor directory is stale). These tests create unique isolated schemas and remove them afterward. Coverage includes startup and migration, concurrent billing settlement, rollback, account encryption, sharing and moderation, administrator queries, and competing upload completion/deletion. The ordinary test suite also covers gateway requests, payment validation, authorization, CSRF, configuration, and actual HTML template rendering without contacting payment providers.
The CI Go job supplies a disposable PostgreSQL service to the full race-enabled suite and checks that Node is available for the JavaScript tests. Its database credentials are test-only and require no repository secrets. Local race checks need CGO and a supported C compiler; see the Go race detector requirements.
The CI container job also loads the built image and starts a new, isolated Compose
project. tests/install-smoke.cjs creates a synthetic first administrator, checks
the admin and customer pages, uploads/downloads a private encrypted fixture, and
repeats the access checks after restarting the application container. The job
collects diagnostics on failure and removes its disposable volumes afterward.
This smoke script is only for a fresh disposable installation: its CLI requires
OBJECTSHARE_SMOKE_ALLOW_SETUP=1 and a CI-specific Compose project name, accepts
only a loopback target, and refuses a site where administrator setup is closed.
For listing performance, run the optional PostgreSQL workspace benchmark described in workspace performance. It seeds an isolated schema with 5,000 accounts, 50,000 file records, and 25,000 invoices, then checks bounded pages and measures serial and concurrent queries. This database benchmark does not replace staging HTTP, upload, or multi-replica load tests.
