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
+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"]
}