feat: implement full landing, auth, dashboard, and browser unlock flows
This commit is contained in:
297
src/app.js
297
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" });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user