misc
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
125
src/pages/api/discord/cached-video.js
Normal file
125
src/pages/api/discord/cached-video.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user