Cloudsteading
shotsync gallery

ShotSync

Share clipboard text and images across your own devices

ShotSync is a self-hosted Pushbullet alternative built on Cloudflare (R2, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: MIT

License TL;DR

You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.

Explain MIT in plain English →

Summary of the main license. Separate packages and assets can have different terms.

Inspect repository ↗Read this project’s actual license ↗

Repository owner

@Defiabell

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The personal self-hosted Worker/R2 path can fit small free allowances. It does not require the hosted beta Supabase/auth subscription. Stored images, share links and retention count against R2 storage/request quotas; configure lifecycle deletion yourself.

Hosting requirements
  • All holders of the shared token can access/manage the pool; this is a trusted-group installation rather than per-user isolation.
  • Configure a strong AUTH_TOKEN and the documented R2 lifecycle rule; thirty-day retention is not guaranteed without that bucket rule.
  • Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested.
Check current pricing ↗
Sources checked 01/10/2026

Repository snapshot: 5165cd5. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • pushbullet ↗

    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,

  • workers ↗

    name = "shotsync" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] [[r2_buckets]] binding = "BUCKET" bucket_name = "shotsync" # Inject AUTH_TOKEN via: wrangler secret put AUTH_TOKEN — do not hardcode here # Public read-only demo pool: deploy with `wrangler deploy --env demo`. # Needs its own bucket + token: see scripts/d

  • r2 ↗

    name = "shotsync" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] [[r2_buckets]] binding = "BUCKET" bucket_name = "shotsync" # Inject AUTH_TOKEN via: wrangler secret put AUTH_TOKEN — do not hardcode here # Public read-only demo pool: deploy with `wrangler deploy --env demo`. # Needs its own bucket + token: see scripts/deploy-demo.sh. [env.demo] name = "shotsync-demo" [env.demo.vars] DEMO_MODE = "1" [[env.demo.r2_buckets]] binding = "BUCKET"

  • free-tier-eligible ↗

    name = "shotsync" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] [[r2_buckets]] binding = "BUCKET" bucket_name = "shotsync" # Inject AUTH_TOKEN via: wrangler secret put AUTH_TOKEN — do not hardcode here # Public read-only demo pool: deploy with `wrangler deploy --env demo`. # Needs its own bucket + token: see scripts/deploy-demo.sh. [env.demo] name = "shotsync-demo" [env.demo.vars] DEMO_MODE = "1" [[env.demo.r2_buckets]] binding = "BUCKET" bucket_name = "shotsync-demo"

  • free-tier-eligible ↗

    | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation |

  • free-tier-eligible ↗

    | Storage | 10 GB-month / month |

  • free-tier-eligible ↗

    | Class A Operations | 1 million requests / month |

  • free-tier-eligible ↗

    | Class B Operations | 10 million requests / month |

  • MIT ↗

    MIT License Copyright (c) 2026 Jinkun Sun Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTI

  • architecture ↗

    name = "shotsync" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] [[r2_buckets]] binding = "BUCKET" bucket_name = "shotsync" # Inject AUTH_TOKEN via: wrangler secret put AUTH_TOKEN — do not hardcode here # Public read-only demo pool: deploy with `wrangler deploy --env demo`. # Needs its own bucket + token: see scripts/deploy-demo.sh. [env.demo] name = "shotsync-demo" [env.demo.vars] DEMO_MODE = "1" [[env.demo.r2_buckets]] binding = "BUCKET" bucket_name = "shotsync-demo"

  • architecture ↗

    name = "shotsync" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] [[r2_buckets]] binding = "BUCKET" bucket_name = "shotsync" # Inject AUTH_TOKEN via: wrangler secret put AUTH_TOKEN — do not hardcode here # Public read-only demo pool: deploy with `wrangler deploy --env demo`. # Needs its own bucket + token: see scripts/d

  • architecture ↗

    name = "shotsync" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat"] [[r2_buckets]] binding = "BUCKET" bucket_name = "shotsync" # Inject AUTH_TOKEN via: wrangler secret put AUTH_TOKEN — do not hardcode here # Public read-only demo pool: deploy with `wrangler deploy --env demo`. # Needs its own bucket + token: see scripts/deploy-demo.sh. [env.demo] name = "shotsync-demo" [env.demo.vars] DEMO_MODE = "1" [[env.demo.r2_buckets]] binding = "BUCKET"

Upstream screenshot · Defiabell/shotsync repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

external SaaS target
varies
→ R2 + Workers

How it works

The shape of ShotSync on Cloudflare, and how it stacks up against the rented tools it replaces.

Architecture

Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.

View upstream source ↗
Public interface
Configured entry points2
shotsync
wrangler.toml
shotsync-hosted
wrangler.hosted.jsonc
↓
App
shotsync
entry
Cloudflare Workers
Entrypoint: src/index.ts
shotsync-hosted
entry
Cloudflare Workers
Entrypoint: src/hosted/index.tsConfigured cron (UTC): * * * * *
↓

Configuration and workflow sources

Reviewed commit 5165cd51791f. Files were read as data; upstream applications and CI jobs were not executed.

Partial source coverage: 2 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.

Deployment configuration · 2 files
wrangler.toml ↗

Cloudflare Workers · compatibility 2024-09-23

shotsync · default

Entrypoint: src/index.ts

  • BUCKET → R2

shotsync-demo · env.demo

Inherited from default: main, compatibility_date, compatibility_flags

Entrypoint: src/index.ts

  • BUCKET → R2
wrangler.hosted.jsonc ↗

Cloudflare Workers · compatibility 2026-08-22

shotsync-hosted · default

Entrypoint: src/hosted/index.ts

Cron triggers (UTC): * * * * *

  • DB → D1
  • BUCKET → R2

Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.

Runtime source · handlers, binding usage and workflow steps

Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.

src/index.ts ↗
  • L13 · fetch handler exported · references DEMO_MODE · calls includes, err, robotsTXT, sitemapXML, handleUpload, handleList, pathname.startsWith, decodeURIComponent, pathname.slice, handleImage, handleDelete, handleShareCreate, handleSharedItem

Environment references: env.DEMO_MODE

src/hosted/index.ts ↗
  • L34 · fetch handler exported · calls route, error, console.error, headers.set
  • L50 · scheduled handler exported · references DB · calls cleanupFiles, cleanupAccounts
  • L9 · route calls (conditional paths may differ): error, mobileResponse, hostedHTML, request.headers.get, consumeRate, includes, path.startsWith, handleAccounts, handleShared, authenticate, handleFiles

Environment references: env.PUBLIC_ORIGIN · env.DB

src/responses.ts ↗
  • L11 · json calls (conditional paths may differ): JSON.stringify
  • L18 · err calls (conditional paths may differ): json
src/handlers/upload.ts ↗
  • L7 · handleUpload calls (conditional paths may differ): isAuthed, err, request.formData, form.get, toLowerCase, trim, full.type.split, makeId, Date.now, randSuffix, request.headers.get, toISOString, String, env.BUCKET.put, fullKey, full.stream, thumbKey, thumb.stream, json

Environment references: env.BUCKET

src/handlers/list.ts ↗
  • L16 · handleList calls (conditional paths may differ): canRead, err, Math.min, Number, url.searchParams.get, env.BUCKET.list, res.objects.map, idFromFullKey, epochMsFromId, items.filter, i.contentType.startsWith, Promise.all, map, textItems.slice, env.BUCKET.get, slice, obj.text, items.map, json

Environment references: env.BUCKET

src/handlers/image.ts ↗
  • L6 · getFull calls (conditional paths may differ): env.BUCKET.get
  • L14 · handleImage calls (conditional paths may differ): canRead, err, searchParams.get, env.BUCKET.get, thumbKey, getFull

Environment references: env.BUCKET

src/handlers/del.ts ↗
  • L5 · handleDelete calls (conditional paths may differ): isAuthed, err, FULL_EXTS.map, keys.push, thumbKey, env.BUCKET.delete, json

Environment references: env.BUCKET

src/handlers/share.ts ↗
  • L9 · handleShareCreate calls (conditional paths may differ): isAuthed, err, Date.now, signShare, encodeURIComponent, json
  • L20 · handleSharedItem calls (conditional paths may differ): Number, q.get, Date.now, err, verifyShare, getFull

Environment references: env.AUTH_TOKEN

src/hosted/accounts.ts ↗
  • L11 · reply calls (conditional paths may differ): Response.json
  • L14 · fail calls (conditional paths may differ): reply
  • L18 · sessionToken calls (conditional paths may differ): request.headers.get, x.trim, x.startsWith
  • L22 · validPassword calls (conditional paths may differ): encode
  • L23 · normalizeEmail calls (conditional paths may differ): toLowerCase, value.trim, test
  • L31 · limited calls (conditional paths may differ): consumeRate
  • L34 · ipKey calls (conditional paths may differ): tokenHash, request.headers.get
  • L37 · challenge calls (conditional paths may differ): fetch, request.headers.get, response.json
  • L46 · authenticate calls (conditional paths may differ): request.headers.get, test, first, bind, env.DB.prepare, tokenHash, Date.now, publicUser, verifyAccessToken
  • L60 · sessionReply calls (conditional paths may differ): first, bind, env.DB.prepare, fail, reply, publicUser, cookie
  • L67 · reserveRegistration calls (conditional paths may differ): run, bind, db.prepare, Date.now
  • L75 · handleAccounts calls (conditional paths may differ): path.startsWith, path.slice, includes, fail, request.headers.get, authenticate, reply, route.startsWith, all, bind, env.DB.prepare, Date.now, run, route.slice, limited, readJson, body.name.trim, randomToken, crypto.randomUUID, tokenHash
  • L208 · cleanupAccounts calls (conditional paths may differ): Date.now, db.batch, bind, db.prepare

Environment references: env.PUBLIC_ORIGIN · env.TURNSTILE_SECRET_KEY · env.DB · env.REGISTRATION_LIMIT

src/hosted/files.ts ↗
  • L10 · hashToken calls (conditional paths may differ): crypto.subtle.digest, encode, join, Array.from, padStart, n.toString
  • L14 · randomToken calls (conditional paths may differ): join, Array.from, crypto.getRandomValues, padStart, n.toString
  • L15 · quotaError calls (conditional paths may differ): includes, String
  • L19 · retentionDays calls (conditional paths may differ): first, bind, env.DB.prepare
  • L24 · getUsage calls (conditional paths may differ): first, bind, env.DB.prepare, day, retentionDays
  • L29 · removeFile calls (conditional paths may differ): run, bind, env.DB.prepare, env.BUCKET.delete, key
  • L35 · upload calls (conditional paths may differ): error, consumeRate, request.headers.get, contentType.startsWith, test, Number, crypto.randomUUID, Date.now, retentionDays, day, run, bind, env.DB.prepare, quotaError, readBody, formData, form.get, some, form.keys, form.getAll
  • L81 · download calls (conditional paths may differ): searchParams.get, run, bind, env.DB.prepare, crypto.randomUUID, day, Date.now, quotaError, env.BUCKET.get, key, error, String
  • L99 · handleShared calls (conditional paths may differ): error, pathname.slice, test, hashToken, first, bind, env.DB.prepare, Date.now, consumeRate, download
  • L111 · handleFiles calls (conditional paths may differ): upload, json, getUsage, all, bind, env.DB.prepare, Date.now, rows.results.map, path.match, error, first, download, removeFile, run, randomToken, hashToken, Math.min, env.DB.batch
  • L136 · cleanupFiles calls (conditional paths may differ): all, bind, env.DB.prepare, Date.now, removeFile, env.DB.batch, day

Environment references: env.DB · env.BUCKET · env.UPLOADS_ENABLED · env.PUBLIC_ORIGIN

src/hosted/limits.ts ↗
  • L11 · consumeRate calls (conditional paths may differ): Date.now, Math.floor, first, bind, db.prepare
src/hosted/http.ts ↗
  • L4 · json calls (conditional paths may differ): Response.json
  • L7 · error calls (conditional paths may differ): json
  • L8 · readBody calls (conditional paths may differ): request.headers.get, test, Number, request.body.getReader, Date.now, finally, Promise.race, reader.read, setTimeout, reject, Math.max, clearTimeout, chunks.push, catch, reader.cancel, result.set
  • L34 · readJson calls (conditional paths may differ): request.headers.get, JSON.parse, decode, readBody, Array.isArray
src/hosted/ui.ts ↗
  • L5 · hostedHTML calls (conditional paths may differ): replace, JSON.stringify

Environment references: env.TURNSTILE_SITE_KEY

src/hosted/mobile.ts ↗
  • L6 · escapeHTML calls (conditional paths may differ): value.replace
  • L10 · mobileHTML calls (conditional paths may differ): escapeHTML
  • L23 · mobileResponse calls (conditional paths may differ): includes, mobileHTML, JSON.stringify, map, Object.hasOwn, Uint8Array.from, atob, char.charCodeAt

Environment references: env.PUBLIC_ORIGIN

src/auth.ts ↗
  • L3 · constantTimeEqual calls (conditional paths may differ): Math.max, a.charCodeAt, b.charCodeAt
  • L14 · canRead calls (conditional paths may differ): isAuthed
  • L18 · isAuthed calls (conditional paths may differ): request.headers.get, h.startsWith, constantTimeEqual, h.slice

Environment references: env.DEMO_MODE · env.AUTH_TOKEN

src/ids.ts ↗
  • L15 · makeId calls (conditional paths may differ): padStart, toString
  • L20 · epochMsFromId calls (conditional paths may differ): Number, id.slice
  • L33 · idFromFullKey calls (conditional paths may differ): key.slice, name.lastIndexOf, name.slice
  • L39 · randSuffix calls (conditional paths may differ): crypto.getRandomValues, out.push, out.join
src/share.ts ↗
  • L6 · hmacHex calls (conditional paths may differ): crypto.subtle.importKey, enc.encode, crypto.subtle.sign, join, Array.from, padStart, b.toString
  • L19 · signShare calls (conditional paths may differ): hmacHex
  • L23 · verifyShare calls (conditional paths may differ): hmacHex, Math.max, expected.charCodeAt, sig.charCodeAt
src/gallery/settings.ts ↗
  • L9 · maskToken calls (conditional paths may differ): repeat, token.slice
src/hosted/auth-provider.ts ↗
  • L12 · legacyKey calls (conditional paths may differ): key.split, parts.every, test, JSON.parse, atob, replace
  • L19 · configuredProvider calls (conditional paths may differ): test, legacyKey
  • L24 · object calls (conditional paths may differ): Array.isArray
  • L27 · identity calls (conditional paths may differ): object
  • L31 · boundedJson calls (conditional paths may differ): response.body.getReader, reader.read, chunks.push, bytes.set, JSON.parse, decode, catch, reader.cancel
  • L49 · request calls (conditional paths may differ): configuredProvider, Promise.race, path.startsWith, legacyKey, fetch, JSON.stringify, includes, boundedJson, setTimeout, controller.abort, reject, clearTimeout
  • L82 · requireId calls (conditional paths may differ): UUID.test
  • L83 · createPasswordUser calls (conditional paths may differ): requireId, request, identity
  • L99 · verifyAccessToken calls (conditional paths may differ): configuredProvider, jwks.get, createRemoteJWKSet, jwks.set, jwtVerify, UUID.test, object, Number.isSafeInteger, Math.floor, Date.now
  • L119 · session calls (conditional paths may differ): object, test, identity, verifyAccessToken
  • L126 · verifyProviderPassword calls (conditional paths may differ): requireId, request, object, identity, session
  • L135 · refreshProviderSession calls (conditional paths may differ): request, includes, session
  • L141 · revokeProviderSession calls (conditional paths may differ): request, includes
  • L145 · updateProviderPassword calls (conditional paths may differ): requireId, request, identity

Environment references: env.SUPABASE_URL · env.SUPABASE_PUBLISHABLE_KEY · env.SUPABASE_SECRET_KEY · env.PUBLIC_ORIGIN

Build and deployment pipeline · 1 GitHub Actions workflows

Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.

CI · .github/workflows/ci.yml ↗

Triggers: push, pull_request

test · no job dependencies declared

  1. actions/checkout@v4actions/checkout@v4
  2. actions/setup-node@v4actions/setup-node@v4
  3. Shell commandnpm ci
  4. Shell commandnpx tsc --noEmit
  5. Shell commandnpm test
  6. Shell commandnpx playwright install --with-deps chromium
  7. Shell commandnpm run test:browser
package.json ↗
  • deploy: wrangler deploy --config wrangler.toml --env ""
  • deploy:hosted: npm run check:hosted && wrangler d1 migrations apply shotsync-hosted --remote --config wrangler.hosted.jsonc && wrangler deploy --config wrangler.hosted.jsonc

Full upstream document by @Defiabell · README.md · snapshot 5165cd5

English | 简体中文

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 — when self-hosted, your data lives only in your own Cloudflare account.

Use ShotSync online → 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

Product overview & setup guide: https://shotsync-demo.defiabell.workers.dev/about

Choose how to use ShotSync

Self-hosting is the default: no ShotSync account, Supabase, D1 or email service is required. Deploy to your Cloudflare account and enter the same access token on each device.

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 Sign in or create an account 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. See the hosted guide for availability and quotas: the current beta still has a Free-plan CPU limitation and authentication now uses Supabase Auth. Mode details and FAQ.

Start using the hosted service

  1. Open the hosted service, 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. On a phone, open ⚙ Settings → 📱 手机快捷入口设置 at the top of settings and follow the home-screen guide. Tap the ShotSync icon to return later; the first launch may require signing in again.

Common actions

Task Where to go
Send images or text + 图片 / 文字 on the gallery; drag or paste images on desktop
Retrieve or share content Open an item → 取回 (download), 复制文字 (copy text) or 分享 (share)
Delete several items 选择 → select items → 删除选中 → confirm; failed items stay selected for retry
Add a phone shortcut ⚙ Settings → 📱 手机快捷入口设置 at the top; iPhone, Android and Shortcuts instructions
Open on another device Settings → 连接其他设备 → 复制相册地址; open the copied address and sign in to the same account
Connect the Mac app / upload Shortcut Settings → 连接其他设备 → enter a device name and generate a token; configure the client with the gallery address and token
Check quotas and retention Account details in settings; new accounts default to 7 days, with the actual policy shown there

New device tokens are masked by default and can be revealed/copied again in the current page, even after closing settings. Reloading the page or signing out clears them, so save them in a password manager. If an existing token is unavailable, revoke it and generate another. Browser sign-in uses your email and password; it does not require a device token.

On a phone, follow the home-screen shortcut guide 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

What it is

A single Cloudflare Worker + R2 bucket backing a small PWA gallery:

  • Upload images (auto-converted to JPEG + thumbnailed client-side) and text snippets.
  • View a newest-first feed on any device; tap to view full, save/download, or delete.
  • Mint a signed, expiring public link to share one item — without exposing the rest of the pool.
  • Token-gated: one shared secret unlocks the pool; everything else stays private.
  • A 30-day transit pool (auto-deleted), not an archive.

Why

iCloud / AirDrop / network drives / public image hosts are either manual, ecosystem-locked, or route your (possibly work) screenshots through someone else's cloud. shotsync is a self-hosted, free, privacy-respecting take: data only moves between your devices and your own Cloudflare account.

How it compares (LocalSend, PairDrop, messaging yourself)

The dividing line is a live transfer vs. a pool that waits. LocalSend and PairDrop connect two devices that are both awake right now and stream between them. shotsync keeps the item for 30 days, so the sending device can be asleep, on a different network, or in another country by the time you pick it up.

shotsync LocalSend PairDrop
Both devices online at once not required required required
Install none (PWA in the browser) an app on every device none (browser)
Across different networks yes no — same local network via a temporary public room
Where the bytes go your own Cloudflare R2 device to device, no server peer-to-peer, public signalling server
Left behind after transfer 30 days, browsable nothing nothing
Setup deploy once, ~5 min install, then open just open the page
Per-item size limit 25 MB bounded by disk bounded by the connection

Choose LocalSend if both devices are on the same Wi-Fi, both in front of you, and the file is large. It is peer-to-peer, has no practical size ceiling, and needs no internet at all.

Choose PairDrop if you want zero setup and would rather not deploy anything. It is the shortest path from nothing to a transferred file.

Choose shotsync if you keep sending yourself screenshots and want them still there when you sit back down hours later, on a different machine, on a different network — and you would rather they lived in your own Cloudflare account than on a public image host. It replaces the habit of messaging things to yourself, not AirDrop.

The default self-hosted mode has no per-user accounts: one shared token unlocks the whole pool. Use the separate hosted mode if you need isolated accounts. See the "Security model & limitations" section below before deploying.

Features

  • Cross-device image + text pool (a shared clipboard + screenshot drop)
  • PWA gallery — "Add to Home Screen", no native app, no App Store
  • Client-side HEIC→JPEG + thumbnail generation (mobile-friendly; the Worker does no image processing)
  • Signed, expiring public share links (HMAC-SHA256, 7 days)
  • Per-item save/download + multi-select batch delete
  • Single-token auth, constant-time compare, token never in URLs
  • 30-day auto-retention via R2 lifecycle
  • Runs entirely on the Cloudflare free tier (Workers + R2)
  • Tests (Vitest + @cloudflare/vitest-pool-workers)

Deploy your own (~5 min)

Prereqs: a Cloudflare account, Node.js 22+, and R2 enabled (Dashboard → R2 → enable; Cloudflare asks for a card even on the free tier — the free allowance is not charged).

git clone https://github.com/Defiabell/shotsync
cd shotsync
npm install
npx wrangler login

# 1. create the R2 bucket (name must match bucket_name in wrangler.toml)
npx wrangler r2 bucket create shotsync

# 2. set the shared access token — any long random string; you enter it on each device
openssl rand -hex 24                  # generate one, copy it
npx wrangler secret put AUTH_TOKEN --config wrangler.toml --env ""    # paste it when prompted

# 3. deploy
npm run deploy

You also need a workers.dev subdomain (Dashboard → Workers & Pages, one-time) or a custom domain. After deploy you get https://shotsync.<your-subdomain>.workers.dev.

npm run deploy explicitly uses wrangler.toml and deploys only the personal pool. It does not create an account database, request Supabase keys, or deploy the hosted service. AUTH_TOKEN is the ShotSync passphrase you generate, not a Cloudflare API token or Supabase key. Save it and share it only with people allowed to access the entire pool.

After deployment, open the same URL and enter the same token on two devices. Upload a short text snippet and check it appears on the other device. An email registration screen means you opened the hosted service; use the personal URL returned by Wrangler instead.

30-day retention

Dashboard → R2 → bucket shotsync → Settings → Object lifecycle rules → delete objects 30 days after creation.

This rule must be configured explicitly: the Worker does not create it automatically.

Public product page and indexing

/about serves a static product overview without requiring JavaScript or a token. The gallery remains at / and links to it. Only the public demo advertises /about in /sitemap.xml; private deployments send X-Robots-Tag: noindex, follow for their copy. The gallery is marked noindex, follow, and /robots.txt excludes API, image, and share paths from crawling. These indexing hints are not access controls: the normal token and signed-link rules still apply.

Using it

The UI labels are in Chinese; the English in parentheses below maps each step to the button you'll see.

1. First time, on each device

  1. Open your Worker URL (e.g. https://shotsync.<subdomain>.workers.dev).
  2. Enter your AUTH_TOKEN when prompted — it's saved in localStorage, so you won't be asked again on that device.
  3. (Optional) In Safari: Share → Add to Home Screen to install it as a PWA. It then runs full-screen like an app.

The gallery shows every item newest-first and auto-refreshes every ~20 s, so anything uploaded from another device appears within seconds.

2. Add things to the pool

  • Image — tap + 图片 (Add image): pick from photos or camera. It's converted to JPEG and thumbnailed in your browser, then uploaded.
  • Text — tap ✎ 文字 (Text), paste/type a snippet, then 发送 (Send). It becomes a text card — a cross-device clipboard.
  • Mac screenshots, automatically — install the Mac menu-bar app: every screenshot uploads on its own.
  • iOS share sheet — set up the Shortcut to push an image from any app's share sheet.

3. Open one item (tap it)

Tap any thumbnail/card to open it full-screen, then:

  • 保存 / 复制 (Save / Copy) — image: save to Photos (mobile) or download (desktop); text: copy to clipboard.
  • 分享 (Share) — mint a 7-day public link to just that item, copied to your clipboard. Anyone with the link can view that one item; the rest of the pool stays private.
  • 删除 (Delete) — remove this item.
  • 关闭 (Close) — back to the gallery.

4. Delete many at once

  1. Tap 选择 (Select) to enter selection mode.
  2. Tap items to check them (blue outline); tap again to uncheck.
  3. Tap 删除选中 (N) (Delete selected) → confirm. Or 取消 (Cancel) to leave without deleting.

5. See your token, set up another device, or log out

Tap ⚙ in the top bar. The panel shows this pool's URL and the token this device holds — masked by default, because the Mac app auto-uploads screenshots and a plaintext token on screen is one ⌘⇧3 away from landing in the pool.

  • 显示 (Show) toggles the full token; 复制 (Copy) puts it on the clipboard for pasting into another device.
  • 退出登录 (Log out) forgets the token on this device and returns to the token prompt. Nothing changes server-side; the same token still works elsewhere.

Security model & limitations

  • Single shared token. Anyone with the URL and token can view/upload/delete. This is a single-user / trusted-circle tool, not multi-tenant — there are no per-user accounts and no way to "switch" tokens on one pool; a second pool is a second Worker deployment. Rotate with npx wrangler secret put AUTH_TOKEN --config wrangler.toml --env "" — note this also invalidates all live share links, since the token is the link signing key.
  • Share links are public until they expire (7 days): anyone with the link can see that one item.
  • Transit pool, not an archive. Items auto-delete after 30 days by design.
  • The UI is currently in Chinese. i18n PRs welcome.
  • The Worker stores received bytes as-is (no server-side image processing); format conversion and thumbnails happen on the client.

Development

npm test          # Vitest (workers pool) — full suite
npx tsc --noEmit  # type-check
npm run dev       # local dev — create a .dev.vars with AUTH_TOKEN=<anything>

License

MIT

Hosted accounts (public beta)

An optional, separate hosted deployment supports email/password accounts with recovery codes (no email delivery), private file pools, revocable device tokens and strict account/global usage limits. Try the hosted beta (100 accounts). It runs on Workers Free with Supabase Auth. Recent login CPU samples were 27, 9 and 10 ms: the first still exceeded the nominal 10 ms budget, so capacity under load is not established. Deployment prerequisites and limits are in docs/hosted.md. Existing self-hosted and read-only demo deployments keep their current behavior. Tooling now requires Node.js 22+.

Hosted authentication delegates passwords and browser sessions to Supabase Auth. Operators need a dedicated personal project; website registration uses server-side admission checks while public Supabase signup stays disabled. See the migration and failure-recovery notes in the hosted guide.

Frequently asked about ShotSync

What is ShotSync?+

ShotSync is a self-hosted Pushbullet alternative built on the Cloudflare developer platform. Share clipboard text and images across your own devices

What does ShotSync replace?+

ShotSync is listed as an alternative to Pushbullet. Compare the features and tradeoffs before migrating.

What Cloudflare primitives does ShotSync use?+

ShotSync is built on R2, Workers.

How much does ShotSync cost to run?+

The personal self-hosted Worker/R2 path can fit small free allowances. It does not require the hosted beta Supabase/auth subscription. Stored images, share links and retention count against R2 storage/request quotas; configure lifecycle deletion yourself. All holders of the shared token can access/manage the pool; this is a trusted-group installation rather than per-user isolation. Configure a strong AUTH_TOKEN and the documented R2 lifecycle rule; thirty-day retention is not guaranteed without that bucket rule. Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested. Check current Cloudflare pricing before deploying.

Is ShotSync open source?+

The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/Defiabell/shotsync/5165cd51791f1790fbaf2c1b4410805db831d926/LICENSE. Source code and contributor credit are available at https://github.com/Defiabell/shotsync.

Discussion · 0

sign in to comment →
No comments yet — be the first.