0c2cb9e283
Expose categories listing, category filter on review queue, and invalidate feed caches when ops hides a published post. Co-authored-by: Cursor <cursoragent@cursor.com>
578 lines
20 KiB
JavaScript
578 lines
20 KiB
JavaScript
import express from 'express';
|
|
import crypto from 'node:crypto';
|
|
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
|
|
import { createPlazaEventService } from './plaza-events.mjs';
|
|
import { createPlazaRecommendService } from './plaza-recommend.mjs';
|
|
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
|
import { parseCookies } from './auth.mjs';
|
|
import {
|
|
ensureAlgorithmConfig,
|
|
loadAlgorithmConfig,
|
|
recalculateHotScores,
|
|
} from './plaza-algorithm.mjs';
|
|
import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
|
|
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
|
|
import { createPlazaSeoService } from './plaza-seo.mjs';
|
|
import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs';
|
|
|
|
export async function bootstrapPlaza(pool) {
|
|
await ensureAlgorithmConfig(pool);
|
|
const algorithmConfig = await loadAlgorithmConfig(pool);
|
|
const redis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
|
|
if (redis.enabled) {
|
|
console.log('Plaza Redis enabled');
|
|
}
|
|
const seo = createPlazaSeoService(pool);
|
|
const interactions = createPlazaInteractionService(pool, {
|
|
formatPostRow,
|
|
plazaRedis: redis,
|
|
});
|
|
const events = createPlazaEventService(pool);
|
|
const recommend = createPlazaRecommendService(pool, {
|
|
eventService: events,
|
|
formatPostRow,
|
|
loadViewerReactions: (viewerId, postIds) =>
|
|
interactions.loadViewerReactions(viewerId, postIds),
|
|
algorithmConfig,
|
|
});
|
|
|
|
let ops = null;
|
|
const posts = createPlazaPostService(pool, {
|
|
loadViewerReactions: (viewerId, postIds) =>
|
|
interactions.loadViewerReactions(viewerId, postIds),
|
|
plazaRedis: redis,
|
|
algorithmConfig,
|
|
recommendService: recommend,
|
|
onPostPublished: (postId) => seo?.notifyPostPublished(postId),
|
|
loadFeaturedPosts: async (viewerId) => {
|
|
if (!ops) return { homepage_banner: [], trending: [], category_top: {} };
|
|
return ops.loadActiveFeaturedPosts(viewerId);
|
|
},
|
|
});
|
|
ops = createPlazaOpsService(pool, {
|
|
formatPostRow,
|
|
reviewPost: (...args) => posts.reviewPost(...args),
|
|
invalidateFeedCaches: () => redis?.invalidateFeedCaches?.(),
|
|
});
|
|
startPlazaTasks({
|
|
pool,
|
|
plazaRedis: redis,
|
|
recalculateHotScores,
|
|
writebackPublications,
|
|
});
|
|
return { posts, interactions, events, recommend, seo, ops, redis };
|
|
}
|
|
|
|
export function createNoopPlaza() {
|
|
return { posts: null, interactions: null, seo: null, ops: null, redis: createNoopPlazaRedis() };
|
|
}
|
|
|
|
export function isPlazaPublicRead(path, method) {
|
|
if (!path.startsWith('/plaza/v1/')) return false;
|
|
if (method === 'POST' && path === '/plaza/v1/events') return true;
|
|
if (method !== 'GET') return false;
|
|
return (
|
|
path === '/plaza/v1/feed' ||
|
|
path === '/plaza/v1/categories' ||
|
|
path === '/plaza/v1/seo/sitemap' ||
|
|
/^\/plaza\/v1\/posts\/[^/]+$/.test(path) ||
|
|
/^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) ||
|
|
/^\/plaza\/v1\/users\/[^/]+$/.test(path) ||
|
|
/^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path)
|
|
);
|
|
}
|
|
|
|
export function plazaClientIp(req) {
|
|
const forwarded = req.headers['x-forwarded-for'];
|
|
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
|
return forwarded.split(',')[0].trim();
|
|
}
|
|
return req.ip;
|
|
}
|
|
|
|
const PLAZA_SID_COOKIE = 'plaza_sid';
|
|
|
|
function resolvePlazaSessionId(req, res) {
|
|
const cookies = parseCookies(req.get('cookie'));
|
|
let sessionId = cookies[PLAZA_SID_COOKIE];
|
|
if (!sessionId) {
|
|
sessionId = crypto.randomUUID();
|
|
res.append(
|
|
'Set-Cookie',
|
|
`${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`,
|
|
);
|
|
}
|
|
return sessionId;
|
|
}
|
|
|
|
function recordPlazaEventsAsync(eventsService, req, res, events) {
|
|
if (!eventsService || !Array.isArray(events) || events.length === 0) return;
|
|
const sessionId = resolvePlazaSessionId(req, res);
|
|
void eventsService
|
|
.recordEvents({
|
|
userId: req.currentUser?.id ?? null,
|
|
sessionId,
|
|
events,
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function reactionEventType(type) {
|
|
if (type === 'like' || type === 'collect' || type === 'share') return type;
|
|
return null;
|
|
}
|
|
|
|
function plazaRouteError(res, req, error, { sendError }) {
|
|
const status = mapPlazaError(error);
|
|
const code = error?.code ?? 'internal_error';
|
|
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
|
|
return sendError(res, req, status, code, message, error?.details);
|
|
}
|
|
|
|
export function attachPlazaApiRoutes(api, plaza, { sendData, sendError }) {
|
|
const { posts, interactions, events, seo, ops, redis } = plaza;
|
|
|
|
function ensurePosts(res, req) {
|
|
if (!posts) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; }
|
|
return true;
|
|
}
|
|
function ensureInteractions(res, req) {
|
|
if (!interactions) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; }
|
|
return true;
|
|
}
|
|
function routeError(res, req, error) {
|
|
return plazaRouteError(res, req, error, { sendError });
|
|
}
|
|
|
|
function makeRequireOps(minRole = 'reviewer') {
|
|
return async (req, res, next) => {
|
|
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
const role = await ops.loadOperatorRole(req.currentUser.id);
|
|
if (!hasOpsRole(role, minRole)) {
|
|
return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限');
|
|
}
|
|
req.opsRole = role;
|
|
return next();
|
|
};
|
|
}
|
|
|
|
// ---- Plaza feed & posts ----
|
|
|
|
api.get('/plaza/v1/categories', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
try {
|
|
return sendData(res, req, { categories: await posts.listCategories() });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/plaza/v1/seo/sitemap', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用');
|
|
try {
|
|
const data = await seo.listSitemapData({
|
|
postLimit: req.query.post_limit,
|
|
userLimit: req.query.user_limit,
|
|
});
|
|
return sendData(res, req, data);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/attribution/events', async (req, res) => {
|
|
if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
|
try {
|
|
const result = await seo.recordAttribution(req.body ?? {}, plazaClientIp(req));
|
|
return sendData(res, req, result, 201);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/posts/:id/reports', async (req, res) => {
|
|
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const report = await ops.createReport(req.currentUser.id, {
|
|
target_type: 'post',
|
|
target_id: req.params.id,
|
|
reason: req.body?.reason,
|
|
detail: req.body?.detail,
|
|
});
|
|
return sendData(res, req, { report }, 201);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/comments/:id/reports', async (req, res) => {
|
|
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const report = await ops.createReport(req.currentUser.id, {
|
|
target_type: 'comment',
|
|
target_id: req.params.id,
|
|
reason: req.body?.reason,
|
|
detail: req.body?.detail,
|
|
});
|
|
return sendData(res, req, { report }, 201);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/plaza/v1/feed', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
try {
|
|
const sessionId = resolvePlazaSessionId(req, res);
|
|
const feed = await posts.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 routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/events', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
if (!events) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
|
try {
|
|
const sessionId =
|
|
String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(req, res);
|
|
const result = await events.recordEvents({
|
|
userId: req.currentUser?.id ?? null,
|
|
sessionId,
|
|
events: req.body?.events ?? [],
|
|
});
|
|
return sendData(res, req, { ...result, session_id: sessionId }, 201);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const result = await interactions.addReaction(req.currentUser.id, req.params.id, req.body?.type);
|
|
const eventType = reactionEventType(result.type);
|
|
if (eventType) {
|
|
recordPlazaEventsAsync(events, req, res, [{ event_type: eventType, post_id: req.params.id }]);
|
|
}
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const result = await interactions.removeReaction(req.currentUser.id, req.params.id, req.params.type);
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/plaza/v1/posts/:id/comments', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
try {
|
|
const comments = await interactions.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 routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const comment = await interactions.createComment(req.currentUser.id, req.params.id, req.body ?? {});
|
|
recordPlazaEventsAsync(events, req, res, [{ event_type: 'comment', post_id: req.params.id }]);
|
|
return sendData(res, req, { comment }, 201);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.delete('/plaza/v1/comments/:id', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const comment = await interactions.deleteComment(req.currentUser.id, req.params.id);
|
|
return sendData(res, req, { comment });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/comments/:id/reactions', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const liked = req.body?.liked !== false;
|
|
const result = await interactions.toggleCommentLike(req.currentUser.id, req.params.id, liked);
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/plaza/v1/users/:slug', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
try {
|
|
const profile = await interactions.getUserProfile(req.params.slug, req.currentUser?.id ?? null);
|
|
return sendData(res, req, profile);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/plaza/v1/users/:slug/posts', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
try {
|
|
const feed = await interactions.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 routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/users/:slug/follow', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const result = await interactions.followUser(req.currentUser.id, req.params.slug);
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.delete('/plaza/v1/users/:slug/follow', async (req, res) => {
|
|
if (!ensureInteractions(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const result = await interactions.unfollowUser(req.currentUser.id, req.params.slug);
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.get('/plaza/v1/posts/:id', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
try {
|
|
const post = await posts.getPostById(req.params.id, {
|
|
viewerId: req.currentUser?.id ?? null,
|
|
});
|
|
void redis.recordView(req.params.id, plazaClientIp(req)).catch(() => {});
|
|
recordPlazaEventsAsync(events, req, res, [{ event_type: 'view', post_id: req.params.id }]);
|
|
return sendData(res, req, { post });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.post('/plaza/v1/posts', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const post = await posts.createPost(req.currentUser.id, req.body ?? {});
|
|
return sendData(res, req, { post }, 201);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.patch('/plaza/v1/posts/:id', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const post = await posts.updatePost(req.currentUser.id, req.params.id, req.body ?? {});
|
|
return sendData(res, req, { post });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.delete('/plaza/v1/posts/:id', async (req, res) => {
|
|
if (!ensurePosts(res, req)) return;
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
try {
|
|
const post = await posts.hidePost(req.currentUser.id, req.params.id);
|
|
return sendData(res, req, { post });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
// ---- Ops API ----
|
|
|
|
const opsApi = express.Router();
|
|
opsApi.use(express.json({ limit: '1mb' }));
|
|
opsApi.use(makeRequireOps('reviewer'));
|
|
|
|
opsApi.get('/categories', async (req, res) => {
|
|
try {
|
|
return sendData(res, req, { categories: await ops.listCategories() });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/review/queue', async (req, res) => {
|
|
try {
|
|
const queue = await ops.listReviewQueue({
|
|
status: req.query.status ?? 'pending_review',
|
|
cursor: req.query.cursor ?? null,
|
|
limit: req.query.limit,
|
|
keyword: req.query.keyword ?? null,
|
|
category: req.query.category ?? null,
|
|
});
|
|
return sendData(res, req, queue);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/review/posts/:id', async (req, res) => {
|
|
try {
|
|
const result = await ops.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, {
|
|
reason: req.body?.reason ?? null,
|
|
});
|
|
return sendData(res, req, { post: result });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
const result = await ops.batchReviewPosts(req.currentUser.id, req.body ?? {});
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/reports', async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await ops.listReports({ status: req.query.status ?? 'pending' }));
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/reports/:id/process', async (req, res) => {
|
|
try {
|
|
const result = await ops.processReport(req.currentUser.id, req.params.id, req.body ?? {});
|
|
return sendData(res, req, { report: result });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await ops.listFeatured());
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
const result = await ops.setFeatured(req.currentUser.id, req.body ?? {});
|
|
return sendData(res, req, { featured: result }, 201);
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
const result = await ops.removeFeatured(req.currentUser.id, req.params.id);
|
|
return sendData(res, req, { featured: result });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await ops.getAnalyticsOverview());
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/creators', async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await ops.listCreators({ keyword: req.query.keyword ?? null }));
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.patch('/creators/:userId', async (req, res) => {
|
|
try {
|
|
const result = await ops.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {});
|
|
return sendData(res, req, { creator: result });
|
|
} catch (error) {
|
|
return routeError(res, req, error);
|
|
}
|
|
});
|
|
|
|
api.use('/ops/v1', opsApi);
|
|
}
|
|
|
|
export function attachPlazaAdminRoutes(adminApi, plaza, { sendError }) {
|
|
const { posts, ops } = plaza;
|
|
|
|
adminApi.get('/plaza/pending', async (_req, res) => {
|
|
if (!posts) return res.status(503).json({ message: 'Plaza 未启用' });
|
|
const plazaPosts = await posts.listPendingPosts();
|
|
res.json({ data: { posts: plazaPosts } });
|
|
});
|
|
|
|
adminApi.post('/plaza/posts/:id/review', async (req, res) => {
|
|
if (!posts) return res.status(503).json({ message: 'Plaza 未启用' });
|
|
try {
|
|
const action = String(req.body?.action ?? 'approve');
|
|
const reason = req.body?.reason ?? null;
|
|
const result =
|
|
ops && req.currentUser?.id
|
|
? await ops.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason })
|
|
: await posts.reviewPost(req.params.id, action, { reason });
|
|
res.json({ data: result });
|
|
} catch (error) {
|
|
const status = mapPlazaError(error);
|
|
res.status(status).json({
|
|
error: { code: error?.code ?? 'review_failed', message: error.message },
|
|
});
|
|
}
|
|
});
|
|
}
|