From e0722fabca276b55dfbf96f1d6ef6494f3832ac7 Mon Sep 17 00:00:00 2001 From: jinkunsun Date: Sun, 20 Sep 2026 12:13:50 +0800 Subject: [PATCH 1/2] feat: restore gallery controls and add mobile access guide --- README.md | 18 +++++++++-- README.zh-CN.md | 18 +++++++++-- docs/deployment-modes.md | 8 ++--- docs/hosted.md | 6 ++-- scripts/test-hosted-browser.mjs | 53 ++++++++++++++++++++++++++++--- src/about.ts | 10 +++--- src/gallery/page.ts | 9 +++++- src/hosted/index.ts | 3 ++ src/hosted/mobile-icons.ts | 7 +++++ src/hosted/mobile.ts | 40 ++++++++++++++++++++++++ src/hosted/ui.ts | 31 ++++++++++++------- test/hosted-mobile.test.ts | 55 +++++++++++++++++++++++++++++++++ test/hosted-ui.test.ts | 8 +++++ 13 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 src/hosted/mobile-icons.ts create mode 100644 src/hosted/mobile.ts create mode 100644 test/hosted-mobile.test.ts diff --git a/README.md b/README.md index 87081e3..a08c7bc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ English | [简体中文](README.zh-CN.md) # shotsync -Your own cross-device image & text pool, deployable to Cloudflare's free tier in a few minutes. Drop a screenshot or photo on one device, grab it on another. No app to install (the phone client is a PWA), no third-party image host — your data lives only in your own Cloudflare account. +Your own cross-device image & text pool, deployable to Cloudflare's free tier in a few minutes. Drop a screenshot or photo on one device, grab it on another. No app to install (the phone client is a PWA), no third-party image host — when self-hosted, your data lives only in your own Cloudflare account. + +**[Use ShotSync online →](https://shotsync-hosted.defiabell.workers.dev)** No deployment needed. Sign up with an email and password to sync images and text. Beta limited to 100 accounts; new accounts keep files for 7 days by default. **🎬 Live demo (read-only sample pool): https://shotsync-demo.defiabell.workers.dev** @@ -15,10 +17,20 @@ Your own cross-device image & text pool, deployable to Cloudflare's free tier in | Mode | Best for | Access | File storage | | --- | --- | --- | --- | | **Self-hosted (default)** | Your own image and text pool | Deploy, then enter your own `AUTH_TOKEN` | Your Cloudflare R2 | -| Hosted service (beta) | Using the operator's service without deploying | Register/sign in at the hosted URL | The operator's Cloudflare R2 | +| Hosted service (beta) | Using the operator's service without deploying | [Sign in or create an account](https://shotsync-hosted.defiabell.workers.dev) | The operator's Cloudflare R2 | | Read-only demo | Exploring the interface | Open the demo above; uploads are disabled | Public samples | -For self-hosting, follow [the steps below](#deploy-your-own-5-min). See the [hosted guide](docs/hosted.md) for availability and quotas: the current beta still has a Free-plan CPU limitation and its authentication upgrade is not deployed. [Mode details and FAQ](docs/deployment-modes.md). +For self-hosting, follow [the steps below](#deploy-your-own-5-min). See the [hosted guide](docs/hosted.md) for availability and quotas: the current beta still has a Free-plan CPU limitation and authentication now uses Supabase Auth. [Mode details and FAQ](docs/deployment-modes.md). + +## Start using the hosted service + +1. Open the [hosted service](https://shotsync-hosted.defiabell.workers.dev), register with an email and password, and save your recovery code. No email verification is required. +2. Choose **+ 图片** to upload an image or **文字** to send text. Sign in to the same account on another device to retrieve it. +3. Open settings to copy the gallery address, check quotas and retention, or generate a device token for the Mac app / iOS Shortcut. + +On a phone, follow the [home-screen shortcut guide](https://shotsync-hosted.defiabell.workers.dev/mobile) for iPhone or Android to open your gallery from an app icon. + +The read-only demo contains public samples and does not accept uploads. Hosted content is isolated by account; users do not need Cloudflare or Supabase configuration. ![shotsync gallery](docs/screenshot.png) diff --git a/README.zh-CN.md b/README.zh-CN.md index d6b7e2f..21e8bbe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,9 @@ # shotsync -属于你自己的跨设备图片 & 文字中转池,几分钟就能部署到 Cloudflare 免费档。在一台设备上截图或存图,另一台设备随手就能拿到。手机端无需装 App(是 PWA),不经任何第三方图床——数据只待在你自己的 Cloudflare 账户里。 +属于你自己的跨设备图片 & 文字中转池,几分钟就能部署到 Cloudflare 免费档。在一台设备上截图或存图,另一台设备随手就能拿到。手机端无需装 App(是 PWA),不经任何第三方图床——自部署时,数据只待在你自己的 Cloudflare 账户里。 + +**[直接使用在线版 →](https://shotsync-hosted.defiabell.workers.dev)** 无需部署,邮箱+密码注册后即可同步图片和文字。免费试用限 100 个账号,新账号默认保留 7 天。 **🎬 在线演示(只读示例池):https://shotsync-demo.defiabell.workers.dev** @@ -13,10 +15,20 @@ | 方式 | 适合谁 | 如何进入 | 数据存放 | | --- | --- | --- | --- | | **自己部署(默认)** | 想拥有自己的图片与文字池 | 部署后输入自己设置的 `AUTH_TOKEN` | 自己的 Cloudflare R2 | -| 公共托管服务(试用) | 不想部署,接受运营方存储数据和使用限额 | 在服务地址注册/登录 | 运营方的 Cloudflare R2 | +| 公共托管服务(试用) | 不想部署,接受运营方存储数据和使用限额 | [注册/登录在线版](https://shotsync-hosted.defiabell.workers.dev) | 运营方的 Cloudflare R2 | | 只读演示 | 先看看界面 | 打开上方演示地址;不能上传 | 公开样例 | -自部署按[下面的步骤](#自己部署约-5-分钟)操作即可。公共服务的可用状态和限额见[托管版说明](docs/hosted.md);托管版目前仍有免费 CPU 限制,认证升级尚未上线。[模式区别与常见问题](docs/deployment-modes.md)。 +自部署按[下面的步骤](#自己部署约-5-分钟)操作即可。公共服务的可用状态和限额见[托管版说明](docs/hosted.md);托管版目前仍有免费 CPU 限制,已接入 Supabase Auth。[模式区别与常见问题](docs/deployment-modes.md)。 + +## 直接使用在线版 + +1. 打开[在线版](https://shotsync-hosted.defiabell.workers.dev),注册邮箱和密码,无需邮件验证;保存页面给出的恢复码。 +2. 点击「+ 图片」上传,或点击「文字」发送;另一台设备登录同一账号即可取回。 +3. 设置中可复制相册地址、查看额度和保留期,或生成设备令牌连接 Mac 客户端/快捷指令。 + +手机用户可打开[手机快捷入口设置](https://shotsync-hosted.defiabell.workers.dev/mobile),按 iPhone / Android 指引添加到主屏幕,以后点图标就能进入相册。 + +只读 Demo 展示公开样例,不能上传;在线版的内容按账号隔离。使用在线版无需配置 Cloudflare 或 Supabase。 ![shotsync gallery](docs/screenshot.png) diff --git a/docs/deployment-modes.md b/docs/deployment-modes.md index 14b50b2..d34a6a2 100644 --- a/docs/deployment-modes.md +++ b/docs/deployment-modes.md @@ -17,15 +17,15 @@ ## 可选:直接使用公共托管服务 -不想部署的用户可使用运营方提供的账号服务。浏览器通过邮箱和密码进入自己的文件池;文件存放在运营方的 Cloudflare 中,受服务人数、上传、存储和保留时间限制。使用者不需要开通 Cloudflare 或 Supabase。 +不想部署的用户可直接打开[ShotSync 在线版](https://shotsync-hosted.defiabell.workers.dev),注册邮箱和密码,保存恢复码。浏览器通过邮箱和密码进入自己的文件池;文件存放在运营方的 Cloudflare 中,受服务人数、上传、存储和保留时间限制。使用者不需要开通 Cloudflare 或 Supabase。 托管服务与自部署实例的数据、登录账号和访问令牌不互通,不会自动迁移或同步。Mac App/快捷指令应填写所选服务的地址:个人池使用共享 `AUTH_TOKEN`,托管服务使用登录后创建的设备令牌。 -当前公共服务仍为试用,原登录实现存在免费 CPU 限制,认证升级尚未部署;实际状态见[托管说明](hosted.md)。只读 demo 仅用于浏览公开样例,不接受上传,也不是公共账号服务。 +当前公共服务已接入 Supabase Auth,限 100 个账号试用,新账号默认保留 7 天;免费 CPU 容量限制仍需注意,实际状态见[托管说明](hosted.md)。只读 demo 仅用于浏览公开样例,不接受上传,也不是公共账号服务。 ## 仅运营者:部署多用户账号服务 -[托管部署文档](hosted.md)面向想为多人运营服务的人。它有独立的 Worker 入口、D1 和 R2 资源;外部认证升级另需运营者的 Supabase 配置。不要把这套配置作为个人池的安装前提,也不要复制维护者的数据库 ID、存储桶或认证项目配置。 +[托管部署文档](hosted.md)面向想为多人运营服务的人。它有独立的 Worker 入口、D1 和 R2 资源;账号认证需要运营者的 Supabase 配置。不要把这套配置作为个人池的安装前提,也不要复制维护者的数据库 ID、存储桶或认证项目配置。 | 命令 | 配置 | 部署内容 | | --- | --- | --- | @@ -38,6 +38,6 @@ Self-hosting remains the default: Worker + R2 + one shared `AUTH_TOKEN`. Run `npm run deploy`; no ShotSync account, Supabase, D1, email or CAPTCHA setup is required. Token holders share the entire pool. Configure the 30-day R2 lifecycle explicitly. -The optional hosted service uses individual accounts and the operator's storage/quotas. Only its operator needs the extra infrastructure; end users sign in at that service's URL. Accounts, files and credentials are not automatically shared with personal deployments. The hosted beta is still undergoing an authentication upgrade; see its guide for current availability. +The optional hosted service uses individual accounts and the operator's storage/quotas. Only its operator needs the extra infrastructure; end users sign in at that service's URL. Accounts, files and credentials are not automatically shared with personal deployments. [Open the hosted beta](https://shotsync-hosted.defiabell.workers.dev): authentication uses Supabase Auth, with a 100-account cap and 7-day default retention. See its guide for quotas and capacity limits. `npm run deploy:hosted` is an explicit, separate operator workflow. It never replaces the default personal deployment. The read-only demo is only for exploring sample content. diff --git a/docs/hosted.md b/docs/hosted.md index 897c6be..3aeaeb7 100644 --- a/docs/hosted.md +++ b/docs/hosted.md @@ -6,7 +6,9 @@ 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. +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. Use **选择** to select multiple items and confirm a batch deletion; failed deletions remain selected for retry. Settings includes a copyable gallery address and device tokens: tokens created on this page can be revealed or copied again during the current page session, and are masked when settings closes. Reloading or logging out clears these copies; the server stores only hashes and cannot recover older tokens. 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. + +The public **[/mobile](https://shotsync-hosted.defiabell.workers.dev/mobile)** guide explains iPhone Safari and Android Chrome home-screen shortcuts plus a Shortcuts “Open URL” entry. It is also linked from Settings. The manifest opens `/` in standalone mode when supported; real 192/512-pixel PNG and 180-pixel Apple touch icons are served locally. These routes contain only the configured public origin, never credentials, and bypass database-backed API counters. No service worker or offline private-data cache is installed; syncing requires connectivity. Browser menus and installation support vary, and a new home-screen context may require sign-in. Instructions follow [Apple](https://support.apple.com/guide/iphone/iphea86e5236/ios) and [Chrome](https://support.google.com/chrome/answer/9658361?co=GENIE.Platform%3DAndroid). 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. @@ -14,7 +16,7 @@ Supabase Auth owns passwords and browser sessions; there is no local PBKDF2/scry Accounts are specific to ShotSync: using the same registration style as Yixi does not share accounts or its database. A dedicated personal Supabase project is required. Public Supabase signup stays disabled, including after launch; website registration remains available because ShotSync performs admin creation only after its own Turnstile and admission checks. Admin creation uses `email_confirm: true` only to allow password sign-in without mail; it is not evidence of mailbox ownership. IDs and server-controlled application metadata bind identities, never an email match alone. `verified_at` remains NULL for new accounts and is reserved for a future, explicit mailbox verification flow. Never link accounts across products or grant mailbox-based trust from an email string alone. Migration `0003_recovery.sql` only adds a nullable hash column; it does not mark existing accounts verified, delete users, or give legacy accounts guessed recovery codes. The obsolete mail-token table is retained for non-destructive migration compatibility, but mail endpoints and delivery code are removed. -Browser refresh cookies are scoped to the hosted origin. Logout revokes the Supabase session and records its session ID in D1 so an already issued access JWT cannot continue accessing ShotSync. Accepted access JWT lifetimes are at most 24 hours; successful remote logout retains the revocation for two days, and uncertain remote logout retains it indefinitely. An expired, correctly signed JWT can only revoke its session, never authorize a file request. Recovery increments the D1 authentication version before changing the provider password; signed application metadata must match that version. Mac and iOS clients use individually revocable device tokens (shown once, maximum 10, expire after 90 days). Use the hosted origin as the existing client's base URL and the device token as its bearer credential; the multipart `full`/optional `thumb` upload protocol is retained. Browser gallery JSON is specific to hosted mode. +Browser refresh cookies are scoped to the hosted origin. Logout revokes the Supabase session and records its session ID in D1 so an already issued access JWT cannot continue accessing ShotSync. Accepted access JWT lifetimes are at most 24 hours; successful remote logout retains the revocation for two days, and uncertain remote logout retains it indefinitely. An expired, correctly signed JWT can only revoke its session, never authorize a file request. Recovery increments the D1 authentication version before changing the provider password; signed application metadata must match that version. Mac and iOS clients use individually revocable device tokens (issued once by the server, maximum 10, expire after 90 days). Use the hosted origin as the existing client's base URL and the device token as its bearer credential; the multipart `full`/optional `thumb` upload protocol is retained. Browser gallery JSON is specific to hosted mode. File access, deletion and sharing resolve ownership from authenticated account IDs, never from caller-supplied user IDs. Files are not end-to-end encrypted: the service operator has access to storage. The UI doesn't persist credentials/files in localStorage or a service worker. Shared links are bearer credentials: anyone holding one can access its file until expiry or revocation. diff --git a/scripts/test-hosted-browser.mjs b/scripts/test-hosted-browser.mjs index 5209649..a9e54b8 100644 --- a/scripts/test-hosted-browser.mjs +++ b/scripts/test-hosted-browser.mjs @@ -104,13 +104,21 @@ try { })); 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 context.grantPermissions(['clipboard-read','clipboard-write']); + await page.goto(origin+'/mobile'); + await expect(page.locator('#mobile-url')).toHaveValue(origin+'/'); + await page.locator('#copy-mobile-url').click();expect(await page.evaluate(()=>navigator.clipboard.readText())).toBe(origin+'/'); + await expect(page.locator('#open-gallery')).toHaveAttribute('href','/'); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + expect(await page.evaluate(async()=>navigator.serviceWorker?(await navigator.serviceWorker.getRegistrations()).length:0)).toBe(0); + await page.locator('#open-gallery').click(); 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('#mobile-guide')).toHaveAttribute('href','/mobile'); await expect(page.locator('#retention')).toContainText('内容保留 90 天'); await page.locator('#close-settings').click(); const firstAccessToken=lastAccessToken;expect(firstAccessToken.split('.')).toHaveLength(3); @@ -132,8 +140,22 @@ try { 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(); + const createdDeviceResponse=page.waitForResponse(response=>response.request().method()==='POST'&&new URL(response.url()).pathname==='/api/account/devices'); 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(); + // Connection settings expose a clean gallery URL and keep new device tokens in this session only. + expect(new URL(await page.locator('#gallery-url').inputValue()).href).toBe(origin+'/'); + await page.locator('#copy-gallery-url').click();expect(new URL(await page.evaluate(()=>navigator.clipboard.readText())).href).toBe(origin+'/'); + const createdDevice=await (await createdDeviceResponse).json(); + await expect(page.locator('#token-value')).not.toHaveText(createdDevice.token); + await page.locator('#reveal-token').click(); + const token=await page.locator('#token-value').textContent();expect(token).toBe(createdDevice.token); + await page.locator('#copy-token').click();expect(await page.evaluate(()=>navigator.clipboard.readText())).toBe(token); + await page.locator('#reveal-token').click();await expect(page.locator('#token-value')).not.toHaveText(token); + await page.locator('#close-settings').click();await page.locator('#open-settings').click(); + await page.locator('#devices').getByRole('button',{name:'查看令牌',exact:true}).click(); + await expect(page.locator('#token-value')).not.toHaveText(token);await page.locator('#reveal-token').click();await expect(page.locator('#token-value')).toHaveText(token); + await page.locator('#dismiss-token').click();await expect(page.locator('#new-token')).not.toBeVisible(); + await page.locator('#devices').getByRole('button',{name:'查看令牌',exact:true}).click();await page.locator('#reveal-token').click();await expect(page.locator('#token-value')).toHaveText(token); 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); @@ -146,7 +168,7 @@ try { 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.locator('#devices').getByRole('button',{name:'撤销',exact:true}).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.locator('#close-settings').click(); // A real file-input change auto-uploads and produces an automatic thumbnail. const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9XkAAAAASUVORK5CYII=','base64'); @@ -181,6 +203,24 @@ try { 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); + // Selection opens no viewer, cancelling is non-destructive, and a rejected confirmation sends no delete. + await page.locator('#select-items').click();await page.locator('.tile-open').nth(0).click();await page.locator('.tile-open').nth(1).click(); + await expect(page.locator('#viewer-dialog')).not.toBeVisible();await expect(page.locator('.tile.selected')).toHaveCount(2); + await expect(page.locator('.tile-open[aria-pressed="true"]')).toHaveCount(2); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.locator('#cancel-selection').click();await expect(page.locator('.tile.selected')).toHaveCount(0);await expect(page.locator('.tile')).toHaveCount(5); + await page.locator('#select-items').click();await page.locator('.tile-open').nth(0).click();await page.locator('.tile-open').nth(1).click(); + let deleteRequests=0;const trackDelete=request=>{if(request.method()==='DELETE'&&new URL(request.url()).pathname.startsWith('/api/img/'))deleteRequests++;};page.on('request',trackDelete); + page.once('dialog',dialog=>dialog.dismiss());await page.locator('#delete-selected').click();expect(deleteRequests).toBe(0);await expect(page.locator('.tile.selected')).toHaveCount(2); + // One failed deletion must stay selected; the successful deletion remains removed in real D1/R2. + let failedDelete=false; + await page.route('**/api/img/*',async route=>{if(route.request().method()==='DELETE'&&!failedDelete){failedDelete=true;await route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:'fixture delete failure'})});}else await route.continue();}); + page.once('dialog',dialog=>dialog.accept());await page.locator('#delete-selected').click(); + await expect(page.locator('.tile')).toHaveCount(4);await expect(page.locator('.tile.selected')).toHaveCount(1);await expect(page.locator('#delete-selected')).toBeEnabled(); + expect(deleteRequests).toBe(2);await page.unroute('**/api/img/*'); + page.once('dialog',dialog=>dialog.accept());await page.locator('#delete-selected').click();await expect(page.locator('.tile')).toHaveCount(3); + expect(deleteRequests).toBe(3);page.off('request',trackDelete); + if(await page.locator('#cancel-selection').isVisible())await page.locator('#cancel-selection').click(); 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. @@ -200,6 +240,9 @@ try { 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(); + // Leave a real device token in memory, then ensure logout clears it and a different account cannot view it. + 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();await page.locator('#reveal-token').click(); + const logoutToken=await page.locator('#token-value').textContent();expect(logoutToken.length).toBeGreaterThan(20);await page.locator('#close-settings').click(); refreshFailure=429; await page.evaluate(()=>{expiresAt=Date.now()-1;}); await page.locator('#refresh').click(); @@ -230,7 +273,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 page.locator('#open-settings').click();await expect(page.locator('#retention')).toContainText('内容保留 7 天');await page.locator('#close-settings').click(); + await page.locator('#open-settings').click();await expect(page.locator('#retention')).toContainText('内容保留 7 天');await expect(page.locator('#new-token')).not.toBeVisible();await expect(page.locator('#devices')).not.toContainText('退出隔离测试');expect(await page.locator('#token-value').textContent()).not.toBe(logoutToken);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); @@ -261,7 +304,7 @@ try { 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: 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.'); + console.log('PASS: selection/cancel/confirmed batch delete/partial failure, gallery URL and masked session token copy/reopen/logout isolation; 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/about.ts b/src/about.ts index 942b335..8a0d4cd 100644 --- a/src/about.ts +++ b/src/about.ts @@ -56,11 +56,11 @@ export const aboutHTML = /* html */ `

开源 · 自部署 · 跨设备暂存

shotsync:截图和文字,换台设备接着用。

-

shotsync 是一个运行在你自己的 Cloudflare 账号中的图片与文字暂存工具。在电脑上传截图,稍后从手机保存;在手机发一段文字,回到电脑复制。两台设备无需同时在线,也无需连接同一个 Wi-Fi。

- +

shotsync 是一个图片与文字暂存工具,可以直接使用在线版,也可以部署在自己的 Cloudflare 账号中。在电脑上传截图,稍后从手机保存;在手机发一段文字,回到电脑复制。两台设备无需同时在线,也无需连接同一个 Wi-Fi。

+

演示站只有公开样例,不提供访客上传。自己部署是默认方式,无需注册账号或配置 Supabase;公共账号服务是另一个独立入口。

-

自己部署,还是直接使用服务?

自己部署:内容存放在你自己的 Cloudflare,输入一个访问令牌即可使用,无需邮箱、账号数据库或 Supabase。

公共托管服务:在运营方提供的地址注册登录,文件按账号隔离,适用运营方的限额和保留时间。公开试用仍在升级认证;查看可用状态与说明。两种方式的账号、访问令牌和文件不互通。

+

自己部署,还是直接使用服务?

自己部署:内容存放在你自己的 Cloudflare,输入一个访问令牌即可使用,无需邮箱、账号数据库或 Supabase。

公共托管服务:在运营方提供的地址注册登录,文件按账号隔离,适用运营方的限额和保留时间。打开在线版,邮箱+密码注册,无需邮件验证,请保存恢复码。当前限 100 个账号试用,新账号默认保留 7 天;查看可用状态与说明。两种方式的账号、访问令牌和文件不互通。

手机使用在线版,可按手机快捷入口设置添加到主屏幕,以后点图标就能进入相册。

适合什么场景?

  • 工作时截一张图,过一会儿在另一台电脑或手机取走。
  • 把照片、链接或一段纯文字暂存在自己的池子,代替给自己发消息。
  • 给别人发送单个内容的临时分享链接,不开放整个图片池。
@@ -92,9 +92,9 @@ export const aboutHTML = /* html */ `

token 泄露了怎么办?

在自己的 Worker 上更新 AUTH_TOKEN,并在各设备重新输入。更新 token 也会使此前签发的分享链接失效。

为什么上传后另一台设备看不到?

先确认两台设备打开的是同一个部署地址,token 一致,上传已成功。等待一次自动刷新;如果仍失败,检查浏览器请求及 Worker 日志。仅打开网页不能完成设备间同步,内容必须先上传成功。

-

What is shotsync?

shotsync is an open-source, self-hosted image and text pool built with Cloudflare Workers and R2. Upload a screenshot or text snippet on one device and retrieve it later on another. The browser/PWA client uses one shared access token; optional macOS and iOS Shortcut integrations are available. Configure R2 lifecycle rules for 30-day retention. It is a personal transit pool, not an archive or a multi-user file service.

+

What is shotsync?

shotsync is an open-source, self-hosted image and text pool built with Cloudflare Workers and R2. Upload a screenshot or text snippet on one device and retrieve it later on another. The browser/PWA client uses one shared access token; optional macOS and iOS Shortcut integrations are available. Configure R2 lifecycle rules for 30-day retention. The default self-hosted mode is a personal transit pool. You can also use the hosted service with an individual account, without deploying; its quotas and retention differ.

- + `; export function robotsTXT(isDemo: boolean): string { diff --git a/src/gallery/page.ts b/src/gallery/page.ts index 86cbe45..c8c1e5b 100644 --- a/src/gallery/page.ts +++ b/src/gallery/page.ts @@ -477,13 +477,20 @@ if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } -// Read-only demo pool: no token gate, no write affordances, a link back to the repo. +// Read-only demo pool: offer the hosted service and self-hosting without enabling writes. async function enterDemo() { showApp(); ["#uploadBtn", "#textBtn", "#selectBtn", "#settingsBtn", "#shareBtn", "#delBtn"].forEach((s) => $(s).classList.add("hidden")); if (DEMO_EN) document.documentElement.lang = "en"; $("#bar h1").textContent = DEMO_EN ? "shotsync · read-only demo" : "shotsync · 只读演示池"; $("#closeBtn").textContent = DEMO_EN ? "Close" : "关闭"; + $("#bar").style.flexWrap = "wrap"; + const hosted = document.createElement("a"); + hosted.id = "hosted-link"; + hosted.href = "https://shotsync-hosted.defiabell.workers.dev"; + hosted.textContent = DEMO_EN ? "Use online →" : "直接使用在线版 →"; + hosted.style.cssText = "background:#2b6cff;color:#fff;font-size:13px;text-decoration:none;padding:8px 12px;border-radius:8px;white-space:nowrap"; + $("#bar").appendChild(hosted); const link = document.createElement("a"); link.href = "https://github.com/Defiabell/shotsync"; link.target = "_blank"; diff --git a/src/hosted/index.ts b/src/hosted/index.ts index 4ae4436..5b06247 100644 --- a/src/hosted/index.ts +++ b/src/hosted/index.ts @@ -4,10 +4,13 @@ import { handleFiles, handleShared, cleanupFiles } from './files'; import { consumeRate } from './limits'; import { error, HttpError } from './http'; import { hostedHTML } from './ui'; +import { mobileResponse } from './mobile'; async function route(request: Request, env: HostedEnv): Promise { const url = new URL(request.url), path = url.pathname; if (url.origin !== env.PUBLIC_ORIGIN) return error(421, '请使用服务正式地址'); + const mobile = mobileResponse(request, env); + if (mobile) return mobile; if (path === '/robots.txt') return new Response('User-agent: *\nDisallow: /\n'); if ((path === '/' || path === '/account') && request.method === 'GET') { return new Response(hostedHTML(env), { headers: { 'content-type': 'text/html; charset=utf-8' } }); diff --git a/src/hosted/mobile-icons.ts b/src/hosted/mobile-icons.ts new file mode 100644 index 0000000..981c7b5 --- /dev/null +++ b/src/hosted/mobile-icons.ts @@ -0,0 +1,7 @@ +// Static PNGs rendered from the ShotSync two-arrow icon; no runtime image dependency. +// Source SVG: +export const MOBILE_ICONS: Record = { + "/icons/icon-192.png": "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", + "/icons/icon-512.png": "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", + "/icons/apple-touch-icon.png": "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" +}; diff --git a/src/hosted/mobile.ts b/src/hosted/mobile.ts new file mode 100644 index 0000000..c4d3c89 --- /dev/null +++ b/src/hosted/mobile.ts @@ -0,0 +1,40 @@ +import type { HostedEnv } from './types'; +import { MOBILE_ICONS } from './mobile-icons'; + +export const MOBILE_HEAD = ``; + +function escapeHTML(value: string): string { + return value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!); +} + +export function mobileHTML(env: Pick): string { + const address = escapeHTML(new URL('/', env.PUBLIC_ORIGIN).href); + return `${MOBILE_HEAD}手机快捷入口 · ShotSync
ShotSync

在手机上一点就打开

把相册放到主屏幕,随时传图片、发文字。第一次从新入口打开时,可能需要重新登录。

打开相册

+

iPhone · 添加到主屏幕

  1. Safari 打开上面的相册地址。
  2. 点「分享」(部分版本先点「更多」),选择「添加到主屏幕」。
  3. 如果出现「作为网页 App 打开」,将它打开,再点「添加」。以后点主屏幕上的 ShotSync 图标即可进入。

在微信等 App 内打开时,先复制地址到 Safari。菜单名称可能随系统版本变化。

+

Android · 添加到主屏幕

  1. Chrome 打开上面的相册地址。
  2. 点右上角「⋮」菜单,选择「添加到主屏幕」。
  3. 根据浏览器显示选择「安装」或「创建快捷方式」,按屏幕提示完成。

是否提供安装、以及打开后的窗口样式,由浏览器和系统决定。

+

iPhone · 快捷指令入口

  1. 先复制上面的相册地址,打开「快捷指令」App,新建快捷指令。
  2. 添加「打开 URL」动作,填入相册地址,命名为「打开 ShotSync」。
  3. 可把它放到「快捷指令」小组件;支持的 iPhone 也可在系统设置里绑定「操作按钮」或「轻点背面」。

这个入口只负责打开相册,无需把密码或令牌放进网址。打开后仍使用你的账号登录。

+

同步需要联网;添加到主屏幕不会开启离线保存。需要在 Mac 客户端或上传快捷指令中同步?登录相册后,在「设置 → 连接其他设备」复制地址并生成设备令牌。

官方说明:iPhone 添加到主屏幕 · Android 网页应用

+
`; +} + +/** Public, credential-free assets. No service worker or offline private cache. */ +export function mobileResponse(request: Request, env: Pick): Response | null { + if (!['GET', 'HEAD'].includes(request.method)) return null; + const path = new URL(request.url).pathname; + const head = request.method === 'HEAD'; + if (path === '/mobile') return new Response(head ? null : mobileHTML(env), { headers: { 'content-type': 'text/html; charset=utf-8' } }); + if (path === '/manifest.webmanifest') { + return new Response(head ? null : JSON.stringify({ + id: '/', name: 'ShotSync', short_name: 'ShotSync', start_url: '/', scope: '/', display: 'standalone', + background_color: '#111111', theme_color: '#111111', lang: 'zh-CN', + icons: [192, 512].map(size => ({ src: '/icons/icon-' + size + '.png', sizes: size + 'x' + size, type: 'image/png', purpose: 'any maskable' })), + }), { headers: { 'content-type': 'application/manifest+json; charset=utf-8' } }); + } + if (Object.hasOwn(MOBILE_ICONS, path)) { + const bytes = head ? null : Uint8Array.from(atob(MOBILE_ICONS[path]), char => char.charCodeAt(0)); + return new Response(bytes, { headers: { 'content-type': 'image/png' } }); + } + return null; +} diff --git a/src/hosted/ui.ts b/src/hosted/ui.ts index b412f82..a8c0316 100644 --- a/src/hosted/ui.ts +++ b/src/hosted/ui.ts @@ -1,28 +1,29 @@ import type { HostedEnv } from './types'; +import { MOBILE_HEAD } from './mobile'; /** The hosted app intentionally has no service worker or persistent browser storage. */ export function hostedHTML(env: HostedEnv): string { const config = JSON.stringify({ sitekey: env.TURNSTILE_SITE_KEY || '' }).replace(/ShotSync · 随手传,随处取
ShotSync
-

随手传,随处取。

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

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

欢迎回来

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

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

随手传,随处取。

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

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

手机使用:添加到主屏幕 / 快捷指令 →

欢迎回来

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

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

发送文字

最多 100 KiB

-

设置

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

连接其他设备

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

自己部署
+

设置

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

手机快捷入口

添加到主屏幕 / 快捷指令 →

连接其他设备

其他浏览器打开相册地址后登录。Mac 客户端和快捷指令使用此地址与设备令牌连接。

本次页面中新建的令牌可再次查看、复制;刷新页面或退出后无法再次查看,请保存到密码管理器。服务端仅保存令牌哈希,已有令牌无法恢复,可撤销后重新生成。

自己部署
`; } diff --git a/test/hosted-mobile.test.ts b/test/hosted-mobile.test.ts new file mode 100644 index 0000000..7d01745 --- /dev/null +++ b/test/hosted-mobile.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import worker from '../src/hosted'; +import type { HostedEnv } from '../src/hosted/types'; + +const origin = 'https://shotsync.example.com'; +// Public install instructions and icons must work without a session or database. +const publicEnv = { + PUBLIC_ORIGIN: origin, + get DB() { throw new Error('Public mobile resources must not consume database quota'); }, +} as unknown as HostedEnv; +const request = (path: string, method = 'GET') => worker.fetch(new Request(origin + path, { method }), publicEnv); + +describe('hosted mobile entry resources', () => { + it('serves the mobile guide and manifest without accessing D1 or authentication', async () => { + const guide = await request('/mobile'); + expect(guide.status).toBe(200); + expect(guide.headers.get('content-type')).toContain('text/html'); + const html = await guide.text(); + expect(html).toContain('id="copy-mobile-url"'); + expect(html).toContain('id="open-gallery"'); + expect(html).not.toMatch(/serviceWorker|localStorage|sessionStorage/); + const manifestResponse = await request('/manifest.webmanifest'); + expect(manifestResponse.status).toBe(200); + expect(manifestResponse.headers.get('content-type')).toContain('manifest'); + const manifest = await manifestResponse.json() as { start_url: string; scope: string; display: string; icons: {src: string}[] }; + expect(manifest).toMatchObject({ start_url: '/', scope: '/', display: 'standalone' }); + expect(manifest.icons.map(icon => icon.src)).toEqual(expect.arrayContaining(['/icons/icon-192.png', '/icons/icon-512.png'])); + }); + + it('serves real PNG icons at their advertised sizes', async () => { + for (const [path, size] of [['/icons/icon-192.png', 192], ['/icons/icon-512.png', 512], ['/icons/apple-touch-icon.png', 180]] as const) { + const response = await request(path); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + const bytes = new Uint8Array(await response.arrayBuffer()); + expect(Array.from(bytes.slice(0, 8))).toEqual([137,80,78,71,13,10,26,10]); + const dimensions = new DataView(bytes.buffer, bytes.byteOffset); + expect(dimensions.getUint32(16)).toBe(size); + expect(dimensions.getUint32(20)).toBe(size); + } + }); + + it('keeps the canonical-origin restriction on public mobile resources', async () => { + const response = await worker.fetch(new Request('https://other.example.com/mobile'), publicEnv); + expect(response.status).toBe(421); + }); + + it('supports public HEAD requests without response bodies or quota usage', async () => { + for (const path of ['/mobile', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', '/icons/apple-touch-icon.png']) { + const response = await request(path, 'HEAD'); + expect(response.status).toBe(200); + expect(await response.text()).toBe(''); + } + }); +}); diff --git a/test/hosted-ui.test.ts b/test/hosted-ui.test.ts index ea1e283..29cecb4 100644 --- a/test/hosted-ui.test.ts +++ b/test/hosted-ui.test.ts @@ -31,6 +31,14 @@ describe('hosted browser UI security and protocol', () => { expect(html).toContain('if(stamp!==generation)return'); }); + it('keeps everyday gallery and connection controls available', () => { + const html = render(); + for (const id of ['select-items', 'delete-selected', 'cancel-selection', 'gallery-url', 'copy-gallery-url', 'reveal-token', 'copy-token', 'dismiss-token']) { + expect(html).toContain('id="' + id + '"'); + } + expect(html).not.toMatch(/localStorage|sessionStorage/); + }); + it('escapes configuration to prevent script breakout', () => { const html = render(''); expect(html).not.toContain('