2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
146 lines
4.6 KiB
JavaScript
146 lines
4.6 KiB
JavaScript
import crypto from 'node:crypto';
|
|
|
|
const SYNC_SET = 'plaza:sync:post_ids';
|
|
const FEED_GEN_KEY = 'plaza:feed:gen';
|
|
|
|
function hashIp(ip) {
|
|
return crypto.createHash('sha256').update(String(ip ?? 'unknown')).digest('hex').slice(0, 16);
|
|
}
|
|
|
|
function counterKey(postId, field) {
|
|
return `plaza:post:${postId}:${field}`;
|
|
}
|
|
|
|
function viewDedupKey(postId, ipHash) {
|
|
return `plaza:view:${ipHash}:${postId}`;
|
|
}
|
|
|
|
function feedCacheKey(gen, sort, categorySlug, cursor) {
|
|
const category = categorySlug || 'all';
|
|
const page = cursor || 'root';
|
|
return `plaza:feed:${sort}:${category}:cursor:${page}:g${gen}`;
|
|
}
|
|
|
|
export function createNoopPlazaRedis(pool = null) {
|
|
const viewDedup = new Map();
|
|
|
|
return {
|
|
enabled: false,
|
|
async connect() {},
|
|
async disconnect() {},
|
|
async recordView(postId, ip) {
|
|
if (!pool) return { counted: false };
|
|
const ipHash = hashIp(ip);
|
|
const dedupKey = `${ipHash}:${postId}`;
|
|
if (viewDedup.has(dedupKey)) return { counted: false };
|
|
viewDedup.set(dedupKey, Date.now());
|
|
setTimeout(() => viewDedup.delete(dedupKey), 86_400_000).unref?.();
|
|
const now = Date.now();
|
|
const [result] = await pool.query(
|
|
`UPDATE plaza_posts SET view_count = view_count + 1, updated_at = ?
|
|
WHERE id = ? AND status = 'published'`,
|
|
[now, postId],
|
|
);
|
|
return { counted: (result.affectedRows ?? 0) > 0 };
|
|
},
|
|
async getFeedCache() {
|
|
return null;
|
|
},
|
|
async setFeedCache() {},
|
|
async invalidateFeedCaches() {},
|
|
async syncCountersToMySQL() {
|
|
return { synced: 0 };
|
|
},
|
|
async checkCommentRateLimit() {
|
|
return { allowed: true };
|
|
},
|
|
async markCommentRateLimit() {},
|
|
};
|
|
}
|
|
|
|
export async function createPlazaRedis(redisUrl, pool) {
|
|
if (!redisUrl) return createNoopPlazaRedis(pool);
|
|
|
|
const { createClient } = await import('redis');
|
|
const client = createClient({ url: redisUrl });
|
|
client.on('error', (error) => {
|
|
console.error('Plaza Redis error:', error.message);
|
|
});
|
|
await client.connect();
|
|
|
|
const getFeedGen = async () => Number((await client.get(FEED_GEN_KEY)) ?? 0);
|
|
|
|
return {
|
|
enabled: true,
|
|
client,
|
|
async disconnect() {
|
|
if (client.isOpen) await client.disconnect();
|
|
},
|
|
async recordView(postId, ip) {
|
|
const ipHash = hashIp(ip);
|
|
const dedup = viewDedupKey(postId, ipHash);
|
|
const inserted = await client.set(dedup, '1', { NX: true, EX: 86_400 });
|
|
if (!inserted) return { counted: false };
|
|
await client.incr(counterKey(postId, 'view_count'));
|
|
await client.sAdd(SYNC_SET, postId);
|
|
return { counted: true };
|
|
},
|
|
async getFeedCache(sort, categorySlug, cursor) {
|
|
const gen = await getFeedGen();
|
|
const key = feedCacheKey(gen, sort, categorySlug, cursor);
|
|
const raw = await client.get(key);
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
async setFeedCache(sort, categorySlug, cursor, payload, ttlSeconds = 300) {
|
|
const gen = await getFeedGen();
|
|
const key = feedCacheKey(gen, sort, categorySlug, cursor);
|
|
await client.set(key, JSON.stringify(payload), { EX: ttlSeconds });
|
|
},
|
|
async invalidateFeedCaches() {
|
|
await client.incr(FEED_GEN_KEY);
|
|
},
|
|
async syncCountersToMySQL() {
|
|
const postIds = await client.sMembers(SYNC_SET);
|
|
if (postIds.length === 0) return { synced: 0 };
|
|
|
|
let synced = 0;
|
|
const now = Date.now();
|
|
for (const postId of postIds) {
|
|
const delta = Number((await client.get(counterKey(postId, 'view_count'))) ?? 0);
|
|
if (delta <= 0) {
|
|
await client.sRem(SYNC_SET, postId);
|
|
continue;
|
|
}
|
|
await pool.query(
|
|
`UPDATE plaza_posts SET view_count = view_count + ?, updated_at = ? WHERE id = ?`,
|
|
[delta, now, postId],
|
|
);
|
|
await client.decrBy(counterKey(postId, 'view_count'), delta);
|
|
const remaining = Number((await client.get(counterKey(postId, 'view_count'))) ?? 0);
|
|
if (remaining <= 0) {
|
|
await client.del(counterKey(postId, 'view_count'));
|
|
await client.sRem(SYNC_SET, postId);
|
|
}
|
|
synced += 1;
|
|
}
|
|
return { synced };
|
|
},
|
|
async checkCommentRateLimit(userId, postId, maxPerHour = 10) {
|
|
const key = `plaza:comment_rate:${userId}:${postId}`;
|
|
const count = Number((await client.get(key)) ?? 0);
|
|
return { allowed: count < maxPerHour, count };
|
|
},
|
|
async markCommentRateLimit(userId, postId) {
|
|
const key = `plaza:comment_rate:${userId}:${postId}`;
|
|
const count = await client.incr(key);
|
|
if (count === 1) await client.expire(key, 3600);
|
|
return count;
|
|
},
|
|
};
|
|
}
|