From df2aa5e3295ca51dc9c48a82b14d374d330e05e7 Mon Sep 17 00:00:00 2001 From: jinkunsun Date: Sun, 20 Sep 2026 11:12:52 +0800 Subject: [PATCH] feat(hosted): configure file retention per account --- docs/hosted.md | 24 +++++++++- migrations/0005_account_retention.sql | 4 ++ scripts/test-hosted-browser.mjs | 27 ++++++++++- src/hosted/files.ts | 36 +++++++++------ src/hosted/ui.ts | 8 ++-- test/hosted-files.test.ts | 66 +++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 migrations/0005_account_retention.sql diff --git a/docs/hosted.md b/docs/hosted.md index df155ff..47e64c0 100644 --- a/docs/hosted.md +++ b/docs/hosted.md @@ -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. @@ -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 @@ -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. diff --git a/migrations/0005_account_retention.sql b/migrations/0005_account_retention.sql new file mode 100644 index 0000000..e81139d --- /dev/null +++ b/migrations/0005_account_retention.sql @@ -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')); diff --git a/scripts/test-hosted-browser.mjs b/scripts/test-hosted-browser.mjs index 6757758..bb010c5 100644 --- a/scripts/test-hosted-browser.mjs +++ b/scripts/test-hosted-browser.mjs @@ -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(); @@ -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); @@ -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(); @@ -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(); @@ -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); @@ -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(); diff --git a/src/hosted/files.ts b/src/hosted/files.ts index 15835d4..172d8cd 100644 --- a/src/hosted/files.ts +++ b/src/hosted/files.ts @@ -2,11 +2,11 @@ import type { HostedEnv, Account } from './types'; import { LIMITS, consumeRate } from './limits'; import { HttpError, json, error, readBody } from './http'; -type FileRow = { id: string; user_id: string; name: string; mime: string; size: number; full_size: number; thumb_size: number; state: string; created_at: number; expires_at: number; day: string }; +type FileRow = { id: string; user_id: string; name: string; mime: string; size: number; full_size: number; thumb_size: number; state: string; created_at: number; expires_at: number; storage_prefix: 'users' | 'retained'; day: string }; const MAX_BODY = LIMITS.maxImageBytes + LIMITS.maxThumbBytes + 64 * 1024; const DAY = 86400000; const day = (now = Date.now()) => new Date(now).toISOString().slice(0, 10); -const key = (f: Pick, thumb = false) => `users/${f.user_id}/${f.id}/${thumb ? 'thumb' : 'full'}`; +const key = (f: Pick, thumb = false) => `${f.storage_prefix}/${f.user_id}/${f.id}/${thumb ? 'thumb' : 'full'}`; export async function hashToken(token: string): Promise { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)); return Array.from(new Uint8Array(hash), n => n.toString(16).padStart(2, '0')).join(''); @@ -16,10 +16,15 @@ function quotaError(e: unknown): never { if (String(e).includes('quota:')) throw new HttpError(429, '已达到账号或服务额度,请稍后重试;可删除旧文件释放存储空间'); throw e; } +async function retentionDays(env: HostedEnv, user: Account): Promise { + const row = await env.DB.prepare('SELECT retention_days FROM users WHERE id=?').bind(user.id).first<{ retention_days: number }>(); + if (!row) throw new HttpError(401, '账号不存在'); + return row.retention_days; +} export async function getUsage(env: HostedEnv, user: Account) { const store = await env.DB.prepare('SELECT bytes,items FROM storage_usage WHERE scope=?').bind(user.id).first<{ bytes: number; items: number }>(); const daily = await env.DB.prepare('SELECT uploads,bytes,downloads,download_bytes FROM daily_usage WHERE scope=? AND day=?').bind(user.id, day()).first<{ uploads: number; bytes: number; downloads: number; download_bytes: number }>(); - return { usage: { storedBytes: store?.bytes ?? 0, storedItems: store?.items ?? 0, dailyUploads: daily?.uploads ?? 0, dailyBytes: daily?.bytes ?? 0, dailyDownloads: daily?.downloads ?? 0, dailyDownloadBytes: daily?.download_bytes ?? 0 }, limits: LIMITS }; + return { usage: { storedBytes: store?.bytes ?? 0, storedItems: store?.items ?? 0, dailyUploads: daily?.uploads ?? 0, dailyBytes: daily?.bytes ?? 0, dailyDownloads: daily?.downloads ?? 0, dailyDownloadBytes: daily?.download_bytes ?? 0 }, limits: { ...LIMITS, retentionDays: await retentionDays(env, user) } }; } export async function removeFile(env: HostedEnv, file: FileRow): Promise { // Keep the reservation until both deletes succeed. A retry is idempotent. @@ -37,10 +42,13 @@ async function upload(request: Request, env: HostedEnv, user: Account): Promise< const reserved = declared === null ? MAX_BODY : Number(declared); const id = crypto.randomUUID(); const now = Date.now(); - const pending: FileRow = { id, user_id: user.id, name: '', mime: '', size: reserved, full_size: 0, thumb_size: 0, state: 'pending', created_at: now, expires_at: now + 5 * 60_000, day: day(now) }; + const retention = await retentionDays(env, user); + const expiresAt = retention === 0 ? 0 : now + retention * DAY; + const storagePrefix = retention === 0 || retention > 7 ? 'retained' : 'users'; + const pending: FileRow = { id, user_id: user.id, name: '', mime: '', size: reserved, full_size: 0, thumb_size: 0, state: 'pending', storage_prefix: storagePrefix, created_at: now, expires_at: now + 5 * 60_000, day: day(now) }; try { - await env.DB.prepare("INSERT INTO files(id,user_id,size,state,created_at,expires_at,day) VALUES(?,?,?,'pending',?,?,?)") - .bind(id, user.id, reserved, now, pending.expires_at, pending.day).run(); + await env.DB.prepare("INSERT INTO files(id,user_id,size,state,created_at,expires_at,day,storage_prefix) VALUES(?,?,?,'pending',?,?,?,?)") + .bind(id, user.id, reserved, now, pending.expires_at, pending.day, storagePrefix).run(); } catch (e) { quotaError(e); } try { const bytes = await readBody(request, reserved); @@ -61,9 +69,9 @@ async function upload(request: Request, env: HostedEnv, user: Account): Promise< await env.BUCKET.put(key(pending), full.stream(), { httpMetadata: { contentType: mime } }); if (thumb) await env.BUCKET.put(key(pending, true), thumb.stream(), { httpMetadata: { contentType: 'image/jpeg' } }); const committed = await env.DB.prepare(`UPDATE files SET state='ready',size=?,full_size=?,thumb_size=?,mime=?,name=?,expires_at=? WHERE id=? AND state='pending' AND expires_at>? RETURNING id`) - .bind(size, full.size, thumb?.size ?? 0, mime, full.name.slice(0, 200), now + LIMITS.retentionDays * DAY, id, Date.now()).first(); + .bind(size, full.size, thumb?.size ?? 0, mime, full.name.slice(0, 200), expiresAt, id, Date.now()).first(); if (!committed) throw new HttpError(409, '上传已过期,请重试'); - return json({ id, expiresAt: now + LIMITS.retentionDays * DAY }); + return json({ id, expiresAt: expiresAt || null }); } catch (e) { await removeFile(env, pending); if (e instanceof HttpError) throw e; @@ -95,7 +103,7 @@ export async function handleShared(request: Request, env: HostedEnv): Promise? AND hits<50 RETURNING file_id,user_id').bind(hash, Date.now()).first<{ file_id: string; user_id: string }>(); if (!share) return error(410, '分享已过期、撤销或达到访问上限'); - const file = await env.DB.prepare("SELECT * FROM files WHERE id=? AND user_id=? AND state='ready' AND expires_at>?").bind(share.file_id, share.user_id, Date.now()).first(); + const file = await env.DB.prepare("SELECT * FROM files WHERE id=? AND user_id=? AND state='ready' AND (expires_at=0 OR expires_at>?)").bind(share.file_id, share.user_id, Date.now()).first(); if (!file) return error(410, '文件已过期'); if (!(await consumeRate(env.DB, 'read:' + file.user_id, 120, 60))) return error(429, '访问过于频繁'); return download(request, env, file); @@ -105,19 +113,19 @@ export async function handleFiles(request: Request, env: HostedEnv, user: Accoun if (path === '/api/upload' && method === 'POST') return upload(request, env, user); if (path === '/api/usage' && method === 'GET') return json(await getUsage(env, user)); if (path === '/api/list' && method === 'GET') { - const rows = await env.DB.prepare("SELECT * FROM files WHERE user_id=? AND state='ready' AND expires_at>? ORDER BY created_at DESC LIMIT 100").bind(user.id, Date.now()).all(); - return json({ items: rows.results.map(f => ({ id: f.id, type: f.mime === 'text/plain' ? 'text' : 'image', size: f.size, createdAt: f.created_at, expiresAt: f.expires_at, name: f.name })), ...await getUsage(env, user) }); + const rows = await env.DB.prepare("SELECT * FROM files WHERE user_id=? AND state='ready' AND (expires_at=0 OR expires_at>?) ORDER BY created_at DESC LIMIT 100").bind(user.id, Date.now()).all(); + return json({ items: rows.results.map(f => ({ id: f.id, type: f.mime === 'text/plain' ? 'text' : 'image', size: f.size, createdAt: f.created_at, expiresAt: f.expires_at || null, name: f.name })), ...await getUsage(env, user) }); } const match = path.match(/^\/(i|api\/img|api\/share)\/([a-f0-9-]{36})$/); if (!match) return error(404, '不存在'); - const file = await env.DB.prepare("SELECT * FROM files WHERE id=? AND user_id=? AND state='ready' AND expires_at>?").bind(match[2], user.id, Date.now()).first(); + const file = await env.DB.prepare("SELECT * FROM files WHERE id=? AND user_id=? AND state='ready' AND (expires_at=0 OR expires_at>?)").bind(match[2], user.id, Date.now()).first(); if (!file) return error(404, '文件不存在或已过期'); if (match[1] === 'i' && method === 'GET') return download(request, env, file); if (match[1] === 'api/img' && method === 'DELETE') { await removeFile(env, file); return json({ deleted: true }); } if (match[1] === 'api/share') { if (method === 'DELETE') { await env.DB.prepare('DELETE FROM shares WHERE file_id=? AND user_id=?').bind(file.id, user.id).run(); return json({ revoked: true }); } if (method === 'POST') { - const token = randomToken(), hash = await hashToken(token), expiresAt = Math.min(Date.now() + DAY, file.expires_at); + const token = randomToken(), hash = await hashToken(token), expiresAt = Math.min(Date.now() + DAY, file.expires_at || Infinity); // One active link per file: replacing it revokes its predecessor. await env.DB.batch([env.DB.prepare('DELETE FROM shares WHERE file_id=?').bind(file.id), env.DB.prepare('INSERT INTO shares(hash,file_id,user_id,expires_at) VALUES(?,?,?,?)').bind(hash, file.id, user.id, expiresAt)]); return json({ url: env.PUBLIC_ORIGIN + '/s/' + token, expiresAt }); @@ -126,7 +134,7 @@ export async function handleFiles(request: Request, env: HostedEnv, user: Accoun return error(405, '不支持此方法'); } export async function cleanupFiles(env: HostedEnv): Promise { - const rows = await env.DB.prepare("SELECT * FROM files WHERE expires_at(); + const rows = await env.DB.prepare("SELECT * FROM files WHERE (state='ready' AND expires_at>0 AND expires_at<=?) OR (state='pending' AND expires_at<=?) OR state='deleting' ORDER BY expires_at LIMIT 100").bind(Date.now(), Date.now()).all(); for (const file of rows.results) await removeFile(env, file); await env.DB.batch([ env.DB.prepare('DELETE FROM shares WHERE expires_atShotSync · 随手传,随处取
ShotSync
-

随手传,随处取。

这里是公共账号服务。想把内容放在自己的 Cloudflare?自己部署,无需注册账号

让截图和文字,在手机与电脑之间轻松流动。免费试用限 100 个账号,内容保留 7 天。

欢迎回来

邮箱仅作为登录名,不验证邮箱,也不会发送找回密码邮件。请保存注册后显示的恢复码。

至少 10 个字符;最多 72 个英文字符或 24 个汉字
- +

随手传,随处取。

这里是公共账号服务。想把内容放在自己的 Cloudflare?自己部署,无需注册账号

让截图和文字,在手机与电脑之间轻松流动。免费试用限 100 个账号,新注册账号的内容默认保留 7 天。

欢迎回来

邮箱仅作为登录名,不验证邮箱,也不会发送找回密码邮件。请保存注册后显示的恢复码。

至少 10 个字符;最多 72 个英文字符或 24 个汉字
+