Initial scaffold: blob store server, CLI scripts, and PWA share target

Clipboard/screenshot sync across own devices via a single DEVICE_TOKEN-gated
blob store, plus a small installable web app so a phone can push/pull
clips and share screenshots via the OS share sheet.
This commit is contained in:
Fredrik Johansson
2026-08-20 15:28:42 +02:00
commit 4eee44f675
28 changed files with 3132 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Server (docker-compose.yml)
IMAGE=
HOST_PORT=3041
DEVICE_TOKEN=change-me
CLIP_TTL_SECONDS=86400
SHOT_TTL_SECONDS=1209600
MAX_UPLOAD_BYTES=52428800
# CLI scripts (source into ~/.config/scrot/env)
SCROT_SERVER=https://scrot.example.com
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
data/
.env
*.tsbuildinfo
+41
View File
@@ -0,0 +1,41 @@
# ── Stage 1: compile the TypeScript server ────────────────────────────────
FROM node:24-slim AS server-build
# better-sqlite3 has a native addon with no prebuilt binary for this
# platform/Node combo yet — build it from source.
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server/package*.json server/
COPY server/tsconfig.json server/
RUN npm install --prefix server
COPY server/src/ server/src/
RUN npm run build --prefix server
# ── Stage 2: runtime ───────────────────────────────────────────────────────
FROM node:24-slim
WORKDIR /app
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY server/package*.json server/
RUN npm install --prefix server --omit=dev \
&& apt-get purge -y python3 make g++ && apt-get autoremove -y
COPY --from=server-build /app/server/dist server/dist
COPY server/public/ server/public/
# Do NOT copy data/ — mutable runtime volume holding the SQLite DB and blob
# bytes. Baking it into the image would clobber it on every redeploy.
ENV NODE_ENV=production
ENV PORT=3000
ENV DATA_DIR=/app/data
EXPOSE 3000
CMD ["node", "server/dist/index.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 explewd
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 CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+153
View File
@@ -0,0 +1,153 @@
# Proposal: `scrot` — clipboard sync + screenshot sharing, one blob store
**Status:** proposed, being scaffolded. Self-contained — written so a fresh
agent with no prior conversation context can pick this up and implement it
without anything explained first.
## Motivation
Two small personal itches, both solved by the same mechanism:
1. **Clipboard/note sync across your own devices** — copy something on one
machine, paste it on another, without a cloud clipboard manager whose
sync feature might get paywalled or discontinued (Authy killed its
desktop app; plenty of "free" sync tools have gone the same way).
2. **Screenshot → instant public link** — hit a hotkey, get a shareable URL
on the clipboard, without depending on Imgur/CloudApp/ShareX's own
uploader service.
Both are "send a blob to `<server>`, get it back later" — one privately (to
your own other devices), one publicly (a link you hand to someone else).
Building two separate services would just be `wisp` and `flit` again with
extra steps. `scrot` is one blob store with a `visibility` flag, because the
underlying problem is identical and only the retrieval path differs.
## What it isn't
- **Not `wisp`.** wisp is single-retrieval, end-to-end encrypted,
multi-user (anyone with the link decrypts). `scrot` is single-user
(you and only you, across your own devices), multi-retrieval for
clipboard items (poll for "is there something newer than what I have"),
and does not need E2E encryption — the server is already fully trusted
homelab infrastructure, same trust level as `keep`.
- **Not `flit`.** flit is synchronous peer-to-peer (both devices online at
once, WebRTC, zero server storage). `scrot` is deliberately the opposite:
async, server-stored, no P2P handshake — you copy on your phone at lunch
and it's there on your laptop that evening.
- **Not a general file sync tool.** No folder watching, no bidirectional
sync, no conflict resolution. One blob at a time, pushed explicitly, read
explicitly. If this needs Syncthing-shaped features later, that's a sign
it's the wrong tool for the job, not a sign to add them here.
## Core model
Single `blobs` table. Every row has a `kind` (`clip` | `shot`) and a
`visibility` implied by kind:
- **`clip`** — text or small file, private. Pull-only: any of your
authenticated devices can ask "what's the latest clip?" and get it back.
No public URL is ever minted for a `clip` blob.
- **`shot`** — image, public. Upload returns a short unguessable link
(`/s/:id`) that renders/downloads the image, no auth required to view —
same trust model as handing someone a link to a photo, not a secret.
TTL-based cleanup for both (default: clips expire fast, a day or so —
they're meant to be grabbed promptly, not archived; shots live longer,
long enough to actually be shared and viewed — a week or two). This
reuses `wisp`'s sweep-on-interval `cleanup.ts` pattern outright, just with
per-kind TTLs instead of one global one.
**No E2E encryption.** Unlike wisp (built for sharing with other people,
where the server operator shouldn't be able to read the payload), scrot's
server is single-user infrastructure you already trust with everything
else (`keep`, `trace`). Adding client-side crypto here would be defense
against a threat model (yourself, or your own trusted host) that doesn't
apply — skip it, keep the client thin.
## Auth
Single shared `DEVICE_TOKEN` (env var, server refuses to start without it —
same posture as wisp's `UPLOAD_PASSWORD`), sent as `X-Device-Token` on every
request. Not a per-device registry: this is you, across a handful of
machines you personally set up, not a multi-tenant service. If a device
needs revoking, rotate the token and re-provision the (few) devices that
need it — full device-management UI would be over-engineering for a
single-user tool.
## API
- `POST /api/blobs` — body is the raw content (text for `clip`, image
bytes for `shot`), `X-Blob-Kind: clip|shot` header selects the table row
shape. Requires `X-Device-Token`. Returns `{ id, retrieveAt }` — for
`shot`, `retrieveAt` is the public `/s/:id` URL; for `clip`, it's null
(nothing public to hand back).
- `GET /api/clip/latest` — requires `X-Device-Token`. Returns the most
recent non-expired `clip` blob's content plus its `id`/`created_at`, or
`204` if there isn't one. Polling this (or calling it on-demand from a
keybinding) is the whole "pull" side of clipboard sync — no push
notification, no websocket, deliberately simple.
- `GET /s/:id` — public, no auth. Serves the `shot` blob's bytes with the
right `Content-Type`. 404 once expired/swept.
That's the entire API surface. No listing endpoint, no delete endpoint
(TTL handles cleanup), no rename/metadata — matches the family's existing
bias toward the smallest API that solves the actual problem.
## Server implementation
Directly reuses `wisp/server`'s shape: Express + `better-sqlite3`, TS,
WAL-mode SQLite, `data/` volume holding both the DB and blob bytes on disk
(not inline in SQLite — same reasoning as wisp, keeps the DB small and
lets large images stream instead of buffering fully into a BLOB column).
```
server/
src/
config.ts // PORT, DATA_DIR, DEVICE_TOKEN, CLIP_TTL_SECONDS, SHOT_TTL_SECONDS
db.ts // blobs table, blobPath()
ids.ts // reused verbatim from wisp (base62 random ids)
auth.ts // checkDeviceToken(req) — single timing-safe comparison
routes.ts // POST /blobs, GET /clip/latest
public.ts // GET /s/:id (separate from routes.ts: no auth middleware)
cleanup.ts // per-kind TTL sweep, same interval-timer pattern as wisp
index.ts
```
## Client side
No native app. Two thin shell scripts under `cli/`, each a keybinding away
from useless friction, plus a small installable PWA (`server/public/`) for
mobile — see [README.md](./README.md#mobile--pwa) for the share-target /
onboarding design, added once this looked headed for public use and "SSH in
and edit a dotfile" stopped being an acceptable mobile onboarding story:
- `cli/scrot-clip-push` — reads the system clipboard (`wl-paste`/`xclip`
depending on session type), `curl -X POST` with `X-Blob-Kind: clip`.
- `cli/scrot-clip-pull` — `curl GET /api/clip/latest`, writes the result
back into the system clipboard (`wl-copy`/`xclip -selection clipboard`).
- `cli/scrot-shot` — runs the local screenshot capture tool (`grim -g "$(slurp)"`
on Wayland, or `maim -s` on X11 — detect via `$XDG_SESSION_TYPE`), pipes
the PNG straight to `curl -X POST` with `X-Blob-Kind: shot`, and copies
the returned `/s/:id` URL to the clipboard so the very next action is
"paste the link."
Each script is `DEVICE_TOKEN`/`SCROT_SERVER` via environment (sourced from
a `~/.config/scrot/env` file, gitignored, same as every other project's
`.env` convention) — no config file parsing needed beyond that.
## Deploy
Directly mirrors `wisp`'s `Dockerfile`/`docker-compose.yml` — single-stage
server build (no client/ to build in stage 1, since there's no browser UI
at all), named volume for `data/`, env-driven config, later migrated onto
`keep` for secret delivery like every other project on the migration list.
## Open questions (deliberately deferred, not blocking scaffolding)
- Does `clip` need a history (last N items) instead of just "latest"? Start
with latest-only — it's the actual use case (grab what I just copied
elsewhere), and multi-item history is a strictly bigger feature to add
later if it turns out to matter.
- Should `shot` support a "burn after first view" mode like wisp's drops?
Deferred — the primary use case is "share a link with someone," which
wants repeat viewing, not single-retrieval.
+93
View File
@@ -0,0 +1,93 @@
# scrot
Clipboard sync across your own devices, and screenshot → instant public
link. One blob store, one small server, two thin CLI scripts.
## Why
Two small itches, solved by the same mechanism — see
[PROPOSAL.md](./PROPOSAL.md) for the full design rationale, and why this
isn't just `wisp` or `flit` again:
- **Clipboard sync**: copy on one machine, pull it on another, without a
cloud clipboard manager that might get paywalled or discontinued.
- **Screenshot → link**: capture a region, get a shareable URL on your
clipboard immediately, without an Imgur/CloudApp-style third-party
uploader.
## Model
A single `blobs` table with a `kind`:
- `clip` — private, text. Pull-only — your other devices ask "what's the
latest clip?" No public URL is ever minted.
- `shot` — public, image. Upload returns a short link (`/s/:id`) anyone
with the link can view — no auth required, same trust level as handing
someone a photo link.
Single shared `DEVICE_TOKEN` gates every write and the private clip-read —
this is single-user infrastructure across your own machines, not a
multi-tenant service, so there's no per-device registry to manage.
No end-to-end encryption: unlike `wisp` (built for sharing with other
people), this server is already fully trusted homelab infra, same trust
level as `keep`/`trace`. Adding client-side crypto here would defend
against a threat that doesn't apply.
## Server
```bash
cd server
npm install
DEVICE_TOKEN=... npm run dev
```
See [.env.example](./.env.example) for the full config surface
(`CLIP_TTL_SECONDS`, `SHOT_TTL_SECONDS`, `MAX_UPLOAD_BYTES`, ...).
## CLI
Four scripts in [cli/](./cli/), each meant to sit behind a keybinding:
- `scrot-clip-push` — pushes the current system clipboard as a `clip`.
- `scrot-clip-pull` — fetches the latest `clip` into the system clipboard.
- `scrot-shot` — captures a region screenshot, uploads it as a `shot`,
copies the resulting public link to the clipboard.
- `scrot-onboard` — prints (and, with `qrencode` installed, renders as a
terminal QR code) the link that onboards a phone onto the PWA below.
Each needs `SCROT_SERVER` and `DEVICE_TOKEN` in the environment — source
them from `~/.config/scrot/env` (gitignored, not tracked here). Wayland
(`wl-paste`/`wl-copy`/`grim`+`slurp`) and X11 (`xclip`/`maim`) are both
supported, auto-detected via `$WAYLAND_DISPLAY`.
## Mobile / PWA
The server also serves a small installable web app (`server/public/`) —
paste-or-share text as a `clip`, upload/share an image as a `shot`, and pull
the latest clip back down. No separate mobile client.
Onboarding a phone onto the shared `DEVICE_TOKEN`: run `scrot-onboard` and
open the printed link (or scan the QR) on the phone. That saves the token
into the page's `localStorage` and mirrors it into a cookie. Add the app to
the home screen and reopen it once — that re-fetches `/manifest.webmanifest`
with the token now baked into its `share_target.action` URL (see
[server/src/manifest.ts](./server/src/manifest.ts)), which is what makes
"Share to scrot" show up in the OS share sheet for photos and selected text.
No service worker involved: the share sheet POSTs straight to `/share`
([server/src/share.ts](./server/src/share.ts)), which checks the token in
the query string (custom headers aren't available to a share-target POST)
and inserts the blob through the same path as `POST /api/blobs`.
## Status
Scaffolded: server (routes, auth, TTL cleanup, SQLite, PWA share target) and
CLI scripts written, not yet run against a real deployment or wired into
`keep`. Next: `npm install` + smoke test locally, then a first deploy.
## Deploy
Mirrors the rest of the project family — `Dockerfile` + `docker-compose.yml`,
named volume for `data/`, env-driven config. Not yet migrated onto `keep`
for secret delivery (tracked in goonk's `FUTURE.md` ops migration list
alongside every other project still on hand-copied `.env`).
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Fetches the latest `clip` blob from scrot and writes it into the local
# system clipboard.
set -euo pipefail
: "${SCROT_SERVER:?set SCROT_SERVER, e.g. https://scrot.example.com}"
: "${DEVICE_TOKEN:?set DEVICE_TOKEN}"
response=$(curl -sf -w '\n%{http_code}' "$SCROT_SERVER/api/clip/latest" \
-H "X-Device-Token: $DEVICE_TOKEN")
status="${response##*$'\n'}"
body="${response%$'\n'*}"
if [ "$status" = "204" ]; then
echo "no clip yet" >&2
exit 1
fi
if [ -n "${WAYLAND_DISPLAY:-}" ]; then
printf '%s' "$body" | wl-copy
else
printf '%s' "$body" | xclip -selection clipboard
fi
echo "clip pulled into clipboard"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Reads the system clipboard and pushes it to scrot as a `clip` blob.
set -euo pipefail
: "${SCROT_SERVER:?set SCROT_SERVER, e.g. https://scrot.example.com}"
: "${DEVICE_TOKEN:?set DEVICE_TOKEN}"
if [ -n "${WAYLAND_DISPLAY:-}" ]; then
content=$(wl-paste --no-newline)
else
content=$(xclip -selection clipboard -o)
fi
curl -sf -X POST "$SCROT_SERVER/api/blobs" \
-H "X-Device-Token: $DEVICE_TOKEN" \
-H "X-Blob-Kind: clip" \
-H "Content-Type: text/plain; charset=utf-8" \
--data-binary "$content" \
-o /dev/null
echo "clip pushed"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Prints (and, if qrencode is installed, renders as a terminal QR code) the
# onboarding link for the scrot PWA: opening it on a phone saves the device
# token (via app.js's #t= handler) and, once the app is added to the home
# screen, enables "Share to scrot" from the OS share sheet.
set -euo pipefail
: "${SCROT_SERVER:?set SCROT_SERVER, e.g. https://scrot.example.com}"
: "${DEVICE_TOKEN:?set DEVICE_TOKEN}"
url="$SCROT_SERVER/#t=$DEVICE_TOKEN"
echo "$url"
echo
if command -v qrencode >/dev/null 2>&1; then
qrencode -t ANSIUTF8 "$url"
else
echo "(install qrencode to render this as a scannable QR code in-terminal)"
fi
cat <<'EOF'
On the phone:
1. Open the link/QR above — saves the device token, this device is now onboarded.
2. Add scrot to the home screen (share/menu -> "Add to Home Screen").
3. Reopen from the home screen icon once — this re-fetches the manifest
with the token baked into the share target, enabling "Share to scrot"
from the OS share sheet (photos, browser selection, etc).
EOF
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Captures a region screenshot, pushes it to scrot as a `shot` blob, and
# copies the returned public link to the clipboard.
set -euo pipefail
: "${SCROT_SERVER:?set SCROT_SERVER, e.g. https://scrot.example.com}"
: "${DEVICE_TOKEN:?set DEVICE_TOKEN}"
tmpfile=$(mktemp --suffix=.png)
trap 'rm -f "$tmpfile"' EXIT
if [ -n "${WAYLAND_DISPLAY:-}" ]; then
grim -g "$(slurp)" "$tmpfile"
else
maim -s "$tmpfile"
fi
response=$(curl -sf -X POST "$SCROT_SERVER/api/blobs" \
-H "X-Device-Token: $DEVICE_TOKEN" \
-H "X-Blob-Kind: shot" \
-H "Content-Type: image/png" \
--data-binary "@$tmpfile")
url=$(printf '%s' "$response" | grep -o '"url":"[^"]*"' | cut -d'"' -f4)
full_url="$SCROT_SERVER$url"
if [ -n "${WAYLAND_DISPLAY:-}" ]; then
printf '%s' "$full_url" | wl-copy
else
printf '%s' "$full_url" | xclip -selection clipboard
fi
echo "screenshot uploaded: $full_url"
+20
View File
@@ -0,0 +1,20 @@
services:
app:
image: ${IMAGE}
container_name: scrot
restart: unless-stopped
pull_policy: always
ports:
- "${HOST_PORT:-3041}:3000"
environment:
- CLIP_TTL_SECONDS=${CLIP_TTL_SECONDS:-86400}
- SHOT_TTL_SECONDS=${SHOT_TTL_SECONDS:-1209600}
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-52428800}
- DEVICE_TOKEN=${DEVICE_TOKEN}
volumes:
# Named volume, not a bind mount into the build context — the SQLite
# DB and blob bytes live here and must survive image redeploys.
- scrot-data:/app/data
volumes:
scrot-data:
+2008
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "scrot-server",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "tsx watch --env-file=../.env src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node --env-file=../.env dist/index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.21.0",
"multer": "^2.2.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/multer": "^2.2.0",
"@types/node": "^22.7.4",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}
+155
View File
@@ -0,0 +1,155 @@
// scrot PWA — thin client mirroring cli/scrot-clip-push, scrot-clip-pull,
// scrot-shot. Token lives in localStorage (used for every fetch here) and
// mirrored into a cookie (read server-side by /manifest.webmanifest to bake
// the token into the share_target action — see server/src/manifest.ts).
const TOKEN_KEY = 'scrot_token'
const $ = (id) => document.getElementById(id)
const toastEl = $('toast')
const onboardEl = $('onboard')
const tokenInput = $('tokenInput')
function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
function setToken(token) {
localStorage.setItem(TOKEN_KEY, token)
// 1 year, Lax is enough since this cookie is only ever read by our own
// manifest route, never sent cross-site meaningfully.
document.cookie = `scrot_token=${encodeURIComponent(token)}; path=/; max-age=31536000; samesite=lax`
}
function toast(message) {
toastEl.textContent = message
toastEl.style.display = 'block'
clearTimeout(toast._t)
toast._t = setTimeout(() => { toastEl.style.display = 'none' }, 4000)
}
function refreshOnboardVisibility() {
const has = !!getToken()
onboardEl.style.display = has ? 'none' : 'block'
if (has) tokenInput.value = getToken()
}
// --- onboarding: pick up #t=<token> from cli/scrot-onboard's QR/link ---
function consumeHashToken() {
const match = location.hash.match(/(?:^#|&)t=([^&]+)/)
if (!match) return false
const token = decodeURIComponent(match[1])
setToken(token)
history.replaceState(null, '', location.pathname + location.search)
toast('Device token saved. Add scrot to your home screen to enable "Share to scrot".')
return true
}
const hadHashToken = consumeHashToken()
if (!hadHashToken && getToken()) setToken(getToken()) // re-assert cookie in case it expired
refreshOnboardVisibility()
$('saveToken').addEventListener('click', () => {
const token = tokenInput.value.trim()
if (!token) return
setToken(token)
refreshOnboardVisibility()
toast('Device token saved.')
})
$('editToken').addEventListener('click', () => {
onboardEl.style.display = 'block'
tokenInput.focus()
})
// --- share-target redirect landing ---
const params = new URLSearchParams(location.search)
if (params.get('shared') === 'clip') toast('Shared text saved as a clip.')
if (params.get('shared') === 'shot') toast('Shared screenshot uploaded.')
if (params.get('shared') === 'empty') toast('Nothing shareable in that share (no text or image).')
if (params.has('shared')) history.replaceState(null, '', location.pathname)
// --- push clip ---
$('pasteFromClipboard').addEventListener('click', async () => {
try {
$('clipText').value = await navigator.clipboard.readText()
} catch {
toast('Clipboard read not available — paste manually instead.')
}
})
$('pushClip').addEventListener('click', async () => {
const text = $('clipText').value
if (!text) return
const result = $('clipResult')
result.textContent = 'Sending…'
try {
const res = await fetch('/api/blobs', {
method: 'POST',
headers: { 'X-Device-Token': getToken(), 'X-Blob-Kind': 'clip', 'Content-Type': 'text/plain; charset=utf-8' },
body: text,
})
if (!res.ok) throw new Error(await res.text())
result.textContent = 'Sent.'
} catch (err) {
result.textContent = `Failed: ${err.message || err}`
}
})
// --- pull clip ---
let lastPulled = ''
$('pullClip').addEventListener('click', async () => {
const result = $('pullResult')
result.textContent = 'Fetching…'
try {
const res = await fetch('/api/clip/latest', { headers: { 'X-Device-Token': getToken() } })
if (res.status === 204) {
result.textContent = 'No clip yet.'
$('copyPulled').disabled = true
return
}
if (!res.ok) throw new Error(await res.text())
lastPulled = await res.text()
result.textContent = lastPulled
$('copyPulled').disabled = false
} catch (err) {
result.textContent = `Failed: ${err.message || err}`
}
})
$('copyPulled').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(lastPulled)
toast('Copied.')
} catch {
toast('Clipboard write not available.')
}
})
// --- push shot ---
$('pushShot').addEventListener('click', async () => {
const file = $('shotFile').files[0]
const result = $('shotResult')
if (!file) {
result.textContent = 'Pick an image first.'
return
}
result.textContent = 'Uploading…'
try {
const res = await fetch('/api/blobs', {
method: 'POST',
headers: { 'X-Device-Token': getToken(), 'X-Blob-Kind': 'shot', 'Content-Type': file.type || 'application/octet-stream' },
body: file,
})
if (!res.ok) throw new Error(await res.text())
const { url } = await res.json()
const fullUrl = new URL(url, location.origin).toString()
result.innerHTML = `<a href="${fullUrl}" target="_blank" rel="noopener">${fullUrl}</a>`
try {
await navigator.clipboard.writeText(fullUrl)
toast('Link copied to clipboard.')
} catch { /* clipboard write may be unavailable; link is still shown above */ }
} catch (err) {
result.textContent = `Failed: ${err.message || err}`
}
})
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" rx="40" fill="#111318"/>
<text x="96" y="128" font-family="system-ui, sans-serif" font-size="104" font-weight="700"
fill="#6ee7b7" text-anchor="middle">s</text>
</svg>

After

Width:  |  Height:  |  Size: 274 B

+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>scrot</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.svg">
<meta name="theme-color" content="#111318">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 1.25rem; padding-bottom: 3rem;
background: #111318; color: #e6e8ec;
font: 16px/1.4 system-ui, sans-serif;
max-width: 32rem; margin-inline: auto;
}
h1 { font-size: 1.1rem; font-weight: 600; color: #9aa1ad; margin: 0 0 1.25rem; }
section {
background: #191c22; border: 1px solid #262b34; border-radius: 12px;
padding: 1rem; margin-bottom: 1rem;
}
section h2 { font-size: 0.95rem; margin: 0 0 0.75rem; }
textarea {
width: 100%; min-height: 6rem; resize: vertical;
background: #0d0f13; color: #e6e8ec; border: 1px solid #2c323d;
border-radius: 8px; padding: 0.6rem; font: inherit;
}
input[type="text"], input[type="password"] {
width: 100%; background: #0d0f13; color: #e6e8ec;
border: 1px solid #2c323d; border-radius: 8px; padding: 0.6rem; font: inherit;
}
button {
appearance: none; border: none; border-radius: 8px;
background: #2c7a5f; color: #eafff5; font: inherit; font-weight: 600;
padding: 0.6rem 1rem; margin-top: 0.6rem; margin-right: 0.5rem;
cursor: pointer;
}
button.secondary { background: #262b34; color: #cfd4dc; }
button:disabled { opacity: 0.5; cursor: default; }
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
#clipResult, #shotResult, #toast { font-size: 0.9rem; color: #9aa1ad; margin-top: 0.6rem; word-break: break-all; }
#shotResult a { color: #6ee7b7; }
#toast { display: none; padding: 0.6rem; background: #1c3a2c; border-radius: 8px; color: #b9f5da; margin-bottom: 1rem; }
#onboard { display: none; }
label { display: block; font-size: 0.85rem; color: #9aa1ad; margin-bottom: 0.3rem; }
</style>
</head>
<body>
<h1>scrot</h1>
<div id="toast"></div>
<section id="onboard">
<h2>Device token</h2>
<label for="tokenInput">Paste the token from <code>~/.config/scrot/env</code>, or open this page's link/QR from <code>cli/scrot-onboard</code>.</label>
<input id="tokenInput" type="password" autocomplete="off" placeholder="device token">
<div class="row">
<button id="saveToken">Save</button>
</div>
</section>
<section>
<h2>Push clipboard</h2>
<textarea id="clipText" placeholder="Paste or type text to sync to your other devices"></textarea>
<div class="row">
<button id="pasteFromClipboard" class="secondary">Paste from clipboard</button>
<button id="pushClip">Send</button>
</div>
<div id="clipResult"></div>
</section>
<section>
<h2>Pull latest clip</h2>
<div class="row">
<button id="pullClip">Fetch latest</button>
<button id="copyPulled" class="secondary" disabled>Copy to clipboard</button>
</div>
<div id="pullResult"></div>
</section>
<section>
<h2>Share screenshot</h2>
<input id="shotFile" type="file" accept="image/*" capture="environment">
<div class="row">
<button id="pushShot">Upload</button>
</div>
<div id="shotResult"></div>
</section>
<section>
<h2>Settings</h2>
<div class="row">
<button id="editToken" class="secondary">Change device token</button>
</div>
</section>
<script src="/app.js"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import { timingSafeEqual } from 'node:crypto'
import type { Request } from 'express'
import { config } from './config.js'
export function timingSafeStringEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB)
}
export function checkDeviceToken(req: Request): boolean {
const token = req.get('X-Device-Token')
if (token === undefined) return false
return timingSafeStringEqual(token, config.deviceToken)
}
+18
View File
@@ -0,0 +1,18 @@
import { config } from './config.js'
import { db } from './db.js'
// Shared by both upload paths: the raw-body POST /api/blobs (CLI, curl) and
// the multipart POST /share (PWA share target) — same row shape either way.
export function insertBlobRecord(
id: string,
kind: 'clip' | 'shot',
contentType: string,
sizeBytes: number,
): void {
const now = Math.floor(Date.now() / 1000)
const ttl = kind === 'clip' ? config.clipTtlSeconds : config.shotTtlSeconds
db.prepare(
`INSERT INTO blobs (id, kind, content_type, size_bytes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, kind, contentType, sizeBytes, now, now + ttl)
}
+24
View File
@@ -0,0 +1,24 @@
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
// Sweeps expired blobs of either kind. Unlike wisp's single-retrieval
// drops (deleted immediately on confirmed download), scrot blobs are
// multi-retrieval — clips get polled repeatedly, shots get viewed
// repeatedly — so TTL expiry is the only deletion trigger there is.
export function sweepExpiredBlobs(): void {
const now = Math.floor(Date.now() / 1000)
const expired = db.prepare(`SELECT id FROM blobs WHERE expires_at < ?`).all(now) as {
id: string
}[]
for (const { id } of expired) {
fs.rm(blobPath(id), { force: true }, () => {})
}
db.prepare(`DELETE FROM blobs WHERE expires_at < ?`).run(now)
}
export function startCleanupJob(): NodeJS.Timeout {
return setInterval(sweepExpiredBlobs, config.cleanupIntervalSeconds * 1000)
}
+18
View File
@@ -0,0 +1,18 @@
import path from 'node:path'
if (!process.env.DEVICE_TOKEN) {
console.error('DEVICE_TOKEN is not set — refusing to start with an open blob store.')
process.exit(1)
}
export const config = {
port: Number(process.env.PORT ?? 3000),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
deviceToken: process.env.DEVICE_TOKEN,
// Clips are meant to be grabbed promptly, not archived — short TTL.
clipTtlSeconds: Number(process.env.CLIP_TTL_SECONDS ?? 60 * 60 * 24), // 1 day
// Shots are meant to actually be shared and viewed — longer TTL.
shotTtlSeconds: Number(process.env.SHOT_TTL_SECONDS ?? 60 * 60 * 24 * 14), // 14 days
maxUploadBytes: Number(process.env.MAX_UPLOAD_BYTES ?? 50 * 1024 * 1024), // 50MB
cleanupIntervalSeconds: Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 60 * 15),
}
+29
View File
@@ -0,0 +1,29 @@
import Database from 'better-sqlite3'
import fs from 'node:fs'
import path from 'node:path'
import { config } from './config.js'
fs.mkdirSync(config.dataDir, { recursive: true })
fs.mkdirSync(path.join(config.dataDir, 'blobs'), { recursive: true })
export const db = new Database(path.join(config.dataDir, 'scrot.db'))
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS blobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('clip', 'shot')),
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- Fetching "the latest clip" is the hot path for clipboard pull — index
-- the (kind, created_at) pair it actually filters and sorts on.
CREATE INDEX IF NOT EXISTS idx_blobs_kind_created ON blobs (kind, created_at DESC);
`)
export function blobPath(id: string): string {
return path.join(config.dataDir, 'blobs', id)
}
+23
View File
@@ -0,0 +1,23 @@
import { randomBytes } from 'node:crypto'
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
// 128-bit random value, rendered as base62 — unguessable, safe to use as a
// public URL segment for `shot` blobs.
function randomBase62(bits: number): string {
const bytes = randomBytes(Math.ceil(bits / 8) + 4) // headroom for the mod-bias trim below
let value = 0n
for (const b of bytes) value = (value << 8n) | BigInt(b)
let out = ''
const base = BigInt(BASE62.length)
while (value > 0n) {
out = BASE62[Number(value % base)] + out
value /= base
}
return out.padStart(Math.ceil(bits / Math.log2(62)), '0')
}
export function newBlobId(): string {
return randomBase62(128)
}
+26
View File
@@ -0,0 +1,26 @@
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from './config.js'
import { router } from './routes.js'
import { publicRouter } from './public.js'
import { manifestRouter } from './manifest.js'
import { shareRouter } from './share.js'
import { startCleanupJob, sweepExpiredBlobs } from './cleanup.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
app.use(manifestRouter)
app.use(shareRouter)
app.use('/api', router)
app.use(publicRouter)
app.use(express.static(path.join(__dirname, '../public')))
sweepExpiredBlobs()
startCleanupJob()
app.listen(config.port, () => {
console.log(`scrot server listening on :${config.port}`)
})
+55
View File
@@ -0,0 +1,55 @@
import { Router } from 'express'
import type { Request } from 'express'
export const manifestRouter = Router()
// No cookie-parser dependency for one cookie — a few lines of parsing beats
// a new package.
function getCookie(req: Request, name: string): string | undefined {
const header = req.get('Cookie')
if (!header) return undefined
for (const part of header.split(';')) {
const eq = part.indexOf('=')
if (eq === -1) continue
if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim())
}
return undefined
}
// Served dynamically (not a static file) so the share_target action can
// carry this device's token — baked in at manifest-fetch time, once the
// onboarding cookie is set. Until then the manifest omits share_target
// entirely: "Add to Home Screen" still works, "Share to scrot" doesn't
// appear until the app has actually been onboarded.
manifestRouter.get('/manifest.webmanifest', (req, res) => {
const token = getCookie(req, 'scrot_token')
const manifest: Record<string, unknown> = {
name: 'scrot',
short_name: 'scrot',
description: 'Push clipboard text and screenshots to your other devices.',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#111318',
theme_color: '#111318',
icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }],
}
if (token) {
manifest.share_target = {
action: `/share?t=${encodeURIComponent(token)}`,
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'url',
files: [{ name: 'images', accept: ['image/*'] }],
},
}
}
res.set('Content-Type', 'application/manifest+json')
res.json(manifest)
})
+23
View File
@@ -0,0 +1,23 @@
import { Router } from 'express'
import fs from 'node:fs'
import { db, blobPath } from './db.js'
// Separate router from routes.ts: /s/:id is deliberately unauthenticated —
// a "shot" blob's whole point is being a link you hand to someone else who
// doesn't have (and shouldn't need) a device token.
export const publicRouter = Router()
publicRouter.get('/s/:id', (req, res) => {
const now = Math.floor(Date.now() / 1000)
const row = db
.prepare(`SELECT content_type FROM blobs WHERE id = ? AND kind = 'shot' AND expires_at > ?`)
.get(req.params.id, now) as { content_type: string } | undefined
if (!row) {
res.status(404).send('not found or expired')
return
}
res.set('Content-Type', row.content_type)
fs.createReadStream(blobPath(req.params.id)).pipe(res)
})
+89
View File
@@ -0,0 +1,89 @@
import { Router } from 'express'
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
import { newBlobId } from './ids.js'
import { checkDeviceToken } from './auth.js'
import { insertBlobRecord } from './blobs.js'
export const router = Router()
interface BlobRow {
id: string
kind: 'clip' | 'shot'
content_type: string
size_bytes: number
created_at: number
expires_at: number
}
router.post('/blobs', (req, res) => {
if (!checkDeviceToken(req)) {
res.status(401).json({ error: 'bad device token' })
return
}
const kind = req.get('X-Blob-Kind')
if (kind !== 'clip' && kind !== 'shot') {
res.status(400).json({ error: 'X-Blob-Kind must be "clip" or "shot"' })
return
}
const contentLength = Number(req.get('Content-Length') ?? 0)
if (contentLength > config.maxUploadBytes) {
res.status(413).json({ error: 'blob too large' })
return
}
const id = newBlobId()
const dest = blobPath(id)
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
let bytesReceived = 0
req.on('data', (chunk: Buffer) => {
bytesReceived += chunk.length
if (bytesReceived > config.maxUploadBytes) {
req.destroy()
writeStream.destroy()
fs.rm(dest, { force: true }, () => {})
}
})
req.pipe(writeStream)
writeStream.on('finish', () => {
insertBlobRecord(id, kind, req.get('Content-Type') ?? 'application/octet-stream', bytesReceived)
res.json({ id, url: kind === 'shot' ? `/s/${id}` : null })
})
writeStream.on('error', () => {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(500).json({ error: 'write failed' })
})
})
// Latest non-expired clip, for the pull side of clipboard sync — no
// history, no listing, just "what's the newest thing I copied elsewhere."
router.get('/clip/latest', (req, res) => {
if (!checkDeviceToken(req)) {
res.status(401).json({ error: 'bad device token' })
return
}
const now = Math.floor(Date.now() / 1000)
const row = db
.prepare(
`SELECT * FROM blobs WHERE kind = 'clip' AND expires_at > ? ORDER BY created_at DESC LIMIT 1`,
)
.get(now) as BlobRow | undefined
if (!row) {
res.status(204).end()
return
}
res.set('Content-Type', row.content_type)
res.set('X-Blob-Id', row.id)
res.set('X-Blob-Created-At', String(row.created_at))
fs.createReadStream(blobPath(row.id)).pipe(res)
})
+49
View File
@@ -0,0 +1,49 @@
import { Router } from 'express'
import fs from 'node:fs'
import multer from 'multer'
import { config } from './config.js'
import { blobPath } from './db.js'
import { newBlobId } from './ids.js'
import { insertBlobRecord } from './blobs.js'
import { timingSafeStringEqual } from './auth.js'
// Separate from routes.ts: the OS share sheet POSTs here directly (no
// custom headers possible), so auth rides in the query string instead of
// X-Device-Token — see manifest.ts for where that URL gets the token baked
// in. Same trust level as the token in ~/.config/scrot/env: not a secret
// from the machine it's on, just from the network.
export const shareRouter = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.maxUploadBytes } })
shareRouter.post('/share', upload.any(), (req, res) => {
const token = typeof req.query.t === 'string' ? req.query.t : ''
if (!timingSafeStringEqual(token, config.deviceToken)) {
res.status(401).send('bad or missing device token — re-add scrot to your home screen to re-onboard')
return
}
const files = (req.files as Express.Multer.File[] | undefined) ?? []
const image = files.find((f) => f.mimetype.startsWith('image/'))
if (image) {
const id = newBlobId()
fs.writeFileSync(blobPath(id), image.buffer)
insertBlobRecord(id, 'shot', image.mimetype, image.buffer.length)
res.redirect(303, `/?shared=shot&id=${id}`)
return
}
const body = req.body as Record<string, string>
const text = (body.text || body.url || body.title || '').trim()
if (text) {
const id = newBlobId()
const buf = Buffer.from(text, 'utf8')
fs.writeFileSync(blobPath(id), buf)
insertBlobRecord(id, 'clip', 'text/plain; charset=utf-8', buf.length)
res.redirect(303, '/?shared=clip')
return
}
res.redirect(303, '/?shared=empty')
})
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}