From b18e24505e3f70ee4bd4f5ec6c7824b87159135c Mon Sep 17 00:00:00 2001 From: jinkunsun Date: Sun, 20 Sep 2026 11:52:12 +0800 Subject: [PATCH] feat(hosted): make the homepage a compact sync gallery --- docs/hosted.md | 2 + scripts/test-hosted-browser.mjs | 97 +++++++++++++++++++++++++++------ src/hosted/ui.ts | 67 +++++++++++++++++------ test/hosted-ui.test.ts | 4 +- 4 files changed, 132 insertions(+), 38 deletions(-) diff --git a/docs/hosted.md b/docs/hosted.md index 47e64c0..897c6be 100644 --- a/docs/hosted.md +++ b/docs/hosted.md @@ -6,6 +6,8 @@ The hosted entry point (`src/hosted/index.ts`) adds email/password accounts with ## What people can do +After sign-in, the homepage is a compact gallery for moving content between devices. Use **+ 图片** to choose images (selection uploads immediately), drop or paste an image, or open **文字** to send a note. Tap a gallery item to view it, copy text or retrieve an image, share it, or delete it. Visible pages check for new content periodically; hidden pages pause automatic syncing. Account information, the actual retention period, quota details and device tokens are available under **设置** instead of occupying the main workspace. + Register with an email and password, complete Turnstile, and save the recovery code shown once before signing in. The email is an unverified username, not proof of mailbox ownership. Recovery requires the email, recovery code and a new password; a successful reset replaces the recovery code and invalidates all old sessions and device tokens. Save the replacement code. Losing both password and recovery code means there is no self-service recovery; no reset emails are sent. Supabase Auth owns passwords and browser sessions; there is no local PBKDF2/scrypt fallback. Login returns a short-lived Supabase access JWT held only in browser memory. The refresh token lives in a Secure/HttpOnly/SameSite cookie; `/api/account/refresh` rotates it and restores the session after reload. Protected requests send the access JWT as a bearer credential. The Worker verifies its signature using cached Supabase JWKS and checks the account state, authentication version and session revocation in D1. D1 retains application users, device tokens, recovery-code hashes, revocations and quotas; R2 retains files. Ordinary file requests need no provider call once signing keys are cached. diff --git a/scripts/test-hosted-browser.mjs b/scripts/test-hosted-browser.mjs index bb010c5..5209649 100644 --- a/scripts/test-hosted-browser.mjs +++ b/scripts/test-hosted-browser.mjs @@ -102,51 +102,103 @@ try { await page.route('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit', route => route.fulfill({ contentType: 'application/javascript', body: "window.turnstile={render:(selector,options)=>{window.fixtureCaptcha=options;options.callback('fixture-challenge');return 'fixture-widget';},reset:()=>window.fixtureCaptcha.callback('fixture-challenge')};", })); + const previewRequests=[];page.on('request',request=>{if(/^\/(?:i|t)\//.test(new URL(request.url()).pathname))previewRequests.push(new URL(request.url()).pathname);}); const errors=[];page.on('pageerror',e=>errors.push(e.message)); 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('#settings-dialog')).not.toBeVisible(); + await expect(page.locator('#composer-dialog')).not.toBeVisible(); + await expect(page.locator('#device-form')).not.toBeVisible(); + await page.locator('#open-settings').click(); await expect(page.locator('#retention')).toContainText('内容保留 90 天'); + await page.locator('#close-settings').click(); 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); const beforeConcurrent=providerCalls.filter(call=>call==='POST /auth/v1/token').length; await page.evaluate(async()=>{accessToken='invalid-fixture-access-token';await Promise.all([api('/api/list'),api('/api/account/devices')]);}); expect(providerCalls.filter(call=>call==='POST /auth/v1/token').length).toBe(beforeConcurrent+1); + // Composer stays out of the gallery until explicitly opened. + await page.locator('#add-text').click();await expect(page.locator('#composer-dialog')).toBeVisible(); + await page.locator('#close-composer').click();await expect(page.locator('#composer-dialog')).not.toBeVisible(); + await page.locator('#add-text').click(); await page.locator('#text').fill('跨设备取回测试');await page.locator('#text-form button').click(); - await expect(page.locator('.tile')).toHaveCount(1); - await page.getByRole('button',{name:'预览',exact:true}).click();await expect(page.locator('.textpreview')).toHaveText('跨设备取回测试'); + await expect(page.locator('#composer-dialog')).not.toBeVisible();await expect(page.locator('.tile')).toHaveCount(1); + await expect(page.locator('.tile')).toContainText('跨设备取回测试'); + await page.locator('.tile-open').click();await expect(page.locator('#viewer-content')).toContainText('跨设备取回测试'); + await context.grantPermissions(['clipboard-read','clipboard-write']); + await page.locator('#viewer-copy').click();expect(await page.evaluate(()=>navigator.clipboard.readText())).toBe('跨设备取回测试'); + const downloadEvent=page.waitForEvent('download');await page.locator('#viewer-download').click();expect((await downloadEvent).suggestedFilename()).toBe('text.txt'); + await page.locator('#close-viewer').click(); + await page.locator('#open-settings').click(); 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 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 item=list.items[0];expect(item.expiresAt-Date.now()).toBeGreaterThan(89*86400000); + await page.locator('#close-settings').click();await page.locator('.tile-open').click(); + await expect(page.locator('#viewer-meta')).toContainText(new Date(item.expiresAt).toLocaleString('zh-CN')); + await expect(page.locator('#viewer-meta')).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(); - const share=await page.getByLabel('分享链接').inputValue();expect(await (await privateContext.request.get(share)).text()).toBe('跨设备取回测试'); - await page.getByRole('button',{name:'撤销分享',exact:true}).click();await expect(page.getByText('分享已撤销。')).toBeVisible();expect((await privateContext.request.get(share)).status()).toBe(410); + await page.locator('#viewer-share').click(); + await expect(page.locator('#share-link')).toHaveValue(/^https:/);const share=await page.locator('#share-link').inputValue();expect(await (await privateContext.request.get(share)).text()).toBe('跨设备取回测试'); + await page.locator('#viewer-revoke').click();await expect(page.locator('#share-result')).not.toBeVisible();expect((await privateContext.request.get(share)).status()).toBe(410); + await page.locator('#close-viewer').click();await page.locator('#open-settings').click(); 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); + await page.locator('#close-settings').click(); + // A real file-input change auto-uploads and produces an automatic thumbnail. + const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9XkAAAAASUVORK5CYII=','base64'); + await page.locator('#file').setInputFiles({name:'同步截图.png',mimeType:'image/png',buffer:png}); + await expect(page.locator('.tile')).toHaveCount(2); + await expect.poll(()=>page.locator('.tile img').evaluateAll(images=>images.length>0&&images.every(image=>image.complete&&image.naturalWidth>0))).toBe(true); + const imageReads=previewRequests.length; + await page.locator('#refresh').click();await expect(page.locator('#refresh')).toBeEnabled();expect(previewRequests.length).toBe(imageReads); + // Pasting into a composer edits text; it must never upload a clipboard image. + await page.locator('#add-text').click(); + await page.locator('#text').evaluate((textarea,encoded)=>{const data=new DataTransfer();data.items.add(new File([Uint8Array.from(atob(encoded),c=>c.charCodeAt(0))],'should-not-upload.png',{type:'image/png'}));textarea.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));},png.toString('base64')); + await page.locator('#close-composer').click();await page.locator('#refresh').click();await expect(page.locator('#refresh')).toBeEnabled();await expect(page.locator('.tile')).toHaveCount(2); + // Browser clipboard event enters the same upload flow without clicking upload. + await page.evaluate(encoded=>{const data=new DataTransfer();data.items.add(new File([Uint8Array.from(atob(encoded),c=>c.charCodeAt(0))],'粘贴截图.png',{type:'image/png'}));document.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));},png.toString('base64')); + await expect(page.locator('.tile')).toHaveCount(3); + await expect.poll(()=>page.locator('.tile img').evaluateAll(images=>images.length===2&&images.every(image=>image.complete&&image.naturalWidth>0))).toBe(true); + // Another device uploads; refresh discovers it without downloading old previews. + const remoteText='另一台设备的笔记'; + const externalUpload=await context.request.post(origin+'/api/upload',{headers:{Authorization:'Bearer '+lastAccessToken,Origin:origin},multipart:{full:{name:'remote.txt',mimeType:'text/plain',buffer:Buffer.from(remoteText)}}});expect(externalUpload.status()).toBe(200); + const oldReads=[...previewRequests];await page.locator('#refresh').click();await expect(page.locator('.tile')).toHaveCount(4);await expect(page.locator('#gallery')).toContainText(remoteText); + for(const path of new Set(oldReads))expect(previewRequests.filter(value=>value===path).length).toBe(oldReads.filter(value=>value===path).length); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.screenshot({path:'/tmp/shotsync-sync-gallery-mobile.png',fullPage:true}); + await page.setViewportSize({width:1280,height:900});expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.screenshot({path:'/tmp/shotsync-sync-gallery-desktop.png',fullPage:true}); + await page.setViewportSize({width:390,height:844}); + // An earlier upload finishing must not close a newly opened composer or erase its draft. + let releaseComposer;const composerGate=new Promise(resolve=>{releaseComposer=resolve;}); + let composerArrived;const composerPending=new Promise(resolve=>{composerArrived=resolve;}); + await page.route('**/api/upload',async route=>{const response=await route.fetch();composerArrived();await composerGate;await route.fulfill({response});},{times:1}); + await page.locator('#add-text').click();await page.locator('#text').fill('迟到的旧提交');await page.locator('#text-form button').click();await composerPending; + await page.locator('#close-composer').click();await page.locator('#add-text').click();await page.locator('#text').fill('不能丢失的新草稿');releaseComposer(); + await expect(page.locator('#text-form button')).toBeEnabled();await expect(page.locator('#composer-dialog')).toBeVisible();await expect(page.locator('#text')).toHaveValue('不能丢失的新草稿');await page.locator('#close-composer').click(); + await expect(page.locator('.tile')).toHaveCount(5); + page.on('dialog',dialog=>dialog.accept()); + while(await page.locator('.tile').count()){const remaining=await page.locator('.tile').count();await page.locator('.tile-open').first().click();await page.locator('#viewer-delete').click();await expect(page.locator('#viewer-dialog')).not.toBeVisible();await expect(page.locator('.tile')).toHaveCount(remaining-1);} // 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('#refresh').click();await expect(page.locator('#refresh')).toBeEnabled();await page.locator('#open-settings').click();await expect(page.locator('#retention')).toContainText('内容永久保存,不自动过期');await page.locator('#close-settings').click(); + await page.locator('#add-text').click(); 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('永久保存,不自动过期'); + await page.locator('.tile-open').click();await expect(page.locator('#viewer-meta')).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(); + await page.locator('#viewer-share').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 expect(page.getByText('任何持有链接的人都可访问', {exact:false})).toContainText('最多 24 小时'); + await page.locator('#viewer-delete').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;}); @@ -161,11 +213,14 @@ try { await expect(page.locator('#status')).toContainText('登录服务暂时不可用'); await expect(page.locator('#app')).toBeVisible(); expect(await page.evaluate(()=>accessToken.length>0)).toBe(true); + const failedSettingsRefresh=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/account/refresh'&&response.status()===503); + await page.locator('#open-settings').click();await failedSettingsRefresh; const beforeLogout=providerCalls.filter(call=>call==='POST /auth/v1/token').length; await page.locator('#logout').click();await expect(page.locator('#auth')).toBeVisible(); expect(providerCalls.filter(call=>call==='POST /auth/v1/token').length).toBe(beforeLogout); expect((await context.cookies()).some(cookie=>cookie.name==='__Host-shotsync-refresh')).toBe(false); refreshFailure=0;expect(await page.locator('#token-value').textContent()).toBe(''); + await expect(page.locator('#gallery')).toBeEmpty();await expect(page.locator('#viewer-content')).toBeEmpty();await expect(page.locator('#settings-dialog')).not.toBeVisible(); expect((await context.request.get(origin+'/api/list',{headers:{Authorization:'Bearer '+firstAccessToken}})).status()).toBe(401); await page.reload();await expect(page.locator('#auth')).toBeVisible();await expect(page.locator('#app')).toBeHidden(); await page.locator('#tab-register').click();await page.locator('#email').fill('new@example.com');await page.locator('#password').fill(password);await page.locator('#auth-submit').click(); @@ -175,7 +230,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 天'); + await page.locator('#open-settings').click();await expect(page.locator('#retention')).toContainText('内容保留 7 天');await page.locator('#close-settings').click(); 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); @@ -199,8 +254,14 @@ try { expect(providerCalls.some(call=>call.startsWith('PUT /auth/v1/admin/users/'))).toBe(true); 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}); + // A stale account request cannot retry its upload body using a later session. + let releaseStale;const staleGate=new Promise(resolve=>{releaseStale=resolve;});let staleArrived;const stalePending=new Promise(resolve=>{staleArrived=resolve;});let staleRequests=0; + await page.route('**/api/upload',async route=>{staleRequests++;staleArrived();await staleGate;await route.fulfill({status:401,contentType:'application/json',body:JSON.stringify({error:'Unauthorized'})});}); + await page.evaluate(()=>{const body=new FormData();body.append('full',new Blob(['old-account-only'],{type:'text/plain'}),'old.txt');window.staleOperation=api('/api/upload',{method:'POST',body}).catch(()=>null);}); + await stalePending;await page.evaluate(()=>{generation++;accessToken='new-account-access-token';});releaseStale();await page.evaluate(()=>window.staleOperation); + expect(staleRequests).toBe(1);expect(await page.evaluate(()=>accessToken)).toBe('new-account-access-token');await expect(page.locator('#app')).toBeVisible();await page.unroute('**/api/upload'); 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, 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'); + console.log('PASS: gallery composer/image/paste, cached previews and remote refresh, viewer copy/download/share/revoke/delete, desktop/mobile layouts, settings/device isolation; native JWT login/refresh/logout/recovery and retention; delayed composer and stale-session 401 regressions. Core flows use real Worker/D1/R2 with external provider/Turnstile fixtures.'); await privateContext.close();await context.close(); } finally { if(browser)await browser.close();if(server)await server.dispose(); diff --git a/src/hosted/ui.ts b/src/hosted/ui.ts index 6e652e4..b412f82 100644 --- a/src/hosted/ui.ts +++ b/src/hosted/ui.ts @@ -4,46 +4,77 @@ import type { HostedEnv } from './types'; export function hostedHTML(env: HostedEnv): string { const config = JSON.stringify({ sitekey: env.TURNSTILE_SITE_KEY || '' }).replace(/ShotSync · 随手传,随处取
ShotSync
+:root{color-scheme:dark;font-family:-apple-system,system-ui,sans-serif;background:#111;color:#eee}*{box-sizing:border-box}body{margin:0}main{margin:auto}header{display:flex;align-items:center;padding:14px}.brand{font-size:16px;font-weight:700}.brand span{color:#eee}h1{font-size:30px}h2{font-size:17px;margin:0}p{line-height:1.6}small,.muted{color:#999;font-size:12px}a{color:#9dbdff}.card{background:#181818;border:1px solid #303030;border-radius:12px;padding:22px;margin:20px 0}.auth{max-width:480px;margin:36px auto;padding:0 18px}label{display:block;font-size:14px;margin:16px 0 7px}input,textarea,button{font:inherit}input,textarea{width:100%;background:#202020;border:1px solid #3a3a3a;border-radius:8px;color:inherit;padding:10px}textarea{resize:vertical;min-height:220px}button,.button{cursor:pointer;border:0;background:#333;color:#eee;border-radius:8px;padding:8px 12px;font-size:14px}button:hover{filter:brightness(1.2)}button:disabled{opacity:.5;cursor:wait}button:focus-visible{outline:2px solid #8aafff;outline-offset:3px}.primary{background:#2b6cff;color:#fff}.plain{background:transparent;color:#afc9ff}.row{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.spread{justify-content:space-between}.toolbar{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:10px 14px;background:#181818;border-bottom:1px solid #2a2a2a}.toolbar .brand{flex:1}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:6px;padding:6px}.tile{min-width:0}.tile-open{display:block;width:100%;aspect-ratio:1;overflow:hidden;padding:0;background:#202020;border-radius:6px;text-align:left;position:relative}.preview{width:100%;height:100%;object-fit:cover;display:block}.textpreview{white-space:pre-wrap;overflow:hidden;padding:12px;height:100%;background:#1c2030;color:#cdd3e0;font-size:12px;overflow-wrap:anywhere}.placeholder{display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-size:12px;padding:10px}.tile-label{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,#000b);padding:18px 8px 6px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.notice{position:fixed;z-index:8;bottom:18px;left:50%;transform:translateX(-50%);max-width:calc(100% - 24px);width:max-content;background:#303030;border-radius:12px;padding:10px 16px;font-size:13px;overflow-wrap:anywhere;box-shadow:0 4px 24px #0006}.error{background:#552d34;color:#ffd4dc}[hidden]{display:none!important}.empty{padding:90px 20px;text-align:center;color:#888;font-size:14px}.device{padding:12px 0;border-bottom:1px solid #303030}.token{overflow-wrap:anywhere;user-select:all;white-space:pre-wrap}.danger{color:#ffb4be}.tabs{margin-bottom:20px}.meters{display:grid;grid-template-columns:1fr 1fr;gap:14px}progress{width:100%;height:6px;accent-color:#2b6cff}dialog{color:#eee;background:#181818;border:1px solid #333;border-radius:14px;padding:20px;width:min(560px,calc(100% - 24px));max-height:calc(100dvh - 32px);overflow:auto}dialog::backdrop{background:#000b}dialog h2{margin:0}.dialog-head{margin-bottom:20px}#viewer-dialog{width:min(900px,calc(100% - 24px))}#viewer-content{display:flex;justify-content:center;min-height:120px;margin:14px 0}#viewer-content img{max-width:100%;max-height:62dvh;object-fit:contain}#viewer-content pre{width:100%;margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.6 ui-monospace,monospace;max-height:60dvh;overflow:auto}#viewer-name{overflow-wrap:anywhere}#share-result{margin-top:16px}#new-token{background:#242c3c;border-radius:8px;padding:12px;margin:12px 0}#settings-dialog section{margin-top:24px}#settings-dialog .account-email{overflow-wrap:anywhere;margin:12px 0}body.dragging:after{content:'松开即可上传图片';position:fixed;inset:12px;z-index:10;border:2px dashed #6798ff;border-radius:14px;background:#15284bea;display:grid;place-items:center;pointer-events:none}@media(max-width:520px){.toolbar{gap:6px;padding:10px}.toolbar button{padding:8px 10px}.grid{grid-template-columns:repeat(3,minmax(0,1fr))}dialog{padding:16px}.toolbar .brand{font-size:15px}.auth{margin-top:20px}} +
ShotSync

随手传,随处取。

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

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

欢迎回来

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

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

发送文字

最多 100 KiB
+

+

设置

每日额度按 UTC 零点重置。图片最多 10 MiB,文字最多 100 KiB;每分钟最多上传 10 次。删除不返还当天上传额度。

连接其他设备

Mac 客户端和快捷指令可使用设备令牌。它等同访问凭证,仅显示一次,请妥善保存。

自己部署
`; } diff --git a/test/hosted-ui.test.ts b/test/hosted-ui.test.ts index 82565f5..ea1e283 100644 --- a/test/hosted-ui.test.ts +++ b/test/hosted-ui.test.ts @@ -42,9 +42,9 @@ describe('hosted browser UI security and protocol', () => { expect(html).toContain("body.append('full',file)"); expect(html).toContain("body.append('full',new Blob([value],{type:'text/plain'}),'text.txt')"); for (const path of ['reset-password', '/api/account/me', '/api/account/devices', '/api/share/']) expect(html).toContain(path); - expect(html).toContain("preview.textContent=await blob.text()"); + expect(html).toContain("entry.preview.textContent=text"); expect(html).toContain("limits.storedBytes||209715200"); - expect(html).toContain("['register','reset'].includes(mode)"); + expect(html).toContain("['register','reset'].includes(submittedMode)"); expect(html).toContain("finally{if(needsCaptcha){captchaToken=''"); expect(html).toContain('最多 50 次'); });