Files
memind/server/portal-plaza-routes.mjs

407 lines
12 KiB
JavaScript

import crypto from 'node:crypto';
import { parseCookies } from '../auth.mjs';
const PLAZA_SID_COOKIE = 'plaza_sid';
function assertRouter(api) {
if (
!api ||
typeof api.get !== 'function' ||
typeof api.post !== 'function' ||
typeof api.patch !== 'function' ||
typeof api.delete !== 'function'
) {
throw new Error(
'attachPortalPlazaRoutes requires an Express-compatible router',
);
}
}
function reactionEventType(type) {
if (type === 'like' || type === 'collect' || type === 'share') return type;
return null;
}
export function attachPortalPlazaRoutes(
api,
{
getPlazaSeo = () => null,
getPlazaOps = () => null,
getPlazaPosts = () => null,
getPlazaEvents = () => null,
getPlazaInteractions = () => null,
getPlazaRedis = () => null,
resolveClientIp = (req) => req.ip,
randomUUID = crypto.randomUUID,
sendData = (res, _req, data, status = 200) =>
res.status(status).json({ data }),
sendError = (res, _req, status, code, message, details) =>
res.status(status).json({ error: { code, message, details } }),
handleRouteError = (_res, _req, error) => {
throw error;
},
} = {},
) {
assertRouter(api);
const resolveSessionId = (req, res) => {
const cookies = parseCookies(req.get('cookie'));
let sessionId = cookies[PLAZA_SID_COOKIE];
if (!sessionId) {
sessionId = randomUUID();
res.append(
'Set-Cookie',
`${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`,
);
}
return sessionId;
};
const recordEventsAsync = (req, res, events) => {
const plazaEvents = getPlazaEvents();
if (!plazaEvents || !Array.isArray(events) || events.length === 0) return;
const sessionId = resolveSessionId(req, res);
void plazaEvents
.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events,
})
.catch(() => {});
};
const requirePosts = (res, req) => {
const plazaPosts = getPlazaPosts();
if (!plazaPosts) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
return null;
}
return plazaPosts;
};
const requireInteractions = (res, req) => {
const plazaInteractions = getPlazaInteractions();
if (!plazaInteractions) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
return null;
}
return plazaInteractions;
};
const requireCurrentUser = (req, res) => {
if (req.currentUser) return req.currentUser;
sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
return null;
};
api.post('/plaza/v1/attribution/events', async (req, res) => {
const plazaSeo = getPlazaSeo();
if (!plazaSeo) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
}
try {
const result = await plazaSeo.recordAttribution(
req.body ?? {},
resolveClientIp(req),
);
return sendData(res, req, result, 201);
} catch (error) {
return handleRouteError(res, req, error);
}
});
for (const [route, targetType] of [
['/plaza/v1/posts/:id/reports', 'post'],
['/plaza/v1/comments/:id/reports', 'comment'],
]) {
api.post(route, async (req, res) => {
const plazaOps = getPlazaOps();
if (!plazaOps) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
}
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const report = await plazaOps.createReport(user.id, {
target_type: targetType,
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return handleRouteError(res, req, error);
}
});
}
api.get('/plaza/v1/feed', async (req, res) => {
const plazaPosts = requirePosts(res, req);
if (!plazaPosts) return;
try {
const sessionId = resolveSessionId(req, res);
const feed = await plazaPosts.listFeed({
sort: req.query.sort,
categorySlug: req.query.category ?? null,
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
sessionId,
});
return sendData(res, req, feed);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.post('/plaza/v1/events', async (req, res) => {
const plazaPosts = requirePosts(res, req);
if (!plazaPosts) return;
const plazaEvents = getPlazaEvents();
if (!plazaEvents) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
}
try {
const sessionId =
String(req.body?.session_id ?? '').trim() ||
resolveSessionId(req, res);
const result = await plazaEvents.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events: req.body?.events ?? [],
});
return sendData(
res,
req,
{ ...result, session_id: sessionId },
201,
);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const result = await plazaInteractions.addReaction(
user.id,
req.params.id,
req.body?.type,
);
const eventType = reactionEventType(result.type);
if (eventType) {
recordEventsAsync(req, res, [
{ event_type: eventType, post_id: req.params.id },
]);
}
return sendData(res, req, result);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const result = await plazaInteractions.removeReaction(
user.id,
req.params.id,
req.params.type,
);
return sendData(res, req, result);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id/comments', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
try {
const comments = await plazaInteractions.listComments(req.params.id, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
parentId: req.query.parent_id ?? null,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, comments);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const comment = await plazaInteractions.createComment(
user.id,
req.params.id,
req.body ?? {},
);
recordEventsAsync(req, res, [
{ event_type: 'comment', post_id: req.params.id },
]);
return sendData(res, req, { comment }, 201);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.delete('/plaza/v1/comments/:id', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const comment = await plazaInteractions.deleteComment(
user.id,
req.params.id,
);
return sendData(res, req, { comment });
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reactions', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const liked = req.body?.liked !== false;
const result = await plazaInteractions.toggleCommentLike(
user.id,
req.params.id,
liked,
);
return sendData(res, req, result);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
try {
const profile = await plazaInteractions.getUserProfile(
req.params.slug,
req.currentUser?.id ?? null,
);
return sendData(res, req, profile);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug/posts', async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
try {
const feed = await plazaInteractions.listUserPosts(req.params.slug, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, feed);
} catch (error) {
return handleRouteError(res, req, error);
}
});
for (const [route, operation] of [
['/plaza/v1/users/:slug/follow', 'followUser'],
['/plaza/v1/users/:slug/follow', 'unfollowUser'],
]) {
const register = operation === 'followUser' ? api.post.bind(api) : api.delete.bind(api);
register(route, async (req, res) => {
const plazaInteractions = requireInteractions(res, req);
if (!plazaInteractions) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const result = await plazaInteractions[operation](
user.id,
req.params.slug,
);
return sendData(res, req, result);
} catch (error) {
return handleRouteError(res, req, error);
}
});
}
api.get('/plaza/v1/posts/:id', async (req, res) => {
const plazaPosts = requirePosts(res, req);
if (!plazaPosts) return;
try {
const post = await plazaPosts.getPostById(req.params.id, {
viewerId: req.currentUser?.id ?? null,
});
void getPlazaRedis()
.recordView(req.params.id, resolveClientIp(req))
.catch(() => {});
recordEventsAsync(req, res, [
{ event_type: 'view', post_id: req.params.id },
]);
return sendData(res, req, { post });
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts', async (req, res) => {
const plazaPosts = requirePosts(res, req);
if (!plazaPosts) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const post = await plazaPosts.createPost(user.id, req.body ?? {});
return sendData(res, req, { post }, 201);
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.patch('/plaza/v1/posts/:id', async (req, res) => {
const plazaPosts = requirePosts(res, req);
if (!plazaPosts) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const post = await plazaPosts.updatePost(
user.id,
req.params.id,
req.body ?? {},
);
return sendData(res, req, { post });
} catch (error) {
return handleRouteError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id', async (req, res) => {
const plazaPosts = requirePosts(res, req);
if (!plazaPosts) return;
const user = requireCurrentUser(req, res);
if (!user) return;
try {
const post = await plazaPosts.hidePost(user.id, req.params.id);
return sendData(res, req, { post });
} catch (error) {
return handleRouteError(res, req, error);
}
});
}