This commit is contained in:
2025-12-17 13:33:31 -05:00
parent e18aa3f42c
commit c49bfe5a3d
38 changed files with 2436 additions and 436 deletions

View File

@@ -7,14 +7,14 @@ import Root from "@/components/AppLayout.jsx";
const user = Astro.locals.user as any;
---
<Base>
<section class="page-section trip-section">
<Root child="qs2.MediaRequestForm" client:only="react" />
<section class="page-section trip-section" transition:animate="none">
<Root child="qs2.MediaRequestForm" client:only="react" transition:persist />
</section>
</Base>
<style is:global>
/* Override main container width for TRip pages */
body:has(.trip-section) main.page-enter {
body:has(.trip-section) main {
max-width: 1400px !important;
}
</style>

View File

@@ -7,14 +7,14 @@ import Root from "@/components/AppLayout.jsx";
const user = Astro.locals.user as any;
---
<Base>
<section class="page-section trip-section">
<Root child="qs2.RequestManagement" client:only="react" />
<section class="page-section trip-section" transition:animate="none">
<Root child="qs2.RequestManagement" client:only="react" transition:persist />
</section>
</Base>
<style is:global>
/* Override main container width for TRip pages */
html:has(.trip-section) main.page-enter {
html:has(.trip-section) main {
max-width: 1400px !important;
width: 100% !important;
}

View File

@@ -0,0 +1,125 @@
/**
* Serve cached videos stored on disk (or by source_url) for Discord attachments/embeds
* Security: uses HMAC signature on id to prevent enumeration and requires id-based lookups to include a valid signature.
*/
import sql from '../../../utils/db.js';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { Readable } from 'stream';
import { checkRateLimit, recordRequest } from '../../../utils/rateLimit.js';
const VIDEO_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET; // share same secret for simplicity
if (!VIDEO_CACHE_SECRET) {
console.error('WARNING: IMAGE_CACHE_SECRET not set, video signing may be unavailable');
}
export function signVideoId(videoId) {
if (!VIDEO_CACHE_SECRET) throw new Error('VIDEO_CACHE_SECRET not configured');
const hmac = crypto.createHmac('sha256', VIDEO_CACHE_SECRET);
hmac.update(String(videoId));
return hmac.digest('hex').substring(0, 16);
}
function verifySignature(id, signature) {
if (!VIDEO_CACHE_SECRET || !signature) return false;
const expected = signVideoId(id);
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}
// Helper to stream a file with range support
function streamFile(filePath, rangeHeader) {
// Ensure file exists
if (!fs.existsSync(filePath)) return { status: 404 };
const stat = fs.statSync(filePath);
const total = stat.size;
if (!rangeHeader) {
const nodeStream = fs.createReadStream(filePath);
const stream = Readable.toWeb(nodeStream);
return { status: 200, stream, total, start: 0, end: total - 1 };
}
// Parse range header
const match = /bytes=(\d*)-(\d*)/.exec(rangeHeader);
if (!match) return { status: 416 };
let start = match[1] === '' ? 0 : parseInt(match[1], 10);
let end = match[2] === '' ? total - 1 : parseInt(match[2], 10);
if (isNaN(start) || isNaN(end) || start > end || start >= total) return { status: 416 };
const nodeStream = fs.createReadStream(filePath, { start, end });
const stream = Readable.toWeb(nodeStream);
return { status: 206, stream, total, start, end };
}
export async function GET({ request }) {
const rateCheck = checkRateLimit(request, { limit: 50, windowMs: 1000, burstLimit: 200, burstWindowMs: 10_000 });
if (!rateCheck.allowed) return new Response('Rate limit exceeded', { status: 429, headers: { 'Retry-After': '1' } });
recordRequest(request, 1000);
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
const signature = url.searchParams.get('sig');
const sourceUrl = url.searchParams.get('url');
if (!id && !sourceUrl) return new Response('Missing id or url parameter', { status: 400 });
// If id-based, require signature
if (id && !verifySignature(id, signature)) {
return new Response('Invalid or missing signature', { status: 403 });
}
let row;
if (id) {
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE video_id = ${id}`;
row = r[0];
} else {
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE source_url = ${sourceUrl} LIMIT 1`;
row = r[0];
}
if (!row) return new Response('Video not found in cache', { status: 404 });
// If file_path is present, stream from disk
if (row.file_path) {
// Protect against directory traversal by resolving path and ensuring it's under configured storage
const storageRoot = '/storage';
const resolved = path.resolve(row.file_path);
if (!resolved.startsWith(storageRoot)) {
console.error('[cached-video] file_path outside storage root', resolved);
return new Response('Forbidden', { status: 403 });
}
const range = request.headers.get('range');
const result = streamFile(resolved, range);
if (result.status === 404) return new Response('File not found', { status: 404 });
if (result.status === 416) return new Response('Range Not Satisfiable', { status: 416 });
const headers = new Headers();
headers.set('Content-Type', row.content_type || 'video/mp4');
headers.set('Accept-Ranges', 'bytes');
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
if (result.status === 200) {
headers.set('Content-Length', String(result.total));
return new Response(result.stream, { status: 200, headers });
}
// Partial content
headers.set('Content-Range', `bytes ${result.start}-${result.end}/${result.total}`);
headers.set('Content-Length', String(result.end - result.start + 1));
return new Response(result.stream, { status: 206, headers });
}
// No file_path - fallback to proxying source_url (but this endpoint expects id-based cache)
return new Response(JSON.stringify({ error: 'No cached file available' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
} catch (err) {
console.error('cached-video error', err);
return new Response('Server error', { status: 500 });
}
}

View File

@@ -7,6 +7,7 @@ import {
checkRateLimit,
recordRequest,
} from '../../../utils/rateLimit.js';
import { signImageId } from './cached-image.js';
export async function GET({ request }) {
// Rate limit check
@@ -69,6 +70,7 @@ export async function GET({ request }) {
c.guild_id,
g.name as guild_name,
g.icon_url as guild_icon,
g.cached_icon_id as guild_cached_icon_id,
COALESCE(cc.message_count, 0) as message_count
FROM channels c
LEFT JOIN guilds g ON c.guild_id = g.guild_id
@@ -97,6 +99,7 @@ export async function GET({ request }) {
c.guild_id,
g.name as guild_name,
g.icon_url as guild_icon,
g.cached_icon_id as guild_cached_icon_id,
COALESCE(cc.message_count, 0) as message_count
FROM channels c
LEFT JOIN guilds g ON c.guild_id = g.guild_id
@@ -106,19 +109,31 @@ export async function GET({ request }) {
`;
}
// Get base URL for cached image URLs
const baseUrl = new URL(request.url).origin;
// Transform to expected format
const formattedChannels = channels.map(ch => ({
id: ch.channel_id.toString(),
name: ch.name,
type: ch.type,
position: ch.position,
parentId: ch.parent_id?.toString() || null,
topic: ch.topic || null,
guildId: ch.guild_id?.toString() || null,
guildName: ch.guild_name,
guildIcon: ch.guild_icon,
messageCount: parseInt(ch.message_count) || 0,
}));
const formattedChannels = channels.map(ch => {
// Use cached icon URL if available, otherwise fall back to Discord CDN
let guildIcon = ch.guild_icon;
if (ch.guild_cached_icon_id) {
const sig = signImageId(ch.guild_cached_icon_id);
guildIcon = `${baseUrl}/api/discord/cached-image?id=${ch.guild_cached_icon_id}&sig=${sig}`;
}
return {
id: ch.channel_id.toString(),
name: ch.name,
type: ch.type,
position: ch.position,
parentId: ch.parent_id?.toString() || null,
topic: ch.topic || null,
guildId: ch.guild_id?.toString() || null,
guildName: ch.guild_name,
guildIcon,
messageCount: parseInt(ch.message_count) || 0,
};
});
return createApiResponse(formattedChannels, 200, setCookieHeader);
} catch (error) {

View File

@@ -160,13 +160,14 @@ export async function GET({ request }) {
if (authError) return authError;
// Helper to create responses with optional Set-Cookie header
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
},
});
const createResponse = (data, status = 200) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (setCookieHeader) {
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
for (const c of cookies) if (c) headers.append('Set-Cookie', c.trim());
}
return new Response(JSON.stringify(data), { status, headers });
};
// Helper to validate Discord snowflake IDs (17-20 digit strings)
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);

View File

@@ -9,6 +9,7 @@ import {
recordRequest,
} from '../../../utils/rateLimit.js';
import { signImageId } from './cached-image.js';
import { signVideoId } from './cached-video.js';
import crypto from 'crypto';
const IMAGE_PROXY_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
@@ -118,13 +119,16 @@ export async function GET({ request }) {
if (authError) return authError;
// Helper to create responses with optional Set-Cookie header
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
},
});
const createResponse = (data, status = 200) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (setCookieHeader) {
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
for (const c of cookies) {
if (c) headers.append('Set-Cookie', c.trim());
}
}
return new Response(JSON.stringify(data), { status, headers });
};
// Helper to validate Discord snowflake IDs (17-20 digit strings)
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
@@ -137,6 +141,7 @@ export async function GET({ request }) {
const after = url.searchParams.get('after');
const around = url.searchParams.get('around'); // For jumping to specific message
const editedSince = url.searchParams.get('editedSince'); // ISO timestamp for fetching recently edited messages
const oldest = url.searchParams.get('oldest') === 'true'; // Fetch oldest messages first
const baseUrl = `${url.protocol}//${url.host}`;
@@ -325,6 +330,7 @@ export async function GET({ request }) {
(
SELECT
m.message_id,
m.raw_data,
m.message_type,
m.content,
m.created_at,
@@ -362,6 +368,46 @@ export async function GET({ request }) {
)
ORDER BY created_at ASC
`;
} else if (oldest) {
// Fetch oldest messages first
messages = await sql`
SELECT
m.message_id,
m.raw_data,
m.message_type,
m.content,
m.created_at,
m.edited_at,
m.reference_message_id,
m.channel_id,
m.webhook_id,
m.reference_guild_id,
c.guild_id as message_guild_id,
u.user_id as author_id,
u.username as author_username,
u.discriminator as author_discriminator,
u.avatar_url as author_avatar,
u.is_bot as author_bot,
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
(
SELECT r.color
FROM roles r
WHERE r.role_id = ANY(gm.roles)
AND r.color IS NOT NULL
AND r.color != 0
ORDER BY r.position DESC
LIMIT 1
) as author_color
FROM messages m
LEFT JOIN users u ON m.author_id = u.user_id
LEFT JOIN channels c ON m.channel_id = c.channel_id
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
WHERE m.channel_id = ${channelId}
AND m.is_deleted = FALSE
ORDER BY m.created_at ASC
LIMIT ${limit}
`;
} else {
messages = await sql`
SELECT
@@ -484,10 +530,13 @@ export async function GET({ request }) {
// Fetch attachments for all messages
const attachments = await sql`
SELECT
attachment_id, message_id, filename, url, proxy_url,
content_type, size, width, height, cached_image_id
FROM attachments
WHERE message_id = ANY(${messageIds})
a.attachment_id, a.message_id, a.filename, a.url, a.proxy_url,
a.content_type, a.size, a.width, a.height, a.cached_image_id,
a.cached_video_id,
vc.file_path as video_file_path
FROM attachments a
LEFT JOIN video_cache vc ON a.cached_video_id = vc.video_id
WHERE a.message_id = ANY(${messageIds})
`;
// Fetch embeds for all messages
@@ -542,23 +591,27 @@ export async function GET({ request }) {
}
// Fetch reactions for all messages (aggregate counts since each reaction is a row)
// Join with emojis table to get cached_image_id for custom emojis
const reactions = await sql`
SELECT
message_id, emoji_id, emoji_name, emoji_animated,
COUNT(*) FILTER (WHERE is_removed = FALSE) as count
FROM reactions
WHERE message_id = ANY(${messageIds})
AND is_removed = FALSE
GROUP BY message_id, emoji_id, emoji_name, emoji_animated
r.message_id, r.emoji_id, r.emoji_name, r.emoji_animated,
e.cached_image_id as emoji_cached_image_id,
COUNT(*) FILTER (WHERE r.is_removed = FALSE) as count
FROM reactions r
LEFT JOIN emojis e ON r.emoji_id = e.emoji_id
WHERE r.message_id = ANY(${messageIds})
AND r.is_removed = FALSE
GROUP BY r.message_id, r.emoji_id, r.emoji_name, r.emoji_animated, e.cached_image_id
`;
// Fetch stickers for all messages
// Fetch stickers for all messages (include cached_image_id for locally cached stickers)
const stickers = await sql`
SELECT
ms.message_id,
s.sticker_id,
s.name,
s.format_type
s.format_type,
s.cached_image_id
FROM message_stickers ms
JOIN stickers s ON ms.sticker_id = s.sticker_id
WHERE ms.message_id = ANY(${messageIds})
@@ -595,6 +648,44 @@ export async function GET({ request }) {
ORDER BY pa.answer_id
`;
// Extract all custom emoji IDs from message content to look up cached images
const allEmojiIds = new Set();
for (const msg of messages) {
if (msg.content) {
// Match <:name:id> and <a:name:id> patterns
const matches = msg.content.matchAll(/<a?:\w+:(\d+)>/g);
for (const match of matches) {
allEmojiIds.add(match[1]);
}
}
}
// Also include poll emojis
for (const poll of polls) {
if (poll.question_emoji_id) allEmojiIds.add(String(poll.question_emoji_id));
}
for (const answer of pollAnswers) {
if (answer.answer_emoji_id) allEmojiIds.add(String(answer.answer_emoji_id));
}
// Fetch emoji cache info for all referenced emojis
let emojiCacheMap = {};
const emojiIdArray = Array.from(allEmojiIds);
if (emojiIdArray.length > 0) {
const emojiCacheRows = await sql`
SELECT emoji_id, emoji_animated, cached_image_id
FROM emojis
WHERE emoji_id = ANY(${emojiIdArray}::bigint[])
AND cached_image_id IS NOT NULL
`;
for (const row of emojiCacheRows) {
const sig = signImageId(row.cached_image_id);
emojiCacheMap[String(row.emoji_id)] = {
url: `${baseUrl}/api/discord/cached-image?id=${row.cached_image_id}&sig=${sig}`,
animated: row.emoji_animated,
};
}
}
// Fetch poll votes with user info
const pollMessageIds = polls.map(p => p.message_id);
let pollVotes = [];
@@ -640,6 +731,55 @@ export async function GET({ request }) {
`;
}
// Build a map of video cache entries for any attachment/embed video URLs
const candidateVideoUrls = [];
for (const att of attachments) {
if (att.content_type?.startsWith('video/')) {
candidateVideoUrls.push(att.url || att.proxy_url);
}
}
for (const emb of embeds) {
if (emb.video_url) candidateVideoUrls.push(emb.video_url);
}
// Query video_cache for any matching source_url values
// Helper: normalize video source URL by stripping query/hash (Discord adds signatures)
const normalizeVideoSrc = (url) => {
if (!url) return url;
try {
const u = new URL(url);
return `${u.origin}${u.pathname}`;
} catch {
return url.split('#')[0].split('?')[0];
}
};
let videoCacheRows = [];
if (candidateVideoUrls.length > 0) {
// dedupe and normalize (strip query params since Discord adds signatures)
const uniqueUrls = Array.from(new Set(candidateVideoUrls.filter(Boolean).map(normalizeVideoSrc)));
if (uniqueUrls.length > 0) {
// Query all video cache entries that start with any of our normalized URLs
// Since source_url may have query params, we use LIKE prefix matching
const likePatterns = uniqueUrls.map(u => u + '%');
videoCacheRows = await sql`
SELECT video_id, source_url, file_path, content_type, is_youtube, youtube_id
FROM video_cache
WHERE source_url LIKE ANY(${likePatterns})
`;
}
}
// Map video cache rows by source_url for quick lookup
const videoCacheBySource = {};
for (const r of videoCacheRows) {
if (r?.source_url) {
videoCacheBySource[r.source_url] = r;
const normalized = normalizeVideoSrc(r.source_url);
videoCacheBySource[normalized] = r;
}
}
// Index related data by message_id for quick lookup
const attachmentsByMessage = {};
const embedsByMessage = {};
@@ -653,11 +793,40 @@ export async function GET({ request }) {
if (!attachmentsByMessage[att.message_id]) {
attachmentsByMessage[att.message_id] = [];
}
// For videos use cached video if available; otherwise use direct URL
const isVideo = att.content_type?.startsWith('video/');
let attachmentUrl;
if (isVideo) {
// Use cached_video_id from the JOIN if available
if (att.cached_video_id && att.video_file_path) {
const sig = signVideoId(att.cached_video_id);
attachmentUrl = `${baseUrl}/api/discord/cached-video?id=${att.cached_video_id}&sig=${sig}`;
} else {
// Fallback to URL-based lookup in videoCacheBySource
const srcRaw = att.url || att.proxy_url;
const src = normalizeVideoSrc(srcRaw);
const cached = videoCacheBySource[src];
if (cached && cached.file_path) {
const sig = signVideoId(cached.video_id);
attachmentUrl = `${baseUrl}/api/discord/cached-video?id=${cached.video_id}&sig=${sig}`;
} else {
// Fallback: use direct URL (Discord CDN) when no cache entry
attachmentUrl = srcRaw;
}
}
} else {
// For images prefer cached image or proxy
attachmentUrl = getCachedOrProxyUrl(att.cached_image_id, att.url || att.proxy_url, baseUrl);
}
attachmentsByMessage[att.message_id].push({
id: att.attachment_id.toString(),
filename: att.filename,
url: getCachedOrProxyUrl(att.cached_image_id, att.url || att.proxy_url, baseUrl),
contentType: att.content_type,
url: attachmentUrl,
// Keep original/origin URL so the client can de-dupe previews when we return a cached URL
originalUrl: att.url || att.proxy_url || null,
content_type: att.content_type,
size: att.size,
width: att.width,
height: att.height,
@@ -689,11 +858,16 @@ export async function GET({ request }) {
width: embed.thumbnail_width,
height: embed.thumbnail_height,
} : null,
video: embed.video_url ? {
url: embed.video_url, // Video URLs usually need to be direct for embedding
width: embed.video_width,
height: embed.video_height,
} : null,
video: embed.video_url ? (() => {
const src = normalizeVideoSrc(embed.video_url);
const cached = videoCacheBySource[src];
if (cached && cached.file_path) {
const sig = signVideoId(cached.video_id);
return { url: `${baseUrl}/api/discord/cached-video?id=${cached.video_id}&sig=${sig}`, width: embed.video_width, height: embed.video_height };
}
// fallback to original url
return { url: embed.video_url, width: embed.video_width, height: embed.video_height };
})() : null,
provider: embed.provider_name ? {
name: embed.provider_name,
url: embed.provider_url,
@@ -712,10 +886,25 @@ export async function GET({ request }) {
if (!reactionsByMessage[reaction.message_id]) {
reactionsByMessage[reaction.message_id] = [];
}
// Build emoji object with optional cached image URL for custom emojis
let emojiObj;
if (reaction.emoji_id) {
// Custom emoji - use cached image if available, otherwise construct Discord CDN URL
let emojiUrl;
if (reaction.emoji_cached_image_id) {
const sig = signImageId(reaction.emoji_cached_image_id);
emojiUrl = `${baseUrl}/api/discord/cached-image?id=${reaction.emoji_cached_image_id}&sig=${sig}`;
} else {
const ext = reaction.emoji_animated ? 'gif' : 'png';
emojiUrl = `https://cdn.discordapp.com/emojis/${reaction.emoji_id}.${ext}?size=32`;
}
emojiObj = { id: reaction.emoji_id.toString(), name: reaction.emoji_name, animated: reaction.emoji_animated, url: emojiUrl };
} else {
// Standard Unicode emoji
emojiObj = { name: reaction.emoji_name };
}
reactionsByMessage[reaction.message_id].push({
emoji: reaction.emoji_id
? { id: reaction.emoji_id.toString(), name: reaction.emoji_name, animated: reaction.emoji_animated }
: { name: reaction.emoji_name },
emoji: emojiObj,
count: parseInt(reaction.count, 10),
});
}
@@ -727,11 +916,19 @@ export async function GET({ request }) {
stickersByMessage[sticker.message_id] = [];
}
const ext = stickerExtensions[sticker.format_type] || 'png';
// Use cached sticker image if available, otherwise fall back to Discord CDN
let stickerUrl;
if (sticker.cached_image_id) {
const sig = signImageId(sticker.cached_image_id);
stickerUrl = `${baseUrl}/api/discord/cached-image?id=${sticker.cached_image_id}&sig=${sig}`;
} else {
stickerUrl = `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`;
}
stickersByMessage[sticker.message_id].push({
id: sticker.sticker_id.toString(),
name: sticker.name,
formatType: sticker.format_type,
url: `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`,
url: stickerUrl,
});
}
@@ -908,7 +1105,7 @@ export async function GET({ request }) {
};
});
return createResponse({ messages: formattedMessages, users: usersMap });
return createResponse({ messages: formattedMessages, users: usersMap, emojiCache: emojiCacheMap });
} catch (error) {
console.error('Error fetching messages:', error);
return createResponse({ error: 'Failed to fetch messages' }, 500);

View File

@@ -34,13 +34,14 @@ export async function GET({ request }) {
if (authError) return authError;
// Helper to create responses with optional Set-Cookie header
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
},
});
const createResponse = (data, status = 200) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (setCookieHeader) {
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
for (const c of cookies) if (c) headers.append('Set-Cookie', c.trim());
}
return new Response(JSON.stringify(data), { status, headers });
};
// Helper to validate Discord snowflake IDs (17-20 digit strings)
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
@@ -73,14 +74,15 @@ export async function GET({ request }) {
let users;
if (emojiId) {
users = await sql`
SELECT
SELECT DISTINCT ON (u.user_id)
u.user_id,
u.username,
u.avatar_url,
u.cached_avatar_id,
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
COALESCE(gm.guild_avatar_url, u.avatar_url) as guild_avatar,
gm.cached_guild_avatar_id
gm.cached_guild_avatar_id,
r.added_at
FROM reactions r
JOIN users u ON r.user_id = u.user_id
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
@@ -88,18 +90,19 @@ export async function GET({ request }) {
AND r.emoji_name = ${emojiName}
AND r.emoji_id = ${emojiId}
AND r.is_removed = FALSE
ORDER BY r.added_at ASC
ORDER BY u.user_id, r.added_at ASC
`;
} else {
users = await sql`
SELECT
SELECT DISTINCT ON (u.user_id)
u.user_id,
u.username,
u.avatar_url,
u.cached_avatar_id,
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
COALESCE(gm.guild_avatar_url, u.avatar_url) as guild_avatar,
gm.cached_guild_avatar_id
gm.cached_guild_avatar_id,
r.added_at
FROM reactions r
JOIN users u ON r.user_id = u.user_id
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
@@ -107,7 +110,7 @@ export async function GET({ request }) {
AND r.emoji_name = ${emojiName}
AND r.emoji_id IS NULL
AND r.is_removed = FALSE
ORDER BY r.added_at ASC
ORDER BY u.user_id, r.added_at ASC
`;
}

View File

@@ -9,12 +9,47 @@ import {
recordRequest,
getCookieId,
} from '../../../utils/rateLimit.js';
import { signVideoId } from './cached-video.js';
import { signImageId } from './cached-image.js';
// Escape LIKE/ILIKE metacharacters to prevent pattern injection
/**
* Escapes special characters in a string for safe use in SQL LIKE/ILIKE queries.
*
* This function prevents SQL injection and pattern manipulation by escaping
* the LIKE/ILIKE metacharacters: %, _, and \. It should be used on any user input
* that will be interpolated into a LIKE or ILIKE pattern.
*
* @param {string} str - The input string to escape.
* @returns {string} The escaped string, safe for use in LIKE/ILIKE patterns.
*/
function escapeLikePattern(str) {
return str.replace(/[%_\\]/g, '\\$&');
}
/**
* Normalize video URL by removing query parameters and hash fragments for cache lookup
*/
function normalizeVideoUrl(url) {
if (!url) return url;
try {
const urlObj = new URL(url);
return urlObj.origin + urlObj.pathname;
} catch {
return url;
}
}
/**
* Returns cached image URL if available, otherwise returns proxy URL
*/
function getCachedOrProxyUrl(cachedImageId, proxyUrl) {
if (cachedImageId) {
const sig = signImageId(cachedImageId);
return `/api/discord/cached-image?id=${cachedImageId}&sig=${sig}`;
}
return proxyUrl;
}
export async function GET({ request }) {
// Rate limit check for authenticated endpoints
const rateCheck = checkRateLimit(request, {
@@ -41,13 +76,14 @@ export async function GET({ request }) {
if (authError) return authError;
// Helper to create responses with optional Set-Cookie header
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
},
});
const createResponse = (data, status = 200) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (setCookieHeader) {
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
for (const c of cookies) if (c) headers.append('Set-Cookie', c.trim());
}
return new Response(JSON.stringify(data), { status, headers });
};
// Helper to validate Discord snowflake IDs (17-20 digit strings)
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
@@ -127,23 +163,43 @@ export async function GET({ request }) {
// Get message IDs for fetching related data
const messageIds = messages.map(m => m.message_id);
// Fetch attachments
// Fetch attachments with cached video info via JOIN
const attachments = await sql`
SELECT
message_id,
attachment_id,
filename,
url,
proxy_url,
content_type,
size,
width,
height
FROM attachments
WHERE message_id = ANY(${messageIds})
ORDER BY attachment_id
a.message_id,
a.attachment_id,
a.filename,
a.url,
a.proxy_url,
a.content_type,
a.size,
a.width,
a.height,
a.cached_image_id,
a.cached_video_id,
vc.file_path as video_file_path
FROM attachments a
LEFT JOIN video_cache vc ON a.cached_video_id = vc.video_id
WHERE a.message_id = ANY(${messageIds})
ORDER BY a.attachment_id
`;
// Fetch cached video URLs for video attachments
const videoUrls = attachments
.filter(att => att.content_type && att.content_type.startsWith('video/'))
.map(att => normalizeVideoUrl(att.url));
const cachedVideos = videoUrls.length > 0 ? await sql`
SELECT source_url, video_id
FROM video_cache
WHERE source_url = ANY(${videoUrls})
` : [];
const videoCacheMap = {};
cachedVideos.forEach(v => {
videoCacheMap[v.source_url] = v.video_id;
});
// Fetch embeds
const embeds = await sql`
SELECT
@@ -215,12 +271,41 @@ export async function GET({ request }) {
if (!attachmentsByMessage[att.message_id]) {
attachmentsByMessage[att.message_id] = [];
}
// Determine the best URL to use
let attachmentUrl = att.url;
let attachmentProxyUrl = att.proxy_url;
// Check if this is a video and has a cached version
if (att.content_type && att.content_type.startsWith('video/')) {
// Use cached_video_id from the JOIN if available
if (att.cached_video_id && att.video_file_path) {
const sig = signVideoId(att.cached_video_id);
attachmentUrl = `/api/discord/cached-video?id=${att.cached_video_id}&sig=${sig}`;
attachmentProxyUrl = attachmentUrl;
} else {
// Fallback to URL-based lookup
const normalizedUrl = normalizeVideoUrl(att.url);
const cachedVideoId = videoCacheMap[normalizedUrl];
if (cachedVideoId) {
const sig = signVideoId(cachedVideoId);
attachmentUrl = `/api/discord/cached-video?id=${cachedVideoId}&sig=${sig}`;
attachmentProxyUrl = attachmentUrl;
}
}
} else {
// For images, use cached version if available
const cachedUrl = getCachedOrProxyUrl(att.cached_image_id, att.proxy_url);
attachmentUrl = cachedUrl;
attachmentProxyUrl = cachedUrl;
}
attachmentsByMessage[att.message_id].push({
id: att.attachment_id,
filename: att.filename,
url: att.url,
proxyUrl: att.proxy_url,
contentType: att.content_type,
url: attachmentUrl,
proxyUrl: attachmentProxyUrl,
content_type: att.content_type,
size: att.size,
width: att.width,
height: att.height,

View File

@@ -7,6 +7,7 @@ import {
createNonceCookie,
getClientIp,
} from '../../utils/rateLimit.js';
import { validateCsrfToken, generateCsrfToken } from '../../utils/csrf.js';
export async function POST({ request }) {
const host = request.headers.get('host');
@@ -15,6 +16,47 @@ export async function POST({ request }) {
return new Response('Not found', { status: 404 });
}
let cookieId = getCookieId(request);
const hadCookie = !!cookieId;
if (!cookieId) {
cookieId = generateNonce();
}
// Validate CSRF token before rate limiting (to avoid consuming rate limit on invalid requests)
let csrfToken;
try {
const body = await request.json();
csrfToken = body.csrfToken;
if (!validateCsrfToken(csrfToken, cookieId)) {
const response = new Response(JSON.stringify({
error: 'Invalid or expired security token. Please refresh the page and try again.'
}), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
console.log(`[submit] CSRF validation failed: cookieId=${cookieId}`);
return response;
}
// Re-parse body for later use (since we already consumed the stream)
request.bodyData = body;
} catch (error) {
const response = new Response(JSON.stringify({
error: 'Invalid request format'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
// Rate limit check (1 request per 15 seconds, flood protection at 10/30s)
const rateCheck = checkRateLimit(request, {
limit: 1,
@@ -23,12 +65,6 @@ export async function POST({ request }) {
burstWindowMs: 30_000,
});
let cookieId = getCookieId(request);
const hadCookie = !!cookieId;
if (!cookieId) {
cookieId = generateNonce();
}
if (!rateCheck.allowed) {
const errorMsg = rateCheck.isFlooding
? { error: 'Too many requests - please slow down' }
@@ -62,7 +98,8 @@ export async function POST({ request }) {
}
try {
const { title, year, type, requester } = await request.json();
// Use pre-parsed body data
const { title, year, type, requester } = request.bodyData;
// Input validation
if (!title || typeof title !== 'string' || !title.trim()) {
@@ -233,7 +270,10 @@ export async function POST({ request }) {
// Record the request for rate limiting after successful submission
recordRateLimitRequest(request, 15_000);
const response = new Response(JSON.stringify({ success: true }), {
// Generate a new CSRF token for the next submission
const newCsrfToken = generateCsrfToken(cookieId);
const response = new Response(JSON.stringify({ success: true, csrfToken: newCsrfToken }), {
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {

View File

@@ -13,6 +13,18 @@ Astro.response.headers.set('Pragma', 'no-cache');
---
<Base title="Discord Archive" description="Archived Discord channel logs">
<script is:inline>
// Suppress "unreachable code" warnings from YouTube's embedded player
(function() {
const originalWarn = console.warn;
console.warn = function(...args) {
if (args[0] && typeof args[0] === 'string' && args[0].includes('unreachable code')) {
return; // Suppress YouTube player warnings
}
originalWarn.apply(console, args);
};
})();
</script>
<section class="page-section discord-logs-section">
<Root child="DiscordLogs" client:only="react" />
</section>
@@ -20,7 +32,7 @@ Astro.response.headers.set('Pragma', 'no-cache');
<style is:global>
/* Override main container width for Discord logs */
body:has(.discord-logs-section) main.page-enter {
body:has(.discord-logs-section) main {
max-width: 1400px !important;
}
</style>

View File

@@ -5,10 +5,12 @@ import Root from "@/components/AppLayout.jsx";
import { requireAuthHook } from '@/hooks/requireAuthHook';
const user = await requireAuthHook(Astro);
const isLoggedIn = Boolean(user);
const accessDenied = Astro.locals.accessDenied || false;
const requiredRoles = Astro.locals.requiredRoles || [];
---
<Base>
<section class="page-section">
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" />
<Root child="LoginPage" loggedIn={isLoggedIn} accessDenied={accessDenied} requiredRoles={requiredRoles} client:only="react" />
</section>
</Base>

View File

@@ -1,8 +0,0 @@
---
import Base from "../../../layouts/Base.astro";
---
<Base>
<h2>Req subsite debug</h2>
<pre>{JSON.stringify({ headers: Object.fromEntries(Astro.request.headers.entries()), pathname: Astro.url.pathname }, null, 2)}</pre>
</Base>

View File

@@ -1,8 +1,30 @@
---
import Base from "../../../layouts/Base.astro";
import ReqForm from "../../../components/req/ReqForm.jsx";
import SubsiteBase from "../../../layouts/SubsiteBase.astro";
import SubsiteRoot from "../../../components/SubsiteAppLayout.jsx";
import { generateCsrfToken } from "../../../utils/csrf.js";
import { getCookieId, generateNonce, createNonceCookie } from "../../../utils/rateLimit.js";
// Generate CSRF token during SSR
let cookieId = getCookieId(Astro.request);
const hadCookie = !!cookieId;
if (!cookieId) {
cookieId = generateNonce();
}
const csrfToken = generateCsrfToken(cookieId);
// Set cookie if new session
if (!hadCookie) {
Astro.response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
---
<Base>
<ReqForm client:load />
</Base>
<SubsiteBase title="Request Media">
<script is:inline define:vars={{ csrfToken }}>
(function(w,d){w[d]=atob(btoa(csrfToken))})(window,'_t');
</script>
<section class="page-section">
<SubsiteRoot child="ReqForm" client:only="react" />
</section>
</SubsiteBase>