407 lines
12 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|