// News API, 2026.01.15
// JSON + form handlers for posts, comments, votes
import { connect } from "../../backend/database.mjs";
import { respond } from "../../backend/http.mjs";
import { authorize, hasAdmin } from "../../backend/authorization.mjs";
import { generateUniqueCode } from "../../backend/generate-short-code.mjs";
import { ObjectId } from "mongodb";
import { createNewsOnAtproto } from "../../backend/news-atproto.mjs";
import { ingestAll, ingestFromActor } from "../../backend/news-bluesky-ingest.mjs";
// Admin users who can delete/censor content
const ADMIN_SUBS = [process.env.ADMIN_SUB].filter(Boolean);
const MAX_TITLE = 200;
const MAX_TEXT = 5000;
const RATE_LIMIT_HOURS = 24; // Users can only post once per 24 hours
function parseBody(event) {
if (!event.body) return {};
const contentType = event.headers?.["content-type"] || event.headers?.["Content-Type"] || "";
if (contentType.includes("application/json")) {
try {
return JSON.parse(event.body);
} catch {
return {};
}
}
const params = new URLSearchParams(event.body);
return Object.fromEntries(params.entries());
}
function wantsHtml(event) {
const accept = event.headers?.accept || event.headers?.Accept || "";
return accept.includes("text/html") || accept.includes("application/xhtml");
}
function redirect(location) {
return {
statusCode: 302,
headers: {
Location: location,
"Content-Type": "text/html",
},
body: `Redirecting`,
};
}
function parseRoute(event) {
// Extract route from path, stripping the function prefix
let path = event.path || "";
const prefixes = ["/.netlify/functions/news-api", "/api/news"];
for (const prefix of prefixes) {
if (path.startsWith(prefix)) {
path = path.slice(prefix.length);
break;
}
}
// Also check queryStringParameters for backwards compatibility
if (!path && event.queryStringParameters?.path) {
path = event.queryStringParameters.path;
}
return path.replace(/^\/+/, "").replace(/\/+$/, "");
}
async function ensureIndexes(posts, comments, votes) {
await posts.createIndex({ code: 1 }, { unique: true, background: true });
await posts.createIndex({ when: -1 }, { background: true });
await posts.createIndex({ score: -1 }, { background: true });
await posts.createIndex({ user: 1 }, { background: true });
await posts.createIndex({ status: 1 }, { background: true });
await posts.createIndex(
{ "external.postUri": 1 },
{ unique: true, sparse: true, background: true },
);
await comments.createIndex({ postCode: 1 }, { background: true });
await comments.createIndex({ parentId: 1 }, { background: true, sparse: true });
await comments.createIndex({ when: 1 }, { background: true });
await comments.createIndex({ user: 1 }, { background: true });
await votes.createIndex({ itemType: 1, itemId: 1, user: 1 }, { unique: true, background: true });
}
function sanitizeText(value, max) {
if (!value) return "";
const trimmed = String(value).trim();
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
}
function normalizeUrl(value) {
if (!value) return "";
const trimmed = value.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
function resolveBasePath(event) {
const host = event.headers?.host || "";
return host.startsWith("news.aesthetic.computer") ? "" : "/news.aesthetic.computer";
}
async function attachCommentCounts(posts, comments) {
if (!posts || posts.length === 0) return posts || [];
const codes = posts.map((post) => post.code).filter(Boolean);
const counts = await comments
.aggregate([
{ $match: { postCode: { $in: codes }, status: { $ne: "dead" } } },
{ $group: { _id: "$postCode", count: { $sum: 1 } } },
])
.toArray();
const countMap = new Map(counts.map((row) => [row._id, row.count]));
return posts.map((post) => ({
...post,
commentCount: countMap.get(post.code) || 0,
}));
}
export function createHandler({
connect: connectFn = connect,
respond: respondFn = respond,
authorize: authorizeFn = authorize,
generateUniqueCode: generateUniqueCodeFn = generateUniqueCode,
ObjectId: ObjectIdImpl = ObjectId,
} = {}) {
async function requireUserWith(event) {
const user = await authorizeFn(event.headers || {});
if (!user) {
const res = respondFn(401, { error: "Unauthorized" });
res.headers["WWW-Authenticate"] = "Bearer";
throw Object.assign(new Error("Unauthorized"), { response: res });
}
return user;
}
return async function handler(event) {
const route = parseRoute(event);
if (event.httpMethod === "OPTIONS") {
return respondFn(204, "");
}
// Unfurl endpoint doesn't need the database
if (event.httpMethod === "GET" && route === "unfurl") {
const targetUrl = event.queryStringParameters?.url;
if (!targetUrl) {
return respondFn(400, { error: "Missing 'url' parameter" });
}
try {
const normalizedUrl = normalizeUrl(targetUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(normalizedUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'AestheticNewsBot/1.0 (+https://news.aesthetic.computer)',
'Accept': 'text/html',
},
redirect: 'follow',
});
clearTimeout(timeout);
if (!res.ok) {
return respondFn(200, { title: '', error: 'Could not fetch URL' });
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
return respondFn(200, { title: '', error: 'Not an HTML page' });
}
// Read only the first 32KB to find the title
const reader = res.body.getReader();
const decoder = new TextDecoder();
let html = '';
while (html.length < 32768) {
const { done, value } = await reader.read();
if (done) break;
html += decoder.decode(value, { stream: true });
// Early exit if we've found or
if (html.includes('') || html.includes('')) break;
}
reader.cancel().catch(() => {});
// Try og:title first, then
let title = '';
const ogMatch = html.match(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i);
if (ogMatch) {
title = ogMatch[1];
} else {
const titleMatch = html.match(/]*>([^<]+)<\/title>/i);
if (titleMatch) title = titleMatch[1];
}
// Decode HTML entities
title = title.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(///g, '/').trim();
return respondFn(200, { title });
} catch (e) {
if (e.name === 'AbortError') {
return respondFn(200, { title: '', error: 'Request timed out' });
}
return respondFn(200, { title: '', error: 'Failed to fetch URL' });
}
}
let database;
try {
database = await connectFn();
const posts = database.db.collection("news-posts");
const comments = database.db.collection("news-comments");
const votes = database.db.collection("news-votes");
await ensureIndexes(posts, comments, votes);
if (event.httpMethod === "GET") {
if (route === "posts") {
const limit = Math.min(parseInt(event.queryStringParameters?.limit || "30", 10), 100);
const sort = event.queryStringParameters?.sort === "new" ? { when: -1 } : { score: -1, when: -1 };
const includeRecentComments = parseInt(event.queryStringParameters?.includeRecentComments || "0", 10);
// Show all posts except "dead"
const docs = await posts.find({ status: { $ne: "dead" } }).sort(sort).limit(limit).toArray();
const withCounts = await attachCommentCounts(docs, comments);
// Optionally include recent comments for each post (with hydrated handles)
if (includeRecentComments > 0) {
const handles = database.db.collection("@handles");
for (const post of withCounts) {
const recentComments = await comments
.find({ postCode: post.code, status: { $ne: "dead" } })
.sort({ when: -1 })
.limit(includeRecentComments)
.toArray();
// Hydrate handles: look up user subs in @handles collection
const userSubs = recentComments.map(c => c.user).filter(Boolean);
const handleDocs = userSubs.length > 0
? await handles.find({ _id: { $in: userSubs } }).toArray()
: [];
const handleMap = new Map(handleDocs.map(h => [h._id, h.handle]));
post.recentComments = recentComments.map(c => ({
text: c.text,
handle: c.user ? (handleMap.get(c.user) || 'anon') : 'anon',
when: c.when,
}));
}
}
return respondFn(200, { posts: withCounts });
}
// Live updates endpoint - check for new posts since a timestamp
if (route === "updates") {
const since = event.queryStringParameters?.since;
if (!since) {
return respondFn(400, { error: "Missing 'since' timestamp parameter" });
}
const sinceDate = new Date(parseInt(since, 10));
const newPosts = await posts.find({
status: "live",
when: { $gt: sinceDate }
}).sort({ when: -1 }).limit(10).toArray();
const withCounts = await attachCommentCounts(newPosts, comments);
// Return count and latest timestamp for efficient polling
const latestWhen = newPosts.length > 0 ? newPosts[0].when.getTime() : parseInt(since, 10);
return respondFn(200, {
newPosts: newPosts.length,
posts: withCounts,
latestWhen,
});
}
return respondFn(404, { error: "Not found" });
}
if (event.httpMethod === "POST") {
if (route === "submit") {
const user = await requireUserWith(event);
const body = parseBody(event);
const title = sanitizeText(body.title, MAX_TITLE);
const text = sanitizeText(body.text, MAX_TEXT);
const url = normalizeUrl(body.url);
if (!title) {
return respondFn(400, { error: "Title required" });
}
// Check rate limit - 1 post per 24 hours (admins exempt)
const isAdmin = await hasAdmin(user, "aesthetic");
if (!isAdmin) {
const rateLimitCutoff = new Date(Date.now() - RATE_LIMIT_HOURS * 60 * 60 * 1000);
const recentPost = await posts.findOne({
user: user.sub,
when: { $gte: rateLimitCutoff },
status: { $ne: "dead" },
});
if (recentPost) {
const hoursAgo = Math.round((Date.now() - recentPost.when.getTime()) / (60 * 60 * 1000));
const hoursLeft = RATE_LIMIT_HOURS - hoursAgo;
return respondFn(429, {
error: `You can only post once every ${RATE_LIMIT_HOURS} hours. Try again in ${hoursLeft} hour${hoursLeft === 1 ? '' : 's'}.`,
retryAfter: hoursLeft * 60 * 60,
});
}
}
const shortCode = await generateUniqueCodeFn(posts, { mode: "random" });
const now = new Date();
const code = `n${shortCode}`; // n-prefixed news code, e.g. "nicd"
const doc = {
code,
title,
url,
text,
user: user.sub,
when: now,
updated: now,
score: 1,
commentCount: 0,
status: "live",
};
const insertResult = await posts.insertOne(doc);
const postId = insertResult.insertedId.toString();
await votes.insertOne({
itemType: "post",
itemId: code,
user: user.sub,
when: now,
});
// Sync to ATProto PDS using the user's account (non-blocking)
createNewsOnAtproto(database, user.sub, {
headline: title,
body: text || null,
link: url || null,
when: now,
}, postId).then(atprotoResult => {
if (atprotoResult.rkey) {
posts.updateOne(
{ code },
{ $set: { atproto: { rkey: atprotoResult.rkey, uri: atprotoResult.uri, did: atprotoResult.did } } }
).catch(e => console.error('Failed to save ATProto rkey:', e));
}
}).catch(e => console.error('ATProto sync error:', e));
const redirectTo = `${resolveBasePath(event)}/${code}`;
if (wantsHtml(event)) {
return redirect(redirectTo);
}
return respondFn(200, { ok: true, redirect: redirectTo, code });
}
if (route === "comment") {
const user = await requireUserWith(event);
const body = parseBody(event);
const postCode = sanitizeText(body.postCode, 32);
const text = sanitizeText(body.text, MAX_TEXT);
if (!postCode || !text) {
return respondFn(400, { error: "Please write something before responding." });
}
const now = new Date();
const doc = {
postCode,
parentId: body.parentId || null,
text,
user: user.sub,
when: now,
score: 1,
status: "live",
};
const insertResult = await comments.insertOne(doc);
const commentId = insertResult.insertedId.toString();
await votes.insertOne({
itemType: "comment",
itemId: commentId,
user: user.sub,
when: now,
});
await posts.updateOne({ code: postCode }, { $inc: { commentCount: 1 } });
const redirectTo = `${resolveBasePath(event)}/${postCode}`;
if (wantsHtml(event)) {
return redirect(redirectTo);
}
return respondFn(200, { ok: true, redirect: redirectTo, commentId });
}
if (route === "vote") {
const user = await requireUserWith(event);
const body = parseBody(event);
const itemType = body.itemType;
const itemId = body.itemId;
const dir = parseInt(body.dir || "1", 10);
if (!itemType || !itemId || !["post", "comment"].includes(itemType)) {
return respondFn(400, { error: "Invalid vote" });
}
const now = new Date();
try {
await votes.insertOne({ itemType, itemId, user: user.sub, when: now });
} catch (error) {
if (error.code === 11000) {
return respondFn(200, { ok: true, duplicate: true });
}
throw error;
}
if (itemType === "post") {
await posts.updateOne({ code: itemId }, { $inc: { score: dir } });
} else {
const commentId = ObjectIdImpl.isValid(itemId) ? new ObjectIdImpl(itemId) : itemId;
await comments.updateOne({ _id: commentId }, { $inc: { score: dir } });
}
return respondFn(200, { ok: true });
}
// Delete content - admins can delete anything, users can delete their own
if (route === "delete") {
const user = await requireUserWith(event);
const isAdmin = await hasAdmin(user, "aesthetic");
const body = parseBody(event);
const itemType = body.itemType;
const itemId = body.itemId;
if (!itemType || !itemId || !["post", "comment"].includes(itemType)) {
return respondFn(400, { error: "Invalid delete request" });
}
if (itemType === "post") {
const post = await posts.findOne({ code: itemId });
if (!post) {
return respondFn(404, { error: "Post not found" });
}
// Check permission: admin OR post owner
if (!isAdmin && post.user !== user.sub) {
return respondFn(403, { error: "You can only delete your own posts" });
}
await posts.updateOne({ code: itemId }, { $set: { status: "dead" } });
// Also mark all comments on this post as dead
await comments.updateMany({ postCode: itemId }, { $set: { status: "dead" } });
} else {
const commentId = ObjectIdImpl.isValid(itemId) ? new ObjectIdImpl(itemId) : itemId;
const comment = await comments.findOne({ _id: commentId });
if (!comment) {
return respondFn(404, { error: "Comment not found" });
}
// Check permission: admin OR comment owner
if (!isAdmin && comment.user !== user.sub) {
return respondFn(403, { error: "You can only delete your own comments" });
}
const wasLive = comment.status !== "dead";
await comments.updateOne({ _id: commentId }, { $set: { status: "dead" } });
if (wasLive && comment.postCode) {
await posts.updateOne({ code: comment.postCode }, { $inc: { commentCount: -1 } });
await posts.updateOne({ code: comment.postCode }, { $max: { commentCount: 0 } });
}
}
const redirectTo = itemType === "post" ? resolveBasePath(event) + "/" : null;
if (wantsHtml(event) && redirectTo) {
return redirect(redirectTo);
}
return respondFn(200, { ok: true, deleted: itemId, redirect: redirectTo });
}
// Admin-only: pull fresh headlines from trusted Bluesky sources.
if (route === "ingest-external") {
const user = await requireUserWith(event);
const isAdmin = await hasAdmin(user, "aesthetic");
if (!isAdmin) {
return respondFn(403, { error: "Admin only" });
}
const body = parseBody(event);
const actor = sanitizeText(body.actor, 256);
const limit = Math.min(parseInt(body.limit || "30", 10) || 30, 100);
const results = actor
? [await ingestFromActor(database, actor, { limit })]
: await ingestAll(database, { limit });
return respondFn(200, { ok: true, results });
}
}
return respondFn(404, { error: "Not found" });
} catch (error) {
if (error.response) {
return error.response;
}
console.error("news-api error:", error);
if (database) await database.disconnect();
return respondFn(500, { error: "Server error" });
} finally {
if (database) await database.disconnect();
}
};
}
export const handler = createHandler();
export { parseBody };