Skip to content

Repository files navigation

ObjectShare

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.

Preview

ObjectShare v0.1.0

Features

  • 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 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
  • Optional server-verified Turnstile protection and shared PostgreSQL request rate limits
  • 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
  • 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.

Roadmap

  • File upload
  • Single-file and multiple-file upload modes
  • Upload quota
  • CAPTCHA and API rate limiting
  • File download
  • Paid storage, retention, and direct-link plans
  • Account credit, top-ups, and prepaid plan purchases
  • Better upload UI
  • File sharing & permission
  • File deletion
  • Auto file deletion after days for guest and unpaid users
  • User management
  • Administrator configuration dashboard
  • Custom branding support
  • Third-party OAuth login support
  • Server-side encryption & decryption
  • Client-side encryption & decryption

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.

Supported Object Storage Services

  • Cloudflare R2
  • AWS S3
  • Backblaze B2
  • Alibaba Cloud OSS
  • Tencent Cloud COS
  • Google Cloud Storage
  • Oracle Cloud Object Storage
  • Microsoft Azure Blob Storage

Email providers

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 and restart every replica, 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. starttls requires STARTTLS before authentication or delivery; tls starts 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, tls selects 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:SingleSendMail permission. 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. Attachments, bulk mailing, queues, bounce processing, and automatic account/notification emails are outside this provider integration; current account authentication behavior is unchanged.

Quick start with Docker Compose

cp .env.example .env
# Edit .env and replace the PostgreSQL, JWT, and settings encryption secrets.
docker compose up --build -d

Open http://localhost:8080. Compose uses PostgreSQL 18 and a persistent local object volume. Stop it with docker compose down; add --volumes only when you intentionally want to delete all stored data.

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, save, and restart every application replica. Back up both named volumes together so metadata, encrypted configuration, and objects remain consistent.

Run from source

Requirements: Go 1.27 and PostgreSQL 18 (PostgreSQL 17 is also supported).

cp config.json.example config.json
# Edit the bootstrap database and secret settings.
go run . -config config.json

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

Only bootstrap settings remain file/environment-owned because they are needed before PostgreSQL configuration can be opened:

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_JWT_SECRET none (required) JWT HMAC signing secret, at least 32 random bytes
OBJECTSHARE_JWT_LIFETIME 12h JWT lifetime (5m to 24h)
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. Saved changes intentionally require a restart because storage clients, encryption, OAuth, CAPTCHA CSP, cookies, and proxy trust must change as one consistent startup snapshot. Restart every replica after saving. 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.

User and administrator management

Normal users manage their profile, password, appearance, paid plan, and account-owned uploads at /account. The light/dark theme choice is stored with the account, so it follows the user across browsers and is applied to every authenticated page. Administrators have 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.

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.

Custom branding

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.

Save the configuration and restart every application replica to activate the branding and its image Content Security Policy together. 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 and per-user storage quotas

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 restart 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 1100), 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.

Paid plans and billing gateways

Plans are managed entirely in ObjectShare and work without an enabled payment gateway. Each plan has one price, paid from the account balance. Stripe and PayPal are optional account-credit top-up providers, 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 top-ups only: existing credits and plan prices are not converted. A user chooses an amount and gateway from /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 atomically deducts its price from the wallet and creates or extends access for its configured duration. 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 add credit 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. Restart every replica after enabling a gateway or rotating its secrets.

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

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.

Automatic file retention

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, save, and restart every application replica. 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=0

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

CAPTCHA and request rate limiting

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=true

Older 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 PostgreSQL fixed-window bucket shared by every application replica. 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 the rate-limit table. 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=60

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

Google, GitHub, and Discord OAuth login

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-secret

Register 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_password

For 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_PASSWORD

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

File sharing and permissions

Open Sharing & permissions from a file's details page, or Share beside an upload in 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.

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.

Object storage

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. 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. Encryption and direct upload are intentionally mutually exclusive because encryption keys remain on the server.

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.

Cloudflare R2

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_NAME
  • OBJECTSHARE_R2_ACCOUNT_ID
  • OBJECTSHARE_R2_ACCESS_KEY_ID
  • OBJECTSHARE_R2_SECRET_ACCESS_KEY
  • OBJECTSHARE_R2_PRESIGN_TIMEOUT (default 10m, maximum 168h)
  • OBJECTSHARE_R2_UPLOAD_PRESIGN_TIMEOUT (default 1h, maximum 168h)

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.

Amazon S3

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.

Backblaze B2

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.

Alibaba Cloud OSS

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.

Tencent Cloud COS

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.

Production checklist

  • 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=require or 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/live for process health and /health/ready for database readiness.
  • Test upgrades and restores in a staging environment before production rollout.

Container publishing

.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 example acme/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.

Development

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.

Credit transaction integration tests require PostgreSQL and 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 TestPostgresCredit -v ./db (with -mod=mod if your ignored vendor directory is stale). These tests create a unique credit_test_* schema, exercise migrations, concurrent payment replay, concurrent spending, form resubmission, and transaction rollback, then remove only that schema. The ordinary test suite also covers gateway requests, payment validation, authorization, CSRF, configuration, and actual HTML template rendering without contacting payment providers.

License

GPL-3.0-only

Releases

Sponsor this project

Packages

Used by

Contributors

Languages