diff --git a/src/app.js b/src/app.js index 4bb9eeb..27ae38f 100644 --- a/src/app.js +++ b/src/app.js @@ -1,47 +1,51 @@ "use strict"; +const { randomUUID } = require("node:crypto"); const { XArtAudioEngine } = require("./lib/engine"); const { verifySignature } = require("./lib/signature"); -const { renderHomePage, renderAudioPage } = require("./views/pages"); +const { + renderLandingPage, + renderLoginPage, + renderAppPage, + renderAudioPage, +} = require("./views/pages"); +const { + json, + html, + text, + redirect, + parseJSON, + parseFormUrlEncoded, + withQuery, +} = require("./lib/http"); +const { + getAuthenticatedUserId, + serializeUserCookie, + clearUserCookie, +} = require("./lib/auth"); -function json(status, data) { - return { - status, - headers: { "content-type": "application/json; charset=utf-8" }, - body: JSON.stringify(data), - }; -} - -function html(status, markup) { - return { - status, - headers: { "content-type": "text/html; charset=utf-8" }, - body: markup, - }; -} - -function text(status, body, contentType) { - return { - status, - headers: { "content-type": contentType || "text/plain; charset=utf-8" }, - body, - }; -} - -function parseJSON(rawBody) { - if (!rawBody) { - return {}; +function sanitizeReturnTo(value, fallback = "/app") { + if (!value || typeof value !== "string") { + return fallback; } - try { - return JSON.parse(rawBody); - } catch { - throw new Error("invalid_json"); + if (!value.startsWith("/")) { + return fallback; } + + if (value.startsWith("//")) { + return fallback; + } + + return value; } -function getUserIdFromHeaders(headers) { - return headers["x-user-id"] || null; +function parsePositiveInt(raw, fallback = null) { + const parsed = Number.parseInt(String(raw || ""), 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + return parsed; } function buildApp({ config }) { @@ -49,6 +53,17 @@ function buildApp({ config }) { creditConfig: config.credit, }); + function ensureAuth(userId, returnTo) { + if (userId) { + return null; + } + + return redirect(withQuery("/login", { + returnTo: sanitizeReturnTo(returnTo, "/app"), + error: "Please sign in first", + })); + } + function handleXWebhook(headers, rawBody) { const signature = headers["x-signature"]; const isValid = verifySignature({ @@ -63,26 +78,30 @@ function buildApp({ config }) { const payload = parseJSON(rawBody); - const result = engine.processMention({ - mentionPostId: payload.mentionPostId, - callerUserId: payload.callerUserId, - parentPost: payload.parentPost, - }); - - if (!result.ok && result.status === "not_article") { - return json(200, { - status: "not_article", - message: "This parent post is not an X Article.", + try { + const result = engine.processMention({ + mentionPostId: payload.mentionPostId, + callerUserId: payload.callerUserId, + parentPost: payload.parentPost, }); - } - return json(200, { - status: "completed", - deduped: result.deduped, - jobId: result.job.id, - creditsCharged: result.job.creditsCharged, - publicLink: result.reply ? result.reply.publicLink : `/audio/${result.job.assetId}`, - }); + if (!result.ok && result.status === "not_article") { + return json(200, { + status: "not_article", + message: "This parent post is not an X Article.", + }); + } + + return json(200, { + status: "completed", + deduped: result.deduped, + jobId: result.job.id, + creditsCharged: result.job.creditsCharged, + publicLink: result.reply ? result.reply.publicLink : `/audio/${result.job.assetId}`, + }); + } catch (error) { + return json(400, { error: error.message }); + } } function handlePolarWebhook(headers, rawBody) { @@ -98,23 +117,19 @@ function buildApp({ config }) { } const payload = parseJSON(rawBody); - engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`); - return json(200, { status: "credited" }); - } - function getJobsForUser(userId) { - if (!userId) { - return []; + try { + engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`); + return json(200, { status: "credited" }); + } catch (error) { + return json(400, { error: error.message }); } - - return Array.from(engine.jobs.values()) - .filter((job) => job.callerUserId === userId) - .sort((a, b) => b.id.localeCompare(a.id)); } - function handleRequest({ method, path, headers, rawBody }) { + function handleRequest({ method, path, headers, rawBody, query }) { const safeHeaders = headers || {}; - const userId = getUserIdFromHeaders(safeHeaders); + const safeQuery = query || {}; + const userId = getAuthenticatedUserId(safeHeaders); if (method === "GET" && path === "/health") { return json(200, { ok: true }); @@ -128,8 +143,8 @@ function buildApp({ config }) { short_name: "XArtAudio", start_url: "/", display: "standalone", - background_color: "#1d232a", - theme_color: "#1d232a", + background_color: "#0f172a", + theme_color: "#0f172a", icons: [], }), "application/manifest+json; charset=utf-8", @@ -145,14 +160,138 @@ function buildApp({ config }) { } if (method === "GET" && path === "/") { - return html(200, renderHomePage({ - authenticated: Boolean(userId), - userId, - balance: userId ? engine.getWalletBalance(userId) : 0, - jobs: getJobsForUser(userId), + return html(200, renderLandingPage({ authenticated: Boolean(userId), userId })); + } + + if (method === "GET" && path === "/login") { + if (userId) { + return redirect("/app"); + } + + return html(200, renderLoginPage({ + returnTo: sanitizeReturnTo(safeQuery.returnTo, "/app"), + error: safeQuery.error || null, })); } + if (method === "POST" && path === "/auth/dev-login") { + const form = parseFormUrlEncoded(rawBody); + const requestedUserId = String(form.userId || "").trim(); + + if (!/^[a-zA-Z0-9_-]{2,40}$/.test(requestedUserId)) { + return redirect(withQuery("/login", { + returnTo: sanitizeReturnTo(form.returnTo, "/app"), + error: "Username must be 2-40 characters using letters, numbers, _ or -", + })); + } + + const nextPath = sanitizeReturnTo(form.returnTo, "/app"); + return redirect(nextPath, { + "set-cookie": serializeUserCookie(requestedUserId), + }); + } + + if (method === "POST" && path === "/auth/logout") { + return redirect("/", { + "set-cookie": clearUserCookie(), + }); + } + + if (method === "GET" && path === "/app") { + const authResponse = ensureAuth(userId, "/app"); + if (authResponse) { + return authResponse; + } + + return html(200, renderAppPage({ + userId, + summary: engine.getUserSummary(userId), + jobs: engine.listJobsForUser(userId), + flash: safeQuery.flash || null, + })); + } + + if (method === "POST" && path === "/app/actions/topup") { + const authResponse = ensureAuth(userId, "/app"); + if (authResponse) { + return authResponse; + } + + const form = parseFormUrlEncoded(rawBody); + const amount = parsePositiveInt(form.amount, null); + if (!amount || amount > 500) { + return redirect(withQuery("/app", { flash: "Invalid credit amount" })); + } + + engine.topUpCredits(userId, amount, `app-topup:${userId}:${randomUUID()}`); + return redirect(withQuery("/app", { flash: `Added ${amount} credits` })); + } + + if (method === "POST" && path === "/app/actions/simulate-mention") { + const authResponse = ensureAuth(userId, "/app"); + if (authResponse) { + return authResponse; + } + + const form = parseFormUrlEncoded(rawBody); + const title = String(form.title || "").trim(); + const body = String(form.body || "").trim(); + + if (!title || !body) { + return redirect(withQuery("/app", { flash: "Title and body are required" })); + } + + try { + const result = engine.processMention({ + mentionPostId: `manual:${userId}:${randomUUID()}`, + callerUserId: userId, + parentPost: { + id: `manual-parent:${randomUUID()}`, + authorId: userId, + article: { + id: `manual-article:${randomUUID()}`, + title, + body, + }, + }, + }); + + if (!result.ok) { + return redirect(withQuery("/app", { flash: "Parent post is not an article" })); + } + + return redirect(withQuery(`/audio/${result.job.assetId}`, { + flash: "Audiobook generated", + })); + } catch (error) { + return redirect(withQuery("/app", { flash: `Generation failed: ${error.message}` })); + } + } + + if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) { + const assetId = path.slice("/audio/".length, -"/unlock".length); + const authResponse = ensureAuth(userId, `/audio/${assetId}`); + if (authResponse) { + return authResponse; + } + + try { + engine.unlockAudio(assetId, userId); + return redirect(withQuery(`/audio/${assetId}`, { flash: "Unlocked" })); + } catch (error) { + return redirect(withQuery(`/audio/${assetId}`, { flash: `Unlock failed: ${error.message}` })); + } + } + + if (method === "GET" && path.startsWith("/audio/")) { + const assetId = path.slice("/audio/".length); + const audio = engine.getAsset(assetId); + const accessDecision = audio + ? engine.checkAudioAccess(assetId, userId) + : { allowed: false, reason: "not_found" }; + return html(200, renderAudioPage({ audio, accessDecision, userId })); + } + if (method === "POST" && path === "/api/webhooks/x") { return handleXWebhook(safeHeaders, rawBody); } @@ -161,12 +300,6 @@ function buildApp({ config }) { return handlePolarWebhook(safeHeaders, rawBody); } - if (method === "POST" && path === "/api/dev/topup") { - const body = parseJSON(rawBody); - engine.topUpCredits(body.userId, body.amount, `dev:${Date.now()}:${body.userId}`); - return json(200, { status: "credited" }); - } - if (method === "GET" && path === "/api/me/wallet") { if (!userId) { return json(401, { error: "auth_required" }); @@ -178,6 +311,7 @@ function buildApp({ config }) { if (!userId) { return json(401, { error: "auth_required" }); } + const jobId = path.slice("/api/jobs/".length); const job = engine.getJob(jobId); if (!job) { @@ -202,15 +336,6 @@ function buildApp({ config }) { } } - if (method === "GET" && path.startsWith("/audio/")) { - const assetId = path.slice("/audio/".length); - const audio = engine.getAsset(assetId); - const accessDecision = audio - ? engine.checkAudioAccess(assetId, userId) - : { allowed: false, reason: "not_found" }; - return html(200, renderAudioPage({ audio, accessDecision })); - } - return json(404, { error: "not_found" }); } diff --git a/test/app.test.js b/test/app.test.js index c60a3cf..25da00a 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -21,66 +21,161 @@ function createApp() { }); } -function postJSON(app, path, payload, secret) { - const rawBody = JSON.stringify(payload); - const sig = hmacSHA256Hex(rawBody, secret); +function call(app, { method, path, headers = {}, body = "", query = {} }) { return app.handleRequest({ - method: "POST", + method, path, - headers: { "x-signature": `sha256=${sig}` }, - rawBody, + headers, + rawBody: body, + query, }); } -test("rejects invalid X webhook signature", () => { +function postJSONWebhook(app, path, payload, secret) { + const rawBody = JSON.stringify(payload); + const sig = hmacSHA256Hex(rawBody, secret); + + return call(app, { + method: "POST", + path, + headers: { "x-signature": `sha256=${sig}` }, + body: rawBody, + }); +} + +test("GET / renders landing page", () => { const app = createApp(); - const response = app.handleRequest({ + const response = call(app, { method: "GET", path: "/" }); + assert.equal(response.status, 200); + assert.match(response.body, /From X Article to audiobook in one mention/); +}); + +test("unauthenticated /app redirects to /login with returnTo", () => { + const app = createApp(); + const response = call(app, { method: "GET", path: "/app" }); + assert.equal(response.status, 303); + assert.match(response.headers.location, /^\/login\?/); + assert.match(response.headers.location, /returnTo=%2Fapp/); +}); + +test("POST /auth/dev-login sets cookie and redirects", () => { + const app = createApp(); + const response = call(app, { + method: "POST", + path: "/auth/dev-login", + body: "userId=matiss&returnTo=%2Fapp", + }); + + assert.equal(response.status, 303); + assert.equal(response.headers.location, "/app"); + assert.match(response.headers["set-cookie"], /^xartaudio_user=matiss/); +}); + +test("authenticated dashboard topup + simulate mention flow", () => { + const app = createApp(); + const cookieHeader = "xartaudio_user=alice"; + + const topup = call(app, { + method: "POST", + path: "/app/actions/topup", + headers: { cookie: cookieHeader }, + body: "amount=8", + }); + assert.equal(topup.status, 303); + assert.match(topup.headers.location, /Added%208%20credits/); + + const simulate = call(app, { + method: "POST", + path: "/app/actions/simulate-mention", + headers: { cookie: cookieHeader }, + body: "title=Hello&body=This+is+the+article+body", + }); + assert.equal(simulate.status, 303); + assert.match(simulate.headers.location, /^\/audio\//); + + const dashboard = call(app, { + method: "GET", + path: "/app", + headers: { cookie: cookieHeader }, + }); + assert.equal(dashboard.status, 200); + assert.match(dashboard.body, /Recent audiobooks/); + assert.match(dashboard.body, /Hello/); +}); + +test("audio flow requires auth for unlock and supports permanent unlock", () => { + const app = createApp(); + + call(app, { + method: "POST", + path: "/app/actions/topup", + headers: { cookie: "xartaudio_user=owner" }, + body: "amount=5", + }); + call(app, { + method: "POST", + path: "/app/actions/topup", + headers: { cookie: "xartaudio_user=viewer" }, + body: "amount=5", + }); + + const generated = call(app, { + method: "POST", + path: "/app/actions/simulate-mention", + headers: { cookie: "xartaudio_user=owner" }, + body: "title=Owned+Audio&body=Body", + }); + + const audioPath = generated.headers.location.split("?")[0]; + const assetId = audioPath.replace("/audio/", ""); + + const beforeUnlock = call(app, { + method: "GET", + path: audioPath, + headers: { cookie: "xartaudio_user=viewer" }, + }); + assert.match(beforeUnlock.body, /Unlock required: 1 credits/); + + const unlock = call(app, { + method: "POST", + path: `/audio/${assetId}/unlock`, + headers: { cookie: "xartaudio_user=viewer" }, + }); + assert.equal(unlock.status, 303); + + const afterUnlock = call(app, { + method: "GET", + path: audioPath, + headers: { cookie: "xartaudio_user=viewer" }, + }); + assert.match(afterUnlock.body, /Access granted/); + + const wallet = call(app, { + method: "GET", + path: "/api/me/wallet", + headers: { cookie: "xartaudio_user=viewer" }, + }); + const walletData = JSON.parse(wallet.body); + assert.equal(walletData.balance, 4); +}); + +test("X webhook invalid signature is rejected", () => { + const app = createApp(); + const response = call(app, { method: "POST", path: "/api/webhooks/x", headers: { "x-signature": "sha256=deadbeef" }, - rawBody: JSON.stringify({ mentionPostId: "m1", callerUserId: "u1", parentPost: {} }), + body: JSON.stringify({ mentionPostId: "m1", callerUserId: "u1", parentPost: {} }), }); assert.equal(response.status, 401); }); -test("serves pwa manifest route", () => { +test("X webhook valid flow processes article", () => { const app = createApp(); - const response = app.handleRequest({ - method: "GET", - path: "/manifest.webmanifest", - headers: {}, - rawBody: "", - }); - assert.equal(response.status, 200); - assert.match(response.headers["content-type"], /application\/manifest\+json/); - const body = JSON.parse(response.body); - assert.equal(body.short_name, "XArtAudio"); -}); - -test("X webhook returns not_article response and no charge", () => { - const app = createApp(); - postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt1" }, "polar-secret"); - - const response = postJSON( - app, - "/api/webhooks/x", - { mentionPostId: "m1", callerUserId: "u1", parentPost: { id: "p1" } }, - "x-secret", - ); - - const body = JSON.parse(response.body); - assert.equal(response.status, 200); - assert.equal(body.status, "not_article"); - assert.equal(app.engine.getWalletBalance("u1"), 5); -}); - -test("X webhook processes article, charges caller, and exposes audio route", () => { - const app = createApp(); - postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt2" }, "polar-secret"); - - const response = postJSON( + postJSONWebhook(app, "/api/webhooks/polar", { userId: "u1", credits: 4, eventId: "evt1" }, "polar-secret"); + const response = postJSONWebhook( app, "/api/webhooks/x", { @@ -88,74 +183,14 @@ test("X webhook processes article, charges caller, and exposes audio route", () callerUserId: "u1", parentPost: { id: "p2", - article: { - id: "a2", - title: "Article", - body: "Hello from article", - }, + article: { id: "a2", title: "T", body: "Hello" }, }, }, "x-secret", ); - const body = JSON.parse(response.body); assert.equal(response.status, 200); + const body = JSON.parse(response.body); assert.equal(body.status, "completed"); assert.equal(body.creditsCharged, 1); - - const audioPageUnauthed = app.handleRequest({ - method: "GET", - path: body.publicLink, - headers: {}, - rawBody: "", - }); - - assert.equal(audioPageUnauthed.status, 200); - assert.match(audioPageUnauthed.body, /Sign in required before playback/); -}); - -test("non-owner can unlock with same credits then access", () => { - const app = createApp(); - postJSON(app, "/api/webhooks/polar", { userId: "u1", credits: 5, eventId: "evt3" }, "polar-secret"); - postJSON(app, "/api/webhooks/polar", { userId: "u2", credits: 5, eventId: "evt4" }, "polar-secret"); - - const makeAudio = postJSON( - app, - "/api/webhooks/x", - { - mentionPostId: "m3", - callerUserId: "u1", - parentPost: { - id: "p3", - article: { - id: "a3", - title: "Article", - body: "Hello from article", - }, - }, - }, - "x-secret", - ); - - const { publicLink } = JSON.parse(makeAudio.body); - const assetId = publicLink.replace("/audio/", ""); - - const unlock = app.handleRequest({ - method: "POST", - path: `/api/audio/${assetId}/unlock`, - headers: { "x-user-id": "u2" }, - rawBody: "", - }); - - assert.equal(unlock.status, 200); - assert.equal(app.engine.getWalletBalance("u2"), 4); - - const pageAfterUnlock = app.handleRequest({ - method: "GET", - path: publicLink, - headers: { "x-user-id": "u2" }, - rawBody: "", - }); - - assert.match(pageAfterUnlock.body, /Access granted/); });