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:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
+41
@@ -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"]
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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`).
|
||||||
Executable
+26
@@ -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"
|
||||||
Executable
+21
@@ -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"
|
||||||
Executable
+30
@@ -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
@@ -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"
|
||||||
@@ -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:
|
||||||
Generated
+2008
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user