From edfce0d325ac7f74341ca3d973803f8b122bc2fd Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Thu, 13 Aug 2026 13:35:23 -0700 Subject: [PATCH] feat(apps): an image host that copies the link on drop The two reference apps both read records somebody else's client wrote. Nothing in examples/ showed the other half: a page that puts bytes into the repo it serves. drop.mjs is that app. Drag, paste or choose an image and it uploads the blob, writes a com.chadtmiller.drop.image record and copies the link, which is the site's own /.blobs/ route. The shared link is the blob, not a page. That route already serves the original bytes immutably under the file's CID, so the link works wherever an image URL does and costs the app no rendering. The consequence is that a crawler asking for a preview card gets the SPA shell instead: per-image Open Graph pages would mean a file entry per upload in the site record, which grows without bound. The single image page carries no account controls. It is the page a link recipient lands on, so Sign in belongs on the index. Paste reads clipboardData.items when files is empty. Chrome and Safari fill files for a pasted screenshot; Firefox describes the image in items only. Co-Authored-By: Claude Opus 5 (1M context) --- examples/apps/README.md | 13 +- examples/apps/drop.mjs | 769 +++++++++++++++++++++++++++++++++++++ examples/apps/package.json | 3 +- 3 files changed, 779 insertions(+), 6 deletions(-) create mode 100644 examples/apps/drop.mjs diff --git a/examples/apps/README.md b/examples/apps/README.md index c8b0730..f17559f 100644 --- a/examples/apps/README.md +++ b/examples/apps/README.md @@ -1,13 +1,14 @@ # Example apps -Two working apps for the site platform. Both discover whose repo they serve -from their own origin, so either one runs on any account that installs it. -[docs/apps.md](../../docs/apps.md) explains how they are put together. +Three working apps for the site platform. Each one discovers whose repo it +serves from its own origin, so any of them runs on any account that installs +it. [docs/apps.md](../../docs/apps.md) explains how they are put together. | App | What it does | |-----|--------------| | `photos.mjs` | Galleries from `social.grain.*` records: justified layouts, a lightbox, terrain headers for located galleries, a map, favourite counts from the network | | `roasts.mjs` | A coffee roast log: a live roast timer, weight-loss and development-time metrics against Sweet Maria's roast card, notes and photos | +| `drop.mjs` | An image host: drag, paste or choose a file and the link copies itself. The link is the site's `/.blobs/` route, so it works anywhere an image URL does | Each script deploys the site to your repo and publishes the manifest that makes it installable: @@ -23,5 +24,7 @@ Then install it from the account page's Sites section, or with `pdsjs-site install at://you.example.com/dev.pdsjs.app.manifest/photos`. `roasts.mjs` writes to `com.chadtmiller.coffee.roast` unless -`ROAST_COLLECTION` names another. An NSID's authority is every segment but the -last, reversed, so use a domain you control. +`ROAST_COLLECTION` names another, and `drop.mjs` writes to +`com.chadtmiller.drop.image` unless `DROP_COLLECTION` names another. An NSID's +authority is every segment but the last, reversed, so use a domain you +control. diff --git a/examples/apps/drop.mjs b/examples/apps/drop.mjs new file mode 100644 index 0000000..a771b4a --- /dev/null +++ b/examples/apps/drop.mjs @@ -0,0 +1,769 @@ +// An image host: drop a file in, get a link to share. Deploy it to your own +// PDS with PDS_URL, PDS_DID and PDS_APP_PASSWORD set; see docs/apps.md. +// +// Images are com.chadtmiller.drop.image records in the owner's repo and the +// bytes are ordinary blobs. The shared link is the site's /.blobs/ +// route, which serves the original bytes immutably. Reads are public through +// dev.pdsjs.query.run; uploads need an app password, which the app exchanges +// for a session against the owner's own PDS. +// +// Usage: PDS_APP_PASSWORD=... node drop.mjs + +const PDS = process.env.PDS_URL; +const DID = process.env.PDS_DID; +if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.'); +const SITE = 'drop-app'; +const SITES = 'dev.pdsjs.site.deploy'; +const DEFS = 'dev.pdsjs.query.def'; +// The collection images are written to. An NSID's authority is every segment +// but the last, reversed, so this names a domain you control. +const IMAGES = process.env.DROP_COLLECTION || 'com.chadtmiller.drop.image'; + +async function xrpc(path, init = {}) { + const response = await fetch(`${PDS}/xrpc/${path}`, init); + if (!response.ok) { + throw new Error( + `${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`, + ); + } + return response.json(); +} + +const password = process.env.PDS_APP_PASSWORD; +if (!password) throw new Error('Set PDS_APP_PASSWORD.'); +const session = await xrpc('com.atproto.server.createSession', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier: DID, password }), +}); +const auth = { Authorization: `Bearer ${session.accessJwt}` }; + +/** @param {string} collection @param {string} rkey @param {Record} record */ +async function put(collection, rkey, record) { + await xrpc('com.atproto.repo.putRecord', { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + repo: DID, + collection, + rkey, + record: { $type: collection, ...record }, + }), + }); + console.error(`put ${collection}/${rkey}`); +} + +// ---- the query the app reads ---- + +await put(DEFS, 'drop-images', { + collection: IMAGES, + sort: { field: 'createdAt', direction: 'desc' }, + createdAt: new Date().toISOString(), +}); + +// ---- the app ---- + +const HTML = ` + + + + +Drop + + + + + +

Loading…

+
+ + + + +`; + +// ---- deploy ---- + +const upload = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'text/html; charset=utf-8' }, + body: HTML, +}); +const blob = (await upload.json()).blob; +await put(SITES, SITE, { + files: [ + { + path: 'index.html', + blob, + contentType: 'text/html; charset=utf-8', + cacheControl: 'public, max-age=300', + }, + ], + fallback: 'index.html', + createdAt: new Date().toISOString(), +}); +console.error( + `Deployed ${SITE}: ${Math.round(HTML.length / 1024)}KB, one file.`, +); + +// ---- publish it as an installable app ---- + +const ICON = ` + + + +`; +const iconUp = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'image/svg+xml' }, + body: ICON, +}); +const icon = (await iconUp.json()).blob; + +const deployed = await xrpc( + `com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`, +); +await put('dev.pdsjs.app.manifest', 'drop', { + name: 'Drop', + description: + 'An image host in your own repo: drag, paste or choose a file and the link copies itself. Images are blobs served immutably from your site, so the link works anywhere an image URL does.', + icon, + site: { uri: deployed.uri, cid: deployed.cid }, + suggestedSite: 'drop', + primitives: ['serve', 'query'], + external: false, + queries: [ + { + name: 'drop-images', + collection: IMAGES, + sort: { field: 'createdAt', direction: 'desc' }, + }, + ], + settings: [ + { + key: 'gallery', + label: 'Show every image to visitors', + type: 'boolean', + default: true, + }, + ], + createdAt: new Date().toISOString(), +}); +console.error('Published dev.pdsjs.app.manifest/drop.'); diff --git a/examples/apps/package.json b/examples/apps/package.json index 4213666..13cde86 100644 --- a/examples/apps/package.json +++ b/examples/apps/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "photos": "node photos.mjs", - "roasts": "node roasts.mjs" + "roasts": "node roasts.mjs", + "drop": "node drop.mjs" } } -- 2.51.2