Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions docs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ File access, deletion and sharing resolve ownership from authenticated account I
| API/share ingress | 120/min/IP, 600/min globally; D1-backed fixed windows |
| Provider login/refresh requests | 120/min globally; concurrent sign-in 1 with expiring D1 lease; mutations use persistent state |
| Registration/recovery | Turnstile and per-address/IP/global request limits; no outbound mail |
| Retention | 7 days; access denied immediately at expiry, cron deletes objects subsequently |
| Retention | 7 days by default; operators may set an individual account to 1–3,650 days or 0 (no automatic expiry); other quotas remain unchanged |
| Share links | One active link/file, up to 24 hours or file expiry, 50 accesses; owner can revoke |

Limits are launch defaults, not a capacity benchmark. Fixed windows may permit boundary bursts. Application limits do not cap the cost of requests reaching Cloudflare: rejected traffic still executes a Worker and some D1 queries. Billing notifications are not a hard spending cap. Configure edge protections and inspect account-level usage before raising limits or registration capacity. Resources within the same Cloudflare account may still share platform quotas.
Expand All @@ -44,7 +44,7 @@ Before reading a body, reserve the entire declared multipart Content-Length, or

On successful R2 writes, shrink the reservation to actual full+thumbnail bytes. Deleting successful files releases storage but **does not refund today's upload allowance**. Failed uploads consume an attempt but release reserved bytes after object deletion succeeds. This prevents endless upload/delete cycles from bypassing daily limits.

Pending uploads expire after five minutes; the one-minute cron reclaims them, expired files and failed deletions in batches of 100. A failed R2 delete retains the quota reservation for retry. A late writer whose lease was reclaimed cannot commit and attempts to remove its objects. Configure an eight-day R2 lifecycle as a backstop for physical orphans; app expiry remains seven days. Cleanup batches can take multiple ticks, so no exact physical deletion time is promised. Lifecycle deletion alone must not be used as the quota ledger.
Pending uploads expire after five minutes; the one-minute cron reclaims them, expired files and failed deletions in batches of 100. A failed R2 delete retains the quota reservation for retry. A late writer whose lease was reclaimed cannot commit and attempts to remove its objects. Configure an eight-day R2 lifecycle on `users/` as a backstop for ordinary uploads. Extended-retention uploads use `retained/` and must not match that rule or any broader object-deletion rule; their D1 expiry is enforced by the cleanup cron. Files without automatic expiry stay until the owner deletes them. Pending/deleting uploads still get cleaned up. App expiry remains seven days for accounts without an override. Cleanup batches can take multiple ticks, so no exact physical deletion time is promised. Lifecycle deletion alone must not be used as the quota ledger.

## Deploy prerequisites

Expand Down Expand Up @@ -87,3 +87,23 @@ Launch verification (2026-09-20): PR #4 deployed as `b20bde51-2865-4464-a784-1a7
Native-auth rollout (2026-09-20): PR #6 merged as `549d334`; migration 0004 applied and Worker `b1507860-be09-414c-805a-2b98216b2370` deployed. Supabase settings and both keys are verified; public signup and email confirmation remain disabled, with website registration handled by ShotSync. Production had zero accounts before migration. Three real production login/refresh/logout rounds passed, including immediate denial of old JWTs and refresh tokens. Exact disposable D1/provider fixtures were removed. Real provider same-password recovery invalidated the prior refresh token in an isolated Worker. Browser tests cover registration, recovery, files, devices and refresh failures; production registration with a human Turnstile challenge has not been completed by automation.

Measured production CPU for this version: login **27, 9, 10 ms**, successful refresh **8, 8, 6 ms**, successful list requests **5–9 ms**, and logout **4–5 ms**. All probes completed normally. The first observed login still exceeded the documented 10 ms Free budget, so these small samples do **not** establish reliable capacity or guarantee every request fits. No paid upgrade was made. The one-minute cleanup schedule remains installed. Store operator credentials in a mode-0600 local env file outside the repo and back it up encrypted in a private vault.

## Per-account file retention

Accounts themselves do not expire after seven days; the default applies to uploaded files. Migration `0005_account_retention.sql` adds `users.retention_days` (default 7, 0 for no automatic expiry, otherwise 1–3,650 days) and a persisted storage prefix on files. Configure the immutable D1 user UUID, not an email supplied by a browser. There is no public API for changing the limit. Browser and device uploads use the same server-side setting.

Inspect the account and bucket lifecycle first, then update only the intended account:

```sh
npx wrangler d1 execute shotsync-hosted --remote --config wrangler.hosted.jsonc --command "SELECT id,retention_days FROM users WHERE id='USER_UUID';"
npx wrangler r2 bucket lifecycle list shotsync-hosted
npx wrangler d1 execute shotsync-hosted --remote --config wrangler.hosted.jsonc --command "UPDATE users SET retention_days=90 WHERE id='USER_UUID';"
```

Use `0` instead of `90` to disable automatic expiry for new uploads. This does not disable the 100-file/200-MiB storage cap, upload/download quotas, or 24-hour/50-access share-link limit. “No automatic expiry” is not a backup or service-availability guarantee.

The setting applies to **new uploads**. Existing files keep their stored expiry and R2 location. Do not simply extend existing D1 timestamps while leaving objects under `users/`: the eight-day R2 lifecycle would still delete them. Existing-file migration needs a verified copy into `retained/` before changing its D1 location/expiry; never delete the source before verifying the copy. This change does not move or delete existing objects.

In `/api/list` and upload responses, `expiresAt: null` means no automatic expiry; `limits.retentionDays: 0` has the same meaning. Internally, only a ready file with `expires_at=0` is permanent; pending reservations always have a short timeout. Cleanup still removes pending/deleting records and expiring share links.

Rollback constraint: after migration 0005, keep code that understands `storage_prefix` and permanent `expires_at=0`. Older Workers assume every object is under `users/`, and their cleanup treats zero as expired; rolling back to them can delete permanent files. Suspend uploads and repair forward instead of deploying an old cleanup implementation.
4 changes: 4 additions & 0 deletions migrations/0005_account_retention.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE users ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 7
CHECK(typeof(retention_days) = 'integer' AND retention_days BETWEEN 0 AND 3650);
ALTER TABLE files ADD COLUMN storage_prefix TEXT NOT NULL DEFAULT 'users'
CHECK(storage_prefix IN ('users', 'retained'));
27 changes: 25 additions & 2 deletions scripts/test-hosted-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ try {
for (const migration of await readD1Migrations('migrations')) await db.batch(migration.queries.map(query => db.prepare(query)));
await db.prepare("INSERT INTO users(id,email,password_hash,verified_at,created_at,auth_provider_id,auth_state) VALUES(?,?,'external:supabase',NULL,1,?,'active')")
.bind(fixtureId, 'browser@example.com', fixtureId).run();
await db.prepare('UPDATE users SET retention_days=90 WHERE id=?').bind(fixtureId).run();
browser=await chromium.launch({headless:true});
const context=await browser.newContext({ignoreHTTPSErrors:true,viewport:{width:390,height:844}});
const page=await context.newPage();
Expand All @@ -105,6 +106,7 @@ try {
await page.goto(origin);
await page.locator('#email').fill('browser@example.com');await page.locator('#password').fill(password);await page.locator('#auth-submit').click();
await expect(page.locator('#app')).toBeVisible();
await expect(page.locator('#retention')).toContainText('内容保留 90 天');
const firstAccessToken=lastAccessToken;expect(firstAccessToken.split('.')).toHaveLength(3);
await page.reload();await expect(page.locator('#app')).toBeVisible();
expect(providerCalls.filter(call=>call==='POST /auth/v1/token').length).toBeGreaterThanOrEqual(2);
Expand All @@ -117,7 +119,11 @@ try {
await page.locator('#device-name').fill('测试设备');await page.locator('#device-form button').click();await expect(page.locator('#new-token')).toBeVisible();
const token=await page.locator('#token-value').textContent();
const deviceList=await context.request.get(origin+'/api/list',{headers:{Authorization:'Bearer '+token}});expect(deviceList.status()).toBe(200);
const item=(await deviceList.json()).items[0];
const list=await deviceList.json();expect(list.limits.retentionDays).toBe(90);
const item=list.items[0];
expect(item.expiresAt-Date.now()).toBeGreaterThan(89*86400000);
await expect(page.locator('.tilebody > .muted')).toContainText(new Date(item.expiresAt).toLocaleString('zh-CN'));
await expect(page.locator('.tilebody > .muted')).toContainText('到期');
const privateContext=await browser.newContext({ignoreHTTPSErrors:true});
expect((await privateContext.request.get(origin+'/i/'+item.id)).status()).toBe(401);
await page.getByRole('button',{name:'分享',exact:true}).click();
Expand All @@ -126,6 +132,22 @@ try {
await page.locator('#devices button').click();await expect(page.locator('#devices .device')).toHaveCount(0);expect((await privateContext.request.get(origin+'/api/list',{headers:{Authorization:'Bearer '+token}})).status()).toBe(401);
await page.screenshot({path:join(temp,'mobile.png'),fullPage:true});
page.on('dialog',dialog=>dialog.accept());await page.getByRole('button',{name:'删除',exact:true}).click();await expect(page.locator('.tile')).toHaveCount(0);
// Account policy and object expiration must agree, including unlimited storage time.
await db.prepare('UPDATE users SET retention_days=0 WHERE id=?').bind(fixtureId).run();
await page.locator('#refresh').click();await expect(page.locator('#retention')).toContainText('内容永久保存,不自动过期');
await page.locator('#text').fill('永久保留测试');await page.locator('#text-form button').click();
await expect(page.locator('.tile')).toHaveCount(1);
await expect(page.locator('.tilebody > .muted')).toContainText('永久保存,不自动过期');
const permanentList=await page.evaluate(()=>api('/api/list'));
expect(permanentList.limits.retentionDays).toBe(0);expect(permanentList.items[0].expiresAt).toBeNull();
const shareResponse=page.waitForResponse(response=>response.request().method()==='POST'&&new URL(response.url()).pathname.startsWith('/api/share/'));
await page.getByRole('button',{name:'分享',exact:true}).click();
const permanentShare=await (await shareResponse).json();
expect(permanentShare.expiresAt-Date.now()).toBeGreaterThan(23*3600000);
expect(permanentShare.expiresAt-Date.now()).toBeLessThanOrEqual(24*3600000);
await expect(page.getByText('任何持有链接的人都可访问', {exact:false})).toContainText('有效期最多 24 小时');
await page.getByRole('button',{name:'删除',exact:true}).click();await expect(page.locator('.tile')).toHaveCount(0);
await db.prepare('UPDATE users SET retention_days=90 WHERE id=?').bind(fixtureId).run();
refreshFailure=429;
await page.evaluate(()=>{expiresAt=Date.now()-1;});
await page.locator('#refresh').click();
Expand Down Expand Up @@ -153,6 +175,7 @@ try {
expect(await page.locator('#password').inputValue()).toBe('');
await page.locator('#recovery-saved').check();await page.locator('#finish-recovery').click();await expect(page.locator('#recovery-value')).toHaveText('');
await page.locator('#password').fill(password);await page.locator('#auth-submit').click();await expect(page.locator('#app')).toBeVisible();
await expect(page.locator('#retention')).toContainText('内容保留 7 天');
const preRecoveryJWT=lastAccessToken;
const recoveryDeviceResponse=await context.request.post(origin+'/api/account/devices',{headers:{Authorization:'Bearer '+preRecoveryJWT,Origin:origin},data:{name:'recovery fixture'}});
expect(recoveryDeviceResponse.status()).toBe(201);
Expand All @@ -177,7 +200,7 @@ try {
const registered=await db.prepare('SELECT password_hash,auth_state,verified_at FROM users WHERE email=?').bind('new@example.com').first();
expect(registered).toMatchObject({password_hash:'external:supabase',auth_state:'active',verified_at:null});
expect(await page.evaluate(()=>localStorage.length+sessionStorage.length)).toBe(0);expect(errors).toEqual([]);
console.log('PASS: real browser signed-JWT login, reload/rotating refresh, concurrent 401 singleflight, upload/private preview, device isolation, share/revoke, delete, logout revocation; registration/recovery rotates codes and invalidates old JWT/device, with fake provider/Turnstile only at outbound boundaries');
console.log('PASS: real browser signed-JWT login, reload/rotating refresh, concurrent 401 singleflight, 90-day/permanent retention with 24-hour shares, upload/private preview, device isolation, share/revoke, delete, logout revocation; registration/recovery rotates codes and invalidates old JWT/device, with fake provider/Turnstile only at outbound boundaries');
await privateContext.close();await context.close();
} finally {
if(browser)await browser.close();if(server)await server.dispose();
Expand Down
Loading
Loading