diff --git a/aesel/src/cli.mjs b/aesel/src/cli.mjs index e74e00c33a..410763e27e 100644 --- a/aesel/src/cli.mjs +++ b/aesel/src/cli.mjs @@ -6,6 +6,7 @@ import { ACSession } from "./ac-session.mjs"; import { planPublish, publishPiece } from "./publish.mjs"; import { handleColorPlan } from "./handle-colors.mjs"; import { SITE } from "./ac-session.mjs"; +import { planMime, postMime } from "./mime.mjs"; const [command = "", ...rest] = process.argv.slice(2); const session = new ACSession(); @@ -52,6 +53,21 @@ try { out(result.route); if (!result.verified) note("published, but the live file did not read back yet"); } + } else if (command === "mime") { + // A file you made, posted to mime.ac as an opening post under your + // @handle; its MIME type picks the board. Anything after the file is the + // caption. Prints the thread's address. + const [file, ...words] = rest.filter((argument) => !argument.startsWith("--")); + if (!file) fail('usage: ac mime ["caption"]'); + const plan = planMime(file, { caption: words.join(" ") }); + if (process.env.AESEL_DRY_RUN === "1") { + out(`would post ${plan.name} (${plan.type}, ${(plan.size / 1024).toFixed(0)} KB) to mime.ac as @${session.handle || "handle"}${plan.caption ? ` · "${plan.caption}"` : ""}`); + } else { + if (!session.handle) fail("sign in first: ac login"); + note(`posting ${plan.name} as @${session.handle}…`); + const posted = await postMime(plan, { session }); + out(posted.url); + } } else if (command === "check") { // Run a piece for real (headless Chrome) and print its errors: a local // file before publishing, or a published @handle/slug or URL after. diff --git a/aesel/src/mime.mjs b/aesel/src/mime.mjs new file mode 100644 index 0000000000..27a7b36a3e --- /dev/null +++ b/aesel/src/mime.mjs @@ -0,0 +1,61 @@ +// mime.mjs — post a file you made to mime.ac, as your @handle. +// +// mime.ac is AC's discussion layer over media (system/backend/MIME.md). Its +// API already takes a native upload as an opening post: `POST /api/mime` with +// `{ parent: null, text, file: { name, type, data } }`, base64 bytes, 8 MiB at +// most. A bearer token makes the post the signed-in account's; the server +// resolves the handle from it, never from anything sent here. The board is +// the file's MIME type. This is the client half, shared by `ac mime` and +// Aesel's media list. +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { SITE } from "./ac-session.mjs"; + +export const MIME_HOME = "https://mime.ac"; +export const MAX_BYTES = 8 * 1024 * 1024; +const MAX_CAPTION = 4000; + +// The type to declare; the server re-checks it and falls back on the extension. +const TYPES = { + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", + mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", mid: "audio/midi", midi: "audio/midi", + mp4: "video/mp4", m4v: "video/mp4", mov: "video/quicktime", webm: "video/webm", + pdf: "application/pdf", zip: "application/zip", json: "application/json", wasm: "application/wasm", + txt: "text/plain", md: "text/markdown", html: "text/html", css: "text/css", + js: "text/javascript", mjs: "text/javascript", lisp: "text/x-lisp", lua: "text/x-lua", + ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2", +}; +export const mimeType = (file) => TYPES[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream"; + +// What would be posted, checked before any bytes leave: a real file, not +// empty, under the limit. Throws with a sentence a person can act on. +export function planMime(file, { cwd = process.cwd(), caption = "" } = {}) { + const absolute = path.resolve(cwd, String(file || "")); + let info; + try { info = statSync(absolute); } catch { throw new Error(`no such file: ${file}`); } + if (!info.isFile()) throw new Error(`not a file: ${file}`); + if (info.size === 0) throw new Error(`${path.basename(absolute)} is empty`); + if (info.size > MAX_BYTES) { + throw new Error(`${path.basename(absolute)} is ${(info.size / 1048576).toFixed(1)} MB; mime.ac takes up to 8 MB (try an mp3 or a smaller export)`); + } + return { path: absolute, name: path.basename(absolute), type: mimeType(absolute), size: info.size, caption: String(caption || "").trim().slice(0, MAX_CAPTION) }; +} + +export const threadUrl = (code) => `${MIME_HOME}/#/t/${code}`; + +// Post it. `session` is an ACSession: its token signs the post, so the +// server credits the account behind it. Returns the thread's code, board +// and address. +export async function postMime(plan, { session, fetchImpl = fetch, site = SITE } = {}) { + if (!session?.signedIn) throw new Error("sign in first: ac login"); + const token = await session.token(); + const data = readFileSync(plan.path).toString("base64"); + const response = await fetchImpl(`${site}/api/mime`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ parent: null, text: plan.caption, file: { name: plan.name, type: plan.type, data } }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || !body.code) throw new Error(body.error || body.message || `mime.ac answered ${response.status}`); + return { code: body.code, board: body.board, url: threadUrl(body.code) }; +} diff --git a/aesel/src/terminal-cli.mjs b/aesel/src/terminal-cli.mjs index 9ce814c4f3..67d4a3c9d4 100644 --- a/aesel/src/terminal-cli.mjs +++ b/aesel/src/terminal-cli.mjs @@ -38,7 +38,7 @@ if(['--help','-h','help'].includes(args[0])){ out(`account: ${session.label()}`); }else if(args[0]==='history'){ await run('history-cli.mjs',args.slice(1)); -}else if(['login','logout','whoami','publish','colors','profile','mood','handle','check'].includes(args[0])){ +}else if(['login','logout','whoami','publish','colors','profile','mood','handle','check','mime'].includes(args[0])){ await run('cli.mjs',args); }else{ const {BACKENDS,BACKEND_ALIASES}=await import('./backends.mjs'); diff --git a/aesel/src/terminal-help.txt b/aesel/src/terminal-help.txt index d88461ba3f..35c1c5cf05 100644 --- a/aesel/src/terminal-help.txt +++ b/aesel/src/terminal-help.txt @@ -9,6 +9,7 @@ Usage: ac [directory] [--runtime mjs|lisp|processing] aesthetic login | logout | whoami aesthetic publish [slug] aesthetic colors + aesthetic mime ["caption"] Open the aesel terminal interface in a workspace, or manage the shared Aesthetic Computer sign-in (~/.ac-token) and publish a piece under diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index e7c1630d3e..24aa6c3729 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -69,6 +69,7 @@ import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; import { mediaPaths, mediaFile, watchMedia, itemText, mediaChanged } from "./media.mjs"; import { watchSource } from "./source-watch.mjs"; +import { planMime, postMime, MAX_BYTES } from "./mime.mjs"; import { Artifacts, MEDIA } from './artifacts.mjs'; import { desktopSnapshot, readDesktopSession, writeDesktopSession, restoreDesktopEngine, writeDesktopControl, readDesktopIntent } from "./desktop-session.mjs"; import { archiveThread, replaceWork } from './new-work.mjs'; @@ -1643,6 +1644,8 @@ function openMediaDropdown() { if (current && mediaFile(current, cwd)) { if (state.media.source === "pinned") items.push({ act: "unpin", label: " ⊘ unpin", detail: "the card follows the tools again" }); else items.push({ act: "pin", file: current, label: " ⦿ pin", detail: "hold it on the card, reload on every write" }); + // Public, so it asks once more before anything leaves the machine. + if (session.handle && (state.media.size || 0) <= MAX_BYTES) items.push({ act: "mime", label: " ⇪ post to mime.ac", detail: `public, as @${session.handle}` }); items.push( { act: "open", label: " ↗ open", detail: "in its own app" }, { act: "reveal", label: " ⌕ reveal", detail: "in Finder" }, @@ -1653,8 +1656,17 @@ function openMediaDropdown() { redraw(); } function chooseMedia(item) { - state.dropdown = null; const current = state.media?.path; + if (item.act === "mime" && current) { + state.dropdown = { kind: "media", title: "post to mime.ac?", index: 0, loading: false, items: [ + { act: "mime-yes", file: current, label: ` ⇪ yes, post ${path.basename(current)}`, detail: `public on mime.ac as @${session.handle}` }, + { act: "cancel", label: " cancel", detail: "" }, + ] }; + return redraw(); + } + state.dropdown = null; + if (item.act === "mime-yes") { void postToMime(item.file); return redraw(); } + if (item.act === "cancel") return redraw(); if (item.file && (!item.act || item.act === "pin")) { const media = mediaFile(item.file, cwd); if (!media) { addEntry("error", `${path.basename(item.file)} is gone`); return redraw(); } @@ -1681,6 +1693,22 @@ function chooseMedia(item) { } return redraw(); } +// The confirmed post: the file as an opening post on mime.ac, the thread's +// address in the transcript, where it can be opened or copied. +async function postToMime(file) { + let plan; + try { plan = planMime(file); } catch (error) { addEntry("error", errorText(error)); return redraw(); } + flash(`posting ${plan.name} to mime.ac…`, 8000); + redraw(); + try { + const posted = await postMime(plan, { session }); + addEntry("notice", `Posted ${plan.name} to mime.ac as @${session.handle} · ${posted.board}\n${posted.url}`); + transcript.event("mime", { path: plan.path, code: posted.code, board: posted.board }); + } catch (error) { + addEntry("error", `mime.ac: ${errorText(error)}`); + } + redraw(); +} function unpinMedia() { unwatchMedia(); unwatchMedia = () => {}; diff --git a/aesel/test/mime.test.mjs b/aesel/test/mime.test.mjs new file mode 100644 index 0000000000..8b13f0dec1 --- /dev/null +++ b/aesel/test/mime.test.mjs @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, truncateSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { planMime, postMime, mimeType, threadUrl, MAX_BYTES } from "../src/mime.mjs"; + +const dir = mkdtempSync(path.join(tmpdir(), "aesel-mime-")); +const mp3 = path.join(dir, "climb.mp3"); +writeFileSync(mp3, Buffer.from("ID3 sound")); + +test("a plan names the file, its type and the caption, and refuses what the server would", () => { + const plan = planMime("climb.mp3", { cwd: dir, caption: " lift two " }); + assert.deepEqual({ ...plan, path: path.basename(plan.path) }, { path: "climb.mp3", name: "climb.mp3", type: "audio/mpeg", size: 9, caption: "lift two" }); + assert.equal(mimeType("x.FLAC"), "audio/flac"); + assert.equal(mimeType("x.unknown"), "application/octet-stream"); + writeFileSync(path.join(dir, "empty.png"), ""); + assert.throws(() => planMime("empty.png", { cwd: dir }), /empty/); + assert.throws(() => planMime("missing.wav", { cwd: dir }), /no such file/); + assert.throws(() => planMime(".", { cwd: dir }), /not a file/); + const big = path.join(dir, "big.wav"); + writeFileSync(big, ""); + truncateSync(big, MAX_BYTES + 1); + assert.throws(() => planMime(big), /8\.0 MB; mime\.ac takes up to 8 MB/); +}); + +test("a post is an opening post signed by the session's token, and answers with its thread", async () => { + const sent = []; + const fetchImpl = async (url, init) => { sent.push({ url, init }); return { ok: true, status: 200, json: async () => ({ code: "abc", board: "audio/mpeg", parent: null }) }; }; + const session = { signedIn: true, token: async () => "tok" }; + const posted = await postMime(planMime(mp3, { caption: "hi" }), { session, fetchImpl, site: "https://ac.test" }); + assert.deepEqual(posted, { code: "abc", board: "audio/mpeg", url: "https://mime.ac/#/t/abc" }); + assert.equal(sent[0].url, "https://ac.test/api/mime"); + assert.equal(sent[0].init.headers.Authorization, "Bearer tok"); + const body = JSON.parse(sent[0].init.body); + assert.deepEqual({ ...body, file: { ...body.file, data: Buffer.from(body.file.data, "base64").toString() } }, + { parent: null, text: "hi", file: { name: "climb.mp3", type: "audio/mpeg", data: "ID3 sound" } }); + assert.equal(threadUrl("x"), "https://mime.ac/#/t/x"); +}); + +test("signed out or refused, it says why", async () => { + await assert.rejects(postMime(planMime(mp3), { session: { signedIn: false } }), /ac login/); + const fetchImpl = async () => ({ ok: false, status: 400, json: async () => ({ error: "file over 8 MB" }) }); + await assert.rejects(postMime(planMime(mp3), { session: { signedIn: true, token: async () => "t" }, fetchImpl }), /file over 8 MB/); +}); diff --git a/system/backend/MIME.md b/system/backend/MIME.md index 8eb0f5d9ab..4a9abf5463 100644 --- a/system/backend/MIME.md +++ b/system/backend/MIME.md @@ -91,6 +91,17 @@ Legacy piece records without an extension retain the existing JavaScript default. Both upload paths now preserve the extension for future records. Rate limiting and moderation tools for anonymous replies remain follow-up work. +## Posting from a terminal + +`ac mime ["caption"]` posts a file as an opening post under the +signed-in @handle (`~/.ac-token`), on the board of its MIME type, and prints +`https://mime.ac/#/t/`. `AESEL_DRY_RUN=1` shows what would be posted. +In Aesel, the media name on the status line opens a list with +"post to mime.ac", which asks once more before posting. The client is +`aesel/src/mime.mjs`; it checks size and type before any bytes leave. Files +over 8 MiB are refused until opening posts can carry a storage URL rather +than inline bytes. + ## Validation Use a disposable loopback MongoDB; the tests refuse remote hosts and create