- Go backend lives in
backend/. - Redis stores one-time secret metadata and counters.
FILE_STORAGE_DIRstores encrypted uploaded files on disk as*.encblobs.- File shares use Redis for one-time metadata plus disk storage for the encrypted blob itself.
- Astro frontend lives in
frontend/and builds static HTML intofrontend/build. - Server-rendered HTML flows in
templates/are deprecated.
The server never sees plaintext or the decryption key. All crypto is client-side; the canonical implementation is frontend/src/lib/protocol.mjs — a shared, dependency-free module that runs in the browser and Node, reused by the web app, the CLI, and the Zapier app.
- Create: the client generates a 20-char
randomKey(getRandomString). With an optional passphrase,fullSecretKey = passphrase + randomKey. HKDF-SHA256 (saltonetimelink:v2) derives (a) an AES-256-GCM key (info=encrypt) and (b)hashedKey(info=auth, hex) — the only key material ever sent to the server. - The client AES-256-GCM-encrypts the secret/file (12-byte IV prepended) and POSTs
{ciphertext, hashedKey, duration}. The server stores{ciphertext, hashedKey}under a server-generated id — never the key or plaintext. - Link:
/v/#<randomKey><id>(text) or/f/#<randomKey><id>(file).randomKeylives only in the URL fragment and is never sent to the server. - Read (one-time by default): the recipient's browser re-derives
hashedKeyfrom the fragment and POSTs{id, hashedKey}; the server constant-time-verifies it and reserves one allowed read/download. The browser decrypts locally. Text ciphertext remains in Redis while views remain. File metadata remains in Redis and the encrypted disk blob remains on disk while downloads remain. - View/download counters:
saveSecretaccepts an optionalviewsfield —1(default, burn after reading) or2..10. File uploads accept multipartviewswith the same1..10range and a one-download default. Both paths use RedisWATCHtransactions with bounded exponential-backoff retries and preserve TTL viaPTTL. Text reads returnviewsLeftandexpiresInin JSON. File downloads return them inX-1Time-Views-LeftandX-1Time-Expires-Inheaders alongside the binary body. A file reservation opens the encrypted blob before committing its counter mutation, so the final concurrent downloader cannot unlink the path before earlier authorized downloaders hold descriptors. The final reservation deletes Redis metadata before streaming and removes the disk blob after the stream attempt. Legacy records without counters remain single-use. - Retrying a read is dangerous —
/api/getis destructive. On persistentWATCHcontention the server answers HTTP503with body{"status":"retry"}, which is the only proof a read was rejected before consuming a view. A client may replay the request solely on that explicit body (isRetryableReadinfrontend/src/islands/view-secret.ts); a bare503from a proxy/CDN gives no such guarantee and replaying it can burn a second view.postJsonattaches bothstatusand the parsedbodyto the thrown error so callers can tell the two apart. - Status ("My Secrets"):
POST /api/secretStatusreports, for a batch of ids, whether each secret still exists — without consuming it. "Gone" means read or expired.
- Entry point:
backend/main.go - HTTP handlers:
backend/handlers.go - Redis access:
backend/storage.go - File upload/download API endpoints live in
backend/handlers.goas/api/saveFileand/api/getFile. /api/secretStatus(apiSecretStatusinbackend/handlers.go) is a non-consuming batch existence check used by the Outbox / "My Secrets" page — it reads whether ids still exist and never deletes.- Backend file size limit is
80 MBviamaxFileSizeinbackend/handlers.go. - Every JSON endpoint caps its request body with
http.MaxBytesReader:maxSaveSecretBodyBytes(25 MB — base64url adds ~4/3 over AES-GCM, so roughly 18 MB of plaintext),maxStatusBodyBytes(8 KB), andmaxLookupBodyBytes(1 KB for/api/get,/api/getFile,/api/stat).maxSaveSecretBodyBytesis deliberately decoupled frommaxFileSize: text secrets gain nothing from a larger cap, so it stays at 25 MB while the file limit is 80 MB. KeepmaxSaveSecretBodyBytesunder nginx'sclient_max_body_size(81m). There is no client-side length guard, so oversized text fails with a generic error. /api/getand/api/getFilevalidate the id and hashed-key shapes (isValidStorageID,isValidHashedKey) before any Redis key is built. Malformed input is answered with the same statuses the storage layer would return (no message/wrong key), deliberately introducing no new response shape — see the exporter coupling under "Analytics & Ops Scripts".- Stored text/file counters and their respective view/download distributions are each written together — see "View-counter stats" below.
- Uploaded encrypted files are written to
FILE_STORAGE_DIR/<id>.encand the Redis record stores the path plus hashed key. backend/storage.goruns a file janitor every 2 hours and deletes expired*.encfiles based on file mtime.- Stored file counters use Redis keys
stats:stored:file:totalandstats:stored:file:day:YYYYMMDD. - View-counter stats: text uses
stats:views:total:<views>andstats:views:day:YYYYMMDD:<views>; files usestats:views:file:total:<downloads>andstats:views:file:day:YYYYMMDD:<downloads>. Lifetime keys do not expire; daily keys usestatsHistoryTTL(60 days). Bucket1is recorded for both. Each distribution is written in the sameTxPipelinedas its stored-item counters so the denominator stays exactly aligned with the buckets. - Stats are recorded by
saveToStorageafter a confirmedSetNX, and are best-effort: a counter failure is logged and must never fail a stored secret or inflate totals with saves that never landed. - The backend listens on
127.0.0.1:8080. - Required env:
FILE_STORAGE_DIR=/absolute/path/to/encrypted-filesREDISHOST=127.0.0.1:6379REDISPASS=
- Optional env:
LISTEN_ADDR=127.0.0.1:8080
Run locally:
go run ./backendBuild/test:
go build ./backend
GOCACHE=/tmp/go-cache go test ./backend/...
make buildmake buildbuilds the frontend production bundle intofrontend/buildand the backend binary intobin/1time-api.
- First-party CLI lives in
cli/and publishes as@1time/cli. - Runtime: Node.js 20+.
- Entry point:
cli/index.mjs - Command implementation:
cli/lib.mjs - Shared encryption protocol:
cli/protocol.mjs - The CLI syncs shared protocol code via
cli/scripts/sync-protocol.mjsbeforenpm test,npm pack, andnpm publish. - Supported commands:
1time send,1time read,1time send-file,1time read-file,--host,-h/--help 1time sendinput precedence is: pipedstdin,1TIME_SECRET, then positional secret argument.- Prefer
stdinforsend; positional secrets leak through shell history and process listings. readandread-filecurrently accept the full secret link as a positional argument only, which also exposes decryption material in shell history and process listings.send-fileandread-filesupport optional passphrases via--passphraseor1TIME_PASSPHRASE.sendandsend-fileaccept--views <N>(default1, max10, matching the backend caps);readandread-fileprint the remaining views/downloads onstderrsostdoutstays pipeable.- File links use the
/f/#<randomKey><id>format. read-file --out <path>refuses to start if the target path already exists.read-filewithout--outwrites into the current directory using the original filename and auto-suffixes collisions likereport (1).txt.- Default host is
1time.io; bare hosts normalize tohttps://...; plainhttp://is only allowed for loopback addresses such as127.0.0.1.
Run locally:
node cli/index.mjs --help
printf 'hello' | node cli/index.mjs send
node cli/index.mjs read 'https://1time.io/v/#...'Build/test:
cd cli
npm test
npm pack --dry-run- Lives in
zap/— a Zapier Platform CLI app (CommonJS). Public action: Create One-Time Link (zap/creates/create_one_time_link.js). - Reuses the shared crypto:
zap/scripts/sync-protocol.mjsgenerateszap/lib/protocol.js(CJS) fromfrontend/src/lib/protocol.mjs. Runnpm run syncbeforezapier push— Zapier does not run npm scripts, andlib/protocol.jsis gitignored (a build artifact).pretestauto-syncs. - Tests use Node's built-in runner (
npm test→node --test); no jest. They round-trip encrypt→link→decrypt to prove byte-compatibility with/v/. - Not end-to-end zero-knowledge: encryption runs on Zapier's servers, so the plaintext passes through Zapier. This is disclosed in the action description. The website and CLI remain the zero-knowledge paths.
- Lives in
extension/— a Manifest V3 extension published on the Chrome Web Store. The canonical listing URL lives infrontend/src/lib/siteConfig.js; use that shared value for website CTAs instead of duplicating the URL. - Share flow: select text → keyboard shortcut (default
Alt+Shift+S), the selection context-menu item, or the share button in the toolbar popup →background.jsreads the selection viachrome.scripting, encrypts it with the shared protocol, POSTs/api/saveSecret, copies the one-time link via an offscreen document, then injects a generic status toast into the page. - Never put the link in the page DOM: the clipboard write happens in
offscreen.html/offscreen.js(an extension-owned context), and the injected toast only ever shows generic text. Isolated-world scripts share the page's DOM, so the secret fragment must never be rendered or inserted there. - The created link is also saved to
chrome.storage.session(in-memory, not disk, not synced, not readable by pages) and shown in the popup's "Last link" card (copy / clear) — the popup is extension-owned, so this is safe. Preferences usechrome.storage.local(neversync, since a self-hosted host can be sensitive). - Zero-knowledge like the web app: encryption happens in the extension's service worker; only ciphertext and
hashedKeyleave the browser. - Toolbar popup (
popup.html/popup.js, styled with the site's design tokens) doubles as the options page: share button, target server, shortcut display. Custom origins are granted viaoptional_host_permissionsat save time.https://1time.iois pre-granted. HTTP only for loopback. - Shared encryption protocol:
extension/protocol.mjsis synced fromfrontend/src/lib/protocol.mjsviaextension/scripts/sync-protocol.mjs— same rule as CLI/Zapier: never edit the copy. - Smoke test (needs a running backend):
node extension/scripts/smoke-test.mjs http://127.0.0.1:8080round-trips encrypt → save → get → decrypt. - The shortcut is remappable at
chrome://extensions/shortcuts; the options page links there.
scripts/holds operational analytics run against nginx logs / Redis — not part of the served app:retention.py— sender cohort retention + conversion funnel from nginx logs.export_redis_stats_to_gsheets.py— exports Redis counters + nginx sender/receiver stats to a Google Sheet. The combinedviews_totaltab shows text-secret and file counts/share percentages side by side by bucket;views_dailyandfile_views_dailyremain separate. Buckets are sorted numerically, so10follows5rather than1.scripts/analytics/— gitignored on purpose. Never force-add anything from this directory.
- Owner/self traffic is identified by hits to
/ss(the private stats page); analytics exclude it.
- Toolchain: Astro static build
- Runtime: Astro islands with small vanilla browser modules
- Config:
frontend/astro.config.mjs(outDir: './build') - Root layout:
frontend/src/layouts/BaseLayout.astro - Pages:
frontend/src/pages/**/index.astroplus generatedrobots.txt.tsandsitemap.xml.ts - Components:
frontend/src/components/*.astro - Browser islands:
frontend/src/islands/*.ts - Crypto:
frontend/src/lib/protocol.mjsis the canonical shared client-side encryption (AES-256-GCM + HKDF-SHA256); it is the single source synced into the CLI and Zapier app.frontend/src/lib/util.jswraps it for the web (createSecretLink, API calls) andfrontend/src/lib/fileProtocol.jshandles file packing. Do not fork the crypto — editprotocol.mjsand re-sync. - Styles:
frontend/src/styles/*.css, inlined per route where needed
Run locally:
cd frontend
npm install
npm run devUseful commands:
cd frontend
npm run check
npm run build- The dev server runs on
127.0.0.1:3001. - In normal local development, the Astro dev proxy forwards relative
/api/requests tohttp://127.0.0.1:8080. - To use a different backend target in development, set
API_PROXY_TARGETbeforenpm run dev. - Do not edit
frontend/builddirectly; it is generated output. - SEO metadata is rendered in Astro page/layout frontmatter and must be present in static HTML.
- Keep static content pages close to zero-JS; add browser islands only when interaction is required.
- The active create/share flow renders the generated secret link inline on the current page.
- The
/v/route reads the secret key from the URL hash (#key), which is client-side only. - File sharing UI lives on
/secure-file-sharing/and usesfrontend/src/islands/secure-file-share.ts. - File download UI lives on
/f/and usesfrontend/src/islands/view-file.ts. - The secure file sharing island encrypts the file in the browser, uploads with
XMLHttpRequest, shows upload progress, and allows1 / 2 / 3 / 5 / 10downloads (one by default). - The file download island reads the link key from the URL hash first; generated file links are hash-based. Successful binary responses expose remaining downloads and TTL in response headers. Missing headers mean a legacy one-download backend.
- Frontend file size limit is
Constants.maxFileSizeBytes = 80 * 1024 * 1024infrontend/src/lib/util.js; keep it aligned with the backend'smaxFileSize. Both describe the plaintext file; the wire limit ismaxFileUploadBodyBytes(81 MB), which allows for the AES-GCM IV/tag and multipart overhead and matches nginx's81m. - File metadata (
name,type,size) is packed into the encrypted payload before upload; the web app server does not store that metadata separately. - Pages with
robots: 'noindex, nofollow'in metadata:/v/,/f/. - Outbox / "My Secrets": the
/my-secrets/page +frontend/src/islands/mySecrets.tskeep alocalStoragelist of the secrets this browser created and batch-check their read status viaPOST /api/secretStatus(non-consuming). Linked from the success screen (components/LinkReadyTemplate.astro) and the footer. localStorage is per-browser — no cross-device, no account. - Frontend validation is
npm run check; there is no React/Vitest suite after the Astro migration.
- Treat every
<link rel="stylesheet">infrontend/build/**/index.htmlas render-blocking unless proven otherwise. - Keep
frontend/src/styles/globals.csslimited to truly shared base styles only: app chrome, typography tokens, buttons, form primitives, and other classes needed on first paint across most routes. - Do not import route-specific CSS into
BaseLayout.astro. - If a stylesheet is specific to one route family and required for first paint, inline it from that route/page via
frontend/src/components/InlineCss.astroinstead of promoting it to the root layout. - If UI only appears after user interaction (success states, generated-link panels, drawers, modals, secondary tools), lazy-load the component so its JS and CSS stay out of the initial render path.
- Do not statically import post-interaction components from large entry components when the initial screen can render without them.
- Before merging frontend UI changes, run
cd frontend && npm run buildand inspect the generated HTML for the affected route to confirm it is not pulling unrelated CSS chunks. - A route should not ship CSS for unrelated pages such as blog, stats, view, about, or post-submit states during initial render.
- Lighthouse performance is a hard constraint, not a nice-to-have. Treat regressions as bugs unless there is a clear product reason.
- Static content routes such as
/blog/**and other read-mostly pages should stay as close to zero-JS as practical. Do not add client components to content pages unless the interaction is essential. - Treat every
<script src>infrontend/build/**/index.htmlas suspect on content routes. Verify whether each chunk is required for user-visible behavior on that route. - Do not pull generator, share-flow, stats, view-secret, or other app-tooling bundles into blog or marketing pages.
- Avoid putting browser islands in
BaseLayout.astrowhen plain markup or a tiny standalone script would work. Shared islands in the root layout force JS across the whole site. - For static pages, prefer plain links and server-rendered navigation patterns when they materially reduce app-router hydration cost.
- If a page is primarily article or marketing content, optimize for first-load HTML and CSS first, and only then add JavaScript that is strictly necessary.
- After frontend performance changes, build with
cd frontend && npm run buildand inspect the affected HTML for both CSS and JS:- confirm there are no unrelated route chunks
- confirm route-specific assets stay route-specific
- confirm content pages are not loading interactive app bundles
- Astro fingerprints built assets under
/_astro/; deployment config should cache that path aggressively. - If you self-host fonts or other root-level static assets outside
/_astro/, make sure deployment config adds explicit cache headers for them.
- Public domain is
https://1time.io. - “One-time” is core product branding and high-value SEO language. Do not remove or replace it in titles, metadata, headings, CTAs, link-ready states, or other prominent copy merely because optional multi-view/download limits exist. Keep the one-time promise as the default and qualify multi-use behavior only where the configured count is actually known.
/f/cannot know a link's download count before making the destructive download request. Its pre-download gate and metadata must therefore retain one-time wording; after download,view-file.tsuses the response headers to state the exact remaining-download status.- Treat established SEO wording as product behavior. Do not rewrite or weaken it without explicit approval, even when nearby implementation details change.
- Frontend production build output:
frontend/build(static HTML files per route) - Backend production binary from
make build:bin/1time-api - Example nginx config:
configs/nginx/1time.conf - nginx serves frontend statics and proxies
/apito the Go app on127.0.0.1:8080. - nginx upload ceiling is
81min bothconfigs/nginx/1time.confanddocker/nginx/default.conf.templateto stay above the backend's80 MBmultipart limit. Upload/download timeouts on/api/saveFileand/api/getFileare10m, sized so an 80 MB transfer survives a slow mobile uplink. - Host nginx has an exact
/f/location with the same sensitive-header treatment as/v/. - The nginx
try_filesdirective includes$uri/index.htmlfor static trailing-slash routes.
- The Astro frontend uses the JSON API routes under
/api. - Each route generates its own
index.htmlwith full pre-rendered content and unique meta tags for SEO. - The deprecated server-rendered
/view/...flow is separate from the SPA/v/flow. - File links are one-time and currently use the SPA
/f/flow. - File downloads are consumed when an authorized fetch reserves one allowed download. The Redis record is decremented while downloads remain and deleted before the final transfer; the disk blob is removed after the final stream attempt.