feat(api): implement rate limiting and SSRF protection across endpoints
- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse. - Introduced SSRF protection in `image-proxy` to block requests to private IP ranges. - Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction. - Refactored authentication checks in various pages to utilize middleware for cleaner code. - Improved JWT key loading with error handling and security warnings for production. - Updated `authFetch` utility to handle token refresh more efficiently with deduplication. - Enhanced rate limiting utility to trust proxy headers from known sources. - Numerous layout / design changes
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
---
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
// Auth is handled by middleware - user available in Astro.locals.user
|
||||
// Middleware redirects to /login if not authenticated
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="qs2.MediaRequestForm" client:only="react">
|
||||
</Root>
|
||||
<section class="page-section trip-section">
|
||||
<Root child="qs2.MediaRequestForm" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for TRip pages */
|
||||
body:has(.trip-section) main.page-enter {
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
---
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
// Auth is handled by middleware - user available in Astro.locals.user
|
||||
// Middleware redirects to /login if not authenticated
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="qs2.RequestManagement" client:only="react">
|
||||
</Root>
|
||||
<section class="page-section trip-section">
|
||||
<Root child="qs2.RequestManagement" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for TRip pages */
|
||||
html:has(.trip-section) main.page-enter {
|
||||
max-width: 1400px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,16 +2,74 @@
|
||||
* API endpoint to serve cached images from the database
|
||||
* Serves avatars, emojis, attachments, etc. from local cache
|
||||
*
|
||||
* Note: This endpoint is intentionally unauthenticated because:
|
||||
* 1. Image tags don't reliably send auth cookies on initial load
|
||||
* 2. Image IDs are not guessable (you need access to the messages API first)
|
||||
* 3. The underlying Discord images are semi-public anyway
|
||||
* Security: Uses HMAC signatures to prevent enumeration of image IDs.
|
||||
* Images can only be accessed with a valid signature generated server-side.
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
|
||||
// Secret for signing image IDs - prevents enumeration attacks
|
||||
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
|
||||
if (!IMAGE_CACHE_SECRET) {
|
||||
console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC signature for an image ID
|
||||
* @param {string|number} imageId - The image ID to sign
|
||||
* @returns {string} - The hex signature
|
||||
*/
|
||||
export function signImageId(imageId) {
|
||||
if (!IMAGE_CACHE_SECRET) {
|
||||
throw new Error('IMAGE_CACHE_SECRET not configured');
|
||||
}
|
||||
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
||||
hmac.update(String(imageId));
|
||||
return hmac.digest('hex').substring(0, 16); // Short signature is sufficient
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature for an image ID
|
||||
* @param {string|number} imageId - The image ID
|
||||
* @param {string} signature - The signature to verify
|
||||
* @returns {boolean} - Whether signature is valid
|
||||
*/
|
||||
function verifyImageSignature(imageId, signature) {
|
||||
if (!IMAGE_CACHE_SECRET || !signature) return false;
|
||||
const expected = signImageId(imageId);
|
||||
// Timing-safe comparison
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check - higher limit for images but still protected
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 100,
|
||||
windowMs: 1000,
|
||||
burstLimit: 500,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response('Rate limit exceeded', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const imageId = url.searchParams.get('id');
|
||||
const signature = url.searchParams.get('sig');
|
||||
const sourceUrl = url.searchParams.get('url');
|
||||
|
||||
if (!imageId && !sourceUrl) {
|
||||
@@ -23,11 +81,16 @@ export async function GET({ request }) {
|
||||
return new Response('Invalid image id', { status: 400 });
|
||||
}
|
||||
|
||||
// Require valid signature for ID-based lookups to prevent enumeration
|
||||
if (imageId && !verifyImageSignature(imageId, signature)) {
|
||||
return new Response('Invalid or missing signature', { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
let image;
|
||||
|
||||
if (imageId) {
|
||||
// Look up by image_id
|
||||
// Look up by image_id (signature already verified above)
|
||||
const result = await sql`
|
||||
SELECT image_data, content_type, source_url
|
||||
FROM image_cache
|
||||
@@ -35,7 +98,7 @@ export async function GET({ request }) {
|
||||
`;
|
||||
image = result[0];
|
||||
} else {
|
||||
// Look up by source_url
|
||||
// Look up by source_url - no signature needed as URL itself is the identifier
|
||||
const result = await sql`
|
||||
SELECT image_data, content_type, source_url
|
||||
FROM image_cache
|
||||
|
||||
@@ -3,8 +3,31 @@
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth, createApiResponse } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
@@ -24,11 +47,18 @@ export async function GET({ request }) {
|
||||
let channels;
|
||||
|
||||
// Channel types: 0=text, 2=voice, 4=category, 5=announcement, 10/11/12=threads
|
||||
// Fetch text channels (type 0) and categories (type 4) for grouping
|
||||
// Fetch text channels (type 0), categories (type 4), and threads (types 10, 11, 12)
|
||||
|
||||
if (guildId) {
|
||||
// Fetch channels for specific guild with message counts
|
||||
// Use LEFT JOIN with aggregated counts to avoid N+1 subqueries
|
||||
channels = await sql`
|
||||
WITH channel_counts AS (
|
||||
SELECT channel_id, COUNT(*) as message_count
|
||||
FROM messages
|
||||
WHERE is_deleted = FALSE
|
||||
GROUP BY channel_id
|
||||
)
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.name,
|
||||
@@ -39,16 +69,24 @@ export async function GET({ request }) {
|
||||
c.guild_id,
|
||||
g.name as guild_name,
|
||||
g.icon_url as guild_icon,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.channel_id AND m.is_deleted = FALSE) as message_count
|
||||
COALESCE(cc.message_count, 0) as message_count
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
LEFT JOIN channel_counts cc ON c.channel_id = cc.channel_id
|
||||
WHERE c.guild_id = ${guildId}
|
||||
AND c.type IN (0, 4)
|
||||
AND c.type IN (0, 4, 10, 11, 12)
|
||||
ORDER BY c.position ASC, c.name ASC
|
||||
`;
|
||||
} else {
|
||||
// Fetch all text channels and categories with message counts
|
||||
// Fetch all text channels, categories, and threads with message counts
|
||||
// Use LEFT JOIN with aggregated counts to avoid N+1 subqueries
|
||||
channels = await sql`
|
||||
WITH channel_counts AS (
|
||||
SELECT channel_id, COUNT(*) as message_count
|
||||
FROM messages
|
||||
WHERE is_deleted = FALSE
|
||||
GROUP BY channel_id
|
||||
)
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.name,
|
||||
@@ -59,10 +97,11 @@ export async function GET({ request }) {
|
||||
c.guild_id,
|
||||
g.name as guild_name,
|
||||
g.icon_url as guild_icon,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.channel_id AND m.is_deleted = FALSE) as message_count
|
||||
COALESCE(cc.message_count, 0) as message_count
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
WHERE c.type IN (0, 4)
|
||||
LEFT JOIN channel_counts cc ON c.channel_id = cc.channel_id
|
||||
WHERE c.type IN (0, 4, 10, 11, 12)
|
||||
ORDER BY g.name ASC, c.position ASC, c.name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
|
||||
// Discord permission flags
|
||||
const VIEW_CHANNEL = 0x400n; // 1024
|
||||
@@ -131,6 +136,25 @@ async function getChannelVisibleMembers(channelId, guildId) {
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
@@ -247,7 +271,8 @@ export async function GET({ request }) {
|
||||
const cachedAvatarId = member.cached_guild_avatar_id || member.cached_avatar_id;
|
||||
let avatarUrl;
|
||||
if (cachedAvatarId) {
|
||||
avatarUrl = `/api/discord/cached-image?id=${cachedAvatarId}`;
|
||||
const sig = signImageId(cachedAvatarId);
|
||||
avatarUrl = `/api/discord/cached-image?id=${cachedAvatarId}&sig=${sig}`;
|
||||
} else {
|
||||
avatarUrl = member.guild_avatar_url || member.avatar_url;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,17 @@
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const IMAGE_PROXY_SECRET = process.env.IMAGE_PROXY_SECRET || 'dev-secret-change-me';
|
||||
const IMAGE_PROXY_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||
if (!IMAGE_PROXY_SECRET) {
|
||||
console.error('WARNING: IMAGE_PROXY_SECRET not set, image signing will fail');
|
||||
}
|
||||
|
||||
// Trusted domains that don't need proxying
|
||||
const TRUSTED_DOMAINS = new Set([
|
||||
@@ -57,7 +65,8 @@ function generateSignature(url) {
|
||||
function getCachedOrProxyUrl(cachedImageId, originalUrl, baseUrl) {
|
||||
// Prefer cached image if available
|
||||
if (cachedImageId) {
|
||||
return `${baseUrl}/api/discord/cached-image?id=${cachedImageId}`;
|
||||
const sig = signImageId(cachedImageId);
|
||||
return `${baseUrl}/api/discord/cached-image?id=${cachedImageId}&sig=${sig}`;
|
||||
}
|
||||
|
||||
// Fall back to original URL with proxy if needed
|
||||
@@ -85,6 +94,25 @@ function getSafeImageUrl(originalUrl, baseUrl) {
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 30,
|
||||
windowMs: 1000,
|
||||
burstLimit: 150,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
@@ -108,6 +136,7 @@ export async function GET({ request }) {
|
||||
const before = url.searchParams.get('before'); // For pagination
|
||||
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 baseUrl = `${url.protocol}//${url.host}`;
|
||||
|
||||
@@ -130,10 +159,54 @@ export async function GET({ request }) {
|
||||
// Joins guild_members for nickname and gets highest role color
|
||||
let messages;
|
||||
|
||||
if (before) {
|
||||
if (editedSince) {
|
||||
// Fetch messages edited since the given timestamp
|
||||
const editedSinceDate = new Date(editedSince);
|
||||
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.edited_at > ${editedSinceDate}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.edited_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else if (before) {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
@@ -169,6 +242,8 @@ export async function GET({ request }) {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
@@ -210,6 +285,8 @@ export async function GET({ request }) {
|
||||
(
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
@@ -248,6 +325,7 @@ export async function GET({ request }) {
|
||||
(
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
@@ -257,9 +335,6 @@ export async function GET({ request }) {
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_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,
|
||||
@@ -291,6 +366,8 @@ export async function GET({ request }) {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
@@ -300,9 +377,6 @@ export async function GET({ request }) {
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_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,
|
||||
@@ -420,6 +494,7 @@ export async function GET({ request }) {
|
||||
const embeds = await sql`
|
||||
SELECT
|
||||
embed_id, message_id, embed_type, title, description, url,
|
||||
raw_data,
|
||||
color, timestamp as embed_timestamp,
|
||||
footer_text, footer_icon_url, cached_footer_icon_id,
|
||||
image_url, image_width, image_height, cached_image_id,
|
||||
@@ -431,6 +506,41 @@ export async function GET({ request }) {
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch message components (buttons, selects, etc.) for all messages
|
||||
const messageComponents = await sql`
|
||||
SELECT
|
||||
component_id, message_id, component_type, custom_id, label, style, url, disabled, placeholder,
|
||||
min_values, max_values, raw_data
|
||||
FROM message_components
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
ORDER BY component_id
|
||||
`;
|
||||
|
||||
// Fetch embed fields for all embeds
|
||||
const embedIds = embeds.map(e => e.embed_id);
|
||||
let embedFields = [];
|
||||
if (embedIds.length > 0) {
|
||||
embedFields = await sql`
|
||||
SELECT embed_id, name, value, inline, position
|
||||
FROM embed_fields
|
||||
WHERE embed_id = ANY(${embedIds})
|
||||
ORDER BY position ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// Index embed fields by embed_id
|
||||
const fieldsByEmbed = {};
|
||||
for (const field of embedFields) {
|
||||
if (!fieldsByEmbed[field.embed_id]) {
|
||||
fieldsByEmbed[field.embed_id] = [];
|
||||
}
|
||||
fieldsByEmbed[field.embed_id].push({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
inline: field.inline || false,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch reactions for all messages (aggregate counts since each reaction is a row)
|
||||
const reactions = await sql`
|
||||
SELECT
|
||||
@@ -454,6 +564,58 @@ export async function GET({ request }) {
|
||||
WHERE ms.message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch polls for all messages
|
||||
const polls = await sql`
|
||||
SELECT
|
||||
p.message_id,
|
||||
p.question_text,
|
||||
p.question_emoji_id,
|
||||
p.question_emoji_name,
|
||||
p.question_emoji_animated,
|
||||
p.allow_multiselect,
|
||||
p.expiry,
|
||||
p.is_finalized,
|
||||
p.total_votes
|
||||
FROM polls p
|
||||
WHERE p.message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch poll answers
|
||||
const pollAnswers = await sql`
|
||||
SELECT
|
||||
pa.message_id,
|
||||
pa.answer_id,
|
||||
pa.answer_text,
|
||||
pa.answer_emoji_id,
|
||||
pa.answer_emoji_name,
|
||||
pa.answer_emoji_animated,
|
||||
pa.vote_count
|
||||
FROM poll_answers pa
|
||||
WHERE pa.message_id = ANY(${messageIds})
|
||||
ORDER BY pa.answer_id
|
||||
`;
|
||||
|
||||
// Fetch poll votes with user info
|
||||
const pollMessageIds = polls.map(p => p.message_id);
|
||||
let pollVotes = [];
|
||||
if (pollMessageIds.length > 0) {
|
||||
pollVotes = await sql`
|
||||
SELECT
|
||||
pv.message_id,
|
||||
pv.answer_id,
|
||||
pv.user_id,
|
||||
u.username,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as avatar_url
|
||||
FROM poll_votes pv
|
||||
LEFT JOIN users u ON pv.user_id = u.user_id
|
||||
LEFT JOIN guild_members gm ON pv.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE pv.message_id = ANY(${pollMessageIds})
|
||||
AND pv.is_removed = FALSE
|
||||
ORDER BY pv.voted_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// Fetch referenced messages for replies
|
||||
const referencedIds = messages
|
||||
.filter(m => m.reference_message_id)
|
||||
@@ -483,6 +645,8 @@ export async function GET({ request }) {
|
||||
const embedsByMessage = {};
|
||||
const reactionsByMessage = {};
|
||||
const stickersByMessage = {};
|
||||
const componentsByMessage = {};
|
||||
const pollsByMessage = {};
|
||||
const referencedById = {};
|
||||
|
||||
for (const att of attachments) {
|
||||
@@ -539,6 +703,8 @@ export async function GET({ request }) {
|
||||
url: embed.author_url,
|
||||
iconUrl: getCachedOrProxyUrl(embed.cached_author_icon_id, embed.author_icon_url, baseUrl),
|
||||
} : null,
|
||||
fields: fieldsByEmbed[embed.embed_id] || [],
|
||||
rawData: embed.raw_data || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -569,6 +735,84 @@ export async function GET({ request }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Index message components
|
||||
for (const comp of messageComponents) {
|
||||
if (!componentsByMessage[comp.message_id]) componentsByMessage[comp.message_id] = [];
|
||||
componentsByMessage[comp.message_id].push({
|
||||
id: comp.component_id.toString(),
|
||||
type: comp.component_type,
|
||||
customId: comp.custom_id,
|
||||
label: comp.label,
|
||||
style: comp.style,
|
||||
url: comp.url,
|
||||
disabled: !!comp.disabled,
|
||||
placeholder: comp.placeholder,
|
||||
minValues: comp.min_values,
|
||||
maxValues: comp.max_values,
|
||||
rawData: comp.raw_data || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Index poll votes by message_id and answer_id
|
||||
const pollVotesByAnswer = {};
|
||||
for (const vote of pollVotes) {
|
||||
const key = `${vote.message_id}-${vote.answer_id}`;
|
||||
if (!pollVotesByAnswer[key]) {
|
||||
pollVotesByAnswer[key] = [];
|
||||
}
|
||||
// Build avatar URL
|
||||
let avatarUrl = null;
|
||||
if (vote.avatar_url) {
|
||||
if (vote.avatar_url.startsWith('http')) {
|
||||
avatarUrl = vote.avatar_url;
|
||||
} else {
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${vote.user_id}/${vote.avatar_url}.png?size=32`;
|
||||
}
|
||||
}
|
||||
pollVotesByAnswer[key].push({
|
||||
id: vote.user_id.toString(),
|
||||
username: vote.username,
|
||||
displayName: vote.display_name || vote.username,
|
||||
avatar: avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Index poll answers by message_id
|
||||
const pollAnswersByMessage = {};
|
||||
for (const answer of pollAnswers) {
|
||||
if (!pollAnswersByMessage[answer.message_id]) {
|
||||
pollAnswersByMessage[answer.message_id] = [];
|
||||
}
|
||||
const voteKey = `${answer.message_id}-${answer.answer_id}`;
|
||||
pollAnswersByMessage[answer.message_id].push({
|
||||
id: answer.answer_id,
|
||||
text: answer.answer_text,
|
||||
emoji: answer.answer_emoji_id
|
||||
? { id: answer.answer_emoji_id.toString(), name: answer.answer_emoji_name, animated: answer.answer_emoji_animated }
|
||||
: answer.answer_emoji_name ? { name: answer.answer_emoji_name } : null,
|
||||
voteCount: answer.vote_count || 0,
|
||||
voters: pollVotesByAnswer[voteKey] || [],
|
||||
});
|
||||
}
|
||||
|
||||
// Index polls by message_id
|
||||
for (const poll of polls) {
|
||||
const answers = pollAnswersByMessage[poll.message_id] || [];
|
||||
pollsByMessage[poll.message_id] = {
|
||||
question: {
|
||||
text: poll.question_text,
|
||||
emoji: poll.question_emoji_id
|
||||
? { id: poll.question_emoji_id.toString(), name: poll.question_emoji_name, animated: poll.question_emoji_animated }
|
||||
: poll.question_emoji_name ? { name: poll.question_emoji_name } : null,
|
||||
},
|
||||
answers,
|
||||
allowMultiselect: poll.allow_multiselect || false,
|
||||
expiry: poll.expiry?.toISOString() || null,
|
||||
isFinalized: poll.is_finalized || false,
|
||||
totalVotes: poll.total_votes || 0,
|
||||
};
|
||||
}
|
||||
|
||||
for (const ref of referencedMessages) {
|
||||
// Handle avatar URL for referenced message author
|
||||
const refAvatarSource = ref.author_guild_avatar || ref.author_avatar;
|
||||
@@ -602,7 +846,8 @@ export async function GET({ request }) {
|
||||
let avatarUrl;
|
||||
if (cachedInfo?.cachedAvatarId) {
|
||||
// Use cached avatar from database
|
||||
avatarUrl = `${baseUrl}/api/discord/cached-image?id=${cachedInfo.cachedAvatarId}`;
|
||||
const sig = signImageId(cachedInfo.cachedAvatarId);
|
||||
avatarUrl = `${baseUrl}/api/discord/cached-image?id=${cachedInfo.cachedAvatarId}&sig=${sig}`;
|
||||
} else {
|
||||
// Handle avatar URL - prefer guild avatar, then user avatar
|
||||
const avatarSource = msg.author_guild_avatar || msg.author_avatar;
|
||||
@@ -635,6 +880,7 @@ export async function GET({ request }) {
|
||||
|
||||
return {
|
||||
id: msg.message_id.toString(),
|
||||
type: msg.message_type,
|
||||
content: msg.content,
|
||||
timestamp: msg.created_at?.toISOString(),
|
||||
editedTimestamp: msg.edited_at?.toISOString() || null,
|
||||
@@ -651,8 +897,11 @@ export async function GET({ request }) {
|
||||
},
|
||||
attachments: attachmentsByMessage[msg.message_id] || [],
|
||||
embeds: embedsByMessage[msg.message_id] || [],
|
||||
components: componentsByMessage[msg.message_id] || [],
|
||||
rawData: msg.raw_data || null,
|
||||
stickers: stickersByMessage[msg.message_id] || [],
|
||||
reactions: reactionsByMessage[msg.message_id] || [],
|
||||
poll: pollsByMessage[msg.message_id] || null,
|
||||
referencedMessage: msg.reference_message_id
|
||||
? referencedById[msg.reference_message_id] || null
|
||||
: null,
|
||||
|
||||
@@ -3,8 +3,32 @@
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
@@ -92,7 +116,8 @@ export async function GET({ request }) {
|
||||
const cachedAvatarId = u.cached_guild_avatar_id || u.cached_avatar_id;
|
||||
let avatar;
|
||||
if (cachedAvatarId) {
|
||||
avatar = `/api/discord/cached-image?id=${cachedAvatarId}`;
|
||||
const sig = signImageId(cachedAvatarId);
|
||||
avatar = `/api/discord/cached-image?id=${cachedAvatarId}&sig=${sig}`;
|
||||
} else {
|
||||
avatar = u.guild_avatar || u.avatar_url;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,41 @@
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
|
||||
// Escape LIKE/ILIKE metacharacters to prevent pattern injection
|
||||
function escapeLikePattern(str) {
|
||||
return str.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check for authenticated endpoints
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
burstLimit: 50,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
@@ -41,7 +71,9 @@ export async function GET({ request }) {
|
||||
return createResponse({ error: 'Search query must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
const searchPattern = `%${query}%`;
|
||||
// Escape LIKE metacharacters to prevent pattern injection
|
||||
const escapedQuery = escapeLikePattern(query);
|
||||
const searchPattern = `%${escapedQuery}%`;
|
||||
|
||||
// Search messages by content, author username, or embed content
|
||||
const messages = await sql`
|
||||
|
||||
@@ -12,8 +12,38 @@ import {
|
||||
createNonceCookie,
|
||||
} from '../../utils/rateLimit.js';
|
||||
|
||||
// Secret key for signing URLs - in production, use an environment variable
|
||||
const SIGNING_SECRET = process.env.IMAGE_PROXY_SECRET || 'dev-secret-change-me';
|
||||
// Secret key for signing URLs - MUST be set in production
|
||||
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||
if (!SIGNING_SECRET) {
|
||||
console.error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set!');
|
||||
}
|
||||
|
||||
// Private IP ranges to block (SSRF protection)
|
||||
const PRIVATE_IP_PATTERNS = [
|
||||
/^127\./, // localhost
|
||||
/^10\./, // Class A private
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Class B private
|
||||
/^192\.168\./, // Class C private
|
||||
/^169\.254\./, // Link-local
|
||||
/^0\./, // Current network
|
||||
/^224\./, // Multicast
|
||||
/^255\./, // Broadcast
|
||||
/^localhost$/i,
|
||||
/^\[?::1\]?$/, // IPv6 localhost
|
||||
/^\[?fe80:/i, // IPv6 link-local
|
||||
/^\[?fc00:/i, // IPv6 private
|
||||
/^\[?fd00:/i, // IPv6 private
|
||||
];
|
||||
|
||||
function isPrivateUrl(urlString) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const hostname = url.hostname;
|
||||
return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(hostname));
|
||||
} catch {
|
||||
return true; // Block invalid URLs
|
||||
}
|
||||
}
|
||||
|
||||
// Max image size to proxy (25MB - needed for animated GIFs)
|
||||
const MAX_IMAGE_SIZE = 25 * 1024 * 1024;
|
||||
@@ -136,6 +166,11 @@ export async function GET({ request }) {
|
||||
return new Response('Missing signature', { status: 403 });
|
||||
}
|
||||
|
||||
// Require signing secret to be configured
|
||||
if (!SIGNING_SECRET) {
|
||||
return new Response('Server misconfigured', { status: 500 });
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const isValid = await verifySignature(imageUrl, signature);
|
||||
if (!isValid) {
|
||||
@@ -153,6 +188,11 @@ export async function GET({ request }) {
|
||||
return new Response('Invalid URL', { status: 400 });
|
||||
}
|
||||
|
||||
// SSRF protection: block private/internal IPs
|
||||
if (isPrivateUrl(imageUrl)) {
|
||||
return new Response('URL not allowed', { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Server-side link preview API endpoint
|
||||
* Fetches Open Graph / meta data for URLs to prevent user IP exposure
|
||||
* Server-side link preview API endpoint (Node.js / Astro)
|
||||
* Uses linkedom for reliable HTML parsing and automatic entity decoding
|
||||
* Returns signed proxy URLs for images from untrusted domains
|
||||
*/
|
||||
|
||||
@@ -12,61 +12,29 @@ import {
|
||||
createNonceCookie,
|
||||
} from '../../utils/rateLimit.js';
|
||||
import { signImageUrl } from './image-proxy.js';
|
||||
import { parseHTML } from 'linkedom';
|
||||
|
||||
// Trusted domains that can be loaded client-side (embed-safe providers)
|
||||
// Trusted domains that can be loaded client-side
|
||||
const TRUSTED_DOMAINS = new Set([
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'youtu.be',
|
||||
'img.youtube.com',
|
||||
'i.ytimg.com',
|
||||
'instagram.com',
|
||||
'www.instagram.com',
|
||||
'twitter.com',
|
||||
'x.com',
|
||||
'www.twitter.com',
|
||||
'pbs.twimg.com',
|
||||
'abs.twimg.com',
|
||||
'twitch.tv',
|
||||
'www.twitch.tv',
|
||||
'clips.twitch.tv',
|
||||
'spotify.com',
|
||||
'open.spotify.com',
|
||||
'soundcloud.com',
|
||||
'www.soundcloud.com',
|
||||
'vimeo.com',
|
||||
'www.vimeo.com',
|
||||
'imgur.com',
|
||||
'i.imgur.com',
|
||||
'giphy.com',
|
||||
'media.giphy.com',
|
||||
'tenor.com',
|
||||
'media.tenor.com',
|
||||
'youtube.com', 'www.youtube.com', 'youtu.be', 'img.youtube.com', 'i.ytimg.com',
|
||||
'instagram.com', 'www.instagram.com',
|
||||
'twitter.com', 'x.com', 'www.twitter.com', 'pbs.twimg.com', 'abs.twimg.com',
|
||||
'twitch.tv', 'www.twitch.tv', 'clips.twitch.tv',
|
||||
'spotify.com', 'open.spotify.com',
|
||||
'soundcloud.com', 'www.soundcloud.com',
|
||||
'vimeo.com', 'www.vimeo.com',
|
||||
'imgur.com', 'i.imgur.com',
|
||||
'giphy.com', 'media.giphy.com',
|
||||
'tenor.com', 'media.tenor.com',
|
||||
'gfycat.com',
|
||||
'reddit.com',
|
||||
'www.reddit.com',
|
||||
'v.redd.it',
|
||||
'i.redd.it',
|
||||
'preview.redd.it',
|
||||
'github.com',
|
||||
'gist.github.com',
|
||||
'raw.githubusercontent.com',
|
||||
'avatars.githubusercontent.com',
|
||||
'user-images.githubusercontent.com',
|
||||
'codepen.io',
|
||||
'codesandbox.io',
|
||||
'streamable.com',
|
||||
'medal.tv',
|
||||
'discord.com',
|
||||
'cdn.discordapp.com',
|
||||
'media.discordapp.net',
|
||||
'picsum.photos',
|
||||
'images.unsplash.com',
|
||||
'reddit.com', 'www.reddit.com', 'v.redd.it', 'i.redd.it', 'preview.redd.it',
|
||||
'github.com', 'gist.github.com', 'raw.githubusercontent.com', 'avatars.githubusercontent.com', 'user-images.githubusercontent.com',
|
||||
'codepen.io', 'codesandbox.io',
|
||||
'streamable.com', 'medal.tv',
|
||||
'discord.com', 'cdn.discordapp.com', 'media.discordapp.net',
|
||||
'picsum.photos', 'images.unsplash.com',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a URL is from a trusted domain
|
||||
*/
|
||||
function isTrustedDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -76,22 +44,13 @@ function isTrustedDomain(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a safe image URL - either direct (if trusted) or signed proxy URL
|
||||
*/
|
||||
async function getSafeImageUrl(imageUrl) {
|
||||
if (!imageUrl) return null;
|
||||
if (isTrustedDomain(imageUrl)) {
|
||||
return imageUrl; // Trusted, return as-is
|
||||
}
|
||||
// Create signed proxy URL
|
||||
if (isTrustedDomain(imageUrl)) return imageUrl;
|
||||
const signature = await signImageUrl(imageUrl);
|
||||
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Open Graph and meta tags from HTML
|
||||
*/
|
||||
function parseMetaTags(html, url) {
|
||||
const meta = {
|
||||
url,
|
||||
@@ -104,101 +63,69 @@ function parseMetaTags(html, url) {
|
||||
themeColor: null,
|
||||
};
|
||||
|
||||
// Helper to extract content from meta tags
|
||||
const getMetaContent = (pattern) => {
|
||||
const match = html.match(pattern);
|
||||
return match ? decodeHTMLEntities(match[1]) : null;
|
||||
};
|
||||
const decode = str => str?.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g,
|
||||
(_, e) => e[0]==='#' ? String.fromCharCode(e[1]==='x'?parseInt(e.slice(2),16):parseInt(e.slice(1),10))
|
||||
: ({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[e]||_));
|
||||
|
||||
// Open Graph tags
|
||||
meta.title = getMetaContent(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i);
|
||||
|
||||
meta.description = getMetaContent(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i);
|
||||
|
||||
meta.image = getMetaContent(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
|
||||
|
||||
meta.siteName = getMetaContent(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i);
|
||||
|
||||
meta.type = getMetaContent(/<meta[^>]+property=["']og:type["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:type["']/i);
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
meta.video = getMetaContent(/<meta[^>]+property=["']og:video(?::url)?["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:video(?::url)?["']/i);
|
||||
|
||||
// Twitter cards fallback
|
||||
if (!meta.title) {
|
||||
meta.title = getMetaContent(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i);
|
||||
}
|
||||
if (!meta.description) {
|
||||
meta.description = getMetaContent(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i);
|
||||
}
|
||||
if (!meta.image) {
|
||||
meta.image = getMetaContent(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i);
|
||||
}
|
||||
// Open Graph / Twitter / fallback
|
||||
meta.title =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:title"]')?.getAttribute('content') ||
|
||||
document.querySelector('title')?.textContent || null
|
||||
);
|
||||
|
||||
// Theme color
|
||||
meta.themeColor = getMetaContent(/<meta[^>]+name=["']theme-color["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']theme-color["']/i);
|
||||
meta.description =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:description"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="description"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
// Fallback to standard meta tags and title
|
||||
if (!meta.title) {
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
meta.title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : null;
|
||||
}
|
||||
if (!meta.description) {
|
||||
meta.description = getMetaContent(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
||||
}
|
||||
meta.image =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:image"]')?.getAttribute('content') ||
|
||||
document.querySelector('meta[name="twitter:image"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.siteName =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:site_name"]')?.getAttribute('content') ||
|
||||
new URL(url).hostname.replace(/^www\./, '')
|
||||
);
|
||||
|
||||
meta.type =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:type"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.video =
|
||||
decode(
|
||||
document.querySelector('meta[property="og:video"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
meta.themeColor =
|
||||
decode(
|
||||
document.querySelector('meta[name="theme-color"]')?.getAttribute('content') || null
|
||||
);
|
||||
|
||||
// Resolve relative image URLs
|
||||
if (meta.image && !meta.image.startsWith('http')) {
|
||||
try {
|
||||
const baseUrl = new URL(url);
|
||||
meta.image = new URL(meta.image, baseUrl.origin).href;
|
||||
meta.image = decode(new URL(meta.image, new URL(url).origin).href);
|
||||
} catch {
|
||||
meta.image = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get site name from domain if not found
|
||||
if (!meta.siteName) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
meta.siteName = parsed.hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode HTML entities
|
||||
*/
|
||||
function decodeHTMLEntities(text) {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, '/')
|
||||
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
|
||||
.replace(/&#x([a-fA-F0-9]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
// Rate limit
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
@@ -208,25 +135,18 @@ export async function GET({ request }) {
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
if (!cookieId) cookieId = generateNonce();
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const errorMsg = rateCheck.isFlooding
|
||||
? { error: 'Too many requests - please slow down' }
|
||||
: { error: 'Rate limit exceeded' };
|
||||
const response = new Response(JSON.stringify(errorMsg), {
|
||||
const resp = new Response(JSON.stringify(errorMsg), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '1',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
@@ -241,13 +161,11 @@ export async function GET({ request }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
// Validate URL
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(targetUrl);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) throw new Error();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL' }), {
|
||||
status: 400,
|
||||
@@ -255,9 +173,8 @@ export async function GET({ request }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a trusted domain (client can fetch directly)
|
||||
const trusted = isTrustedDomain(targetUrl);
|
||||
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
@@ -286,71 +203,44 @@ export async function GET({ request }) {
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Handle image URLs directly - return safe (possibly proxied) URL
|
||||
|
||||
// Handle direct image
|
||||
if (contentType.startsWith('image/')) {
|
||||
const safeImageUrl = await getSafeImageUrl(targetUrl);
|
||||
const result = {
|
||||
url: targetUrl,
|
||||
type: 'image',
|
||||
image: safeImageUrl,
|
||||
trusted,
|
||||
};
|
||||
const result = { url: targetUrl, type: 'image', image: safeImageUrl, trusted };
|
||||
const resp = new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Handle video URLs directly (no proxy for video - too large)
|
||||
// Handle direct video
|
||||
if (contentType.startsWith('video/')) {
|
||||
// Only allow trusted video sources
|
||||
if (!trusted) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Untrusted video source',
|
||||
}), {
|
||||
return new Response(JSON.stringify({ error: 'Untrusted video source' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
url: targetUrl,
|
||||
type: 'video',
|
||||
video: targetUrl,
|
||||
trusted,
|
||||
};
|
||||
const result = { url: targetUrl, type: 'video', video: targetUrl, trusted };
|
||||
const resp = new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Only parse HTML
|
||||
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'URL is not an HTML page',
|
||||
contentType
|
||||
}), {
|
||||
return new Response(JSON.stringify({ error: 'URL is not an HTML page', contentType }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Read only the first 50KB to get meta tags (they're usually in <head>)
|
||||
// Read first 50KB
|
||||
const reader = response.body.getReader();
|
||||
let html = '';
|
||||
let bytesRead = 0;
|
||||
@@ -361,37 +251,27 @@ export async function GET({ request }) {
|
||||
if (done) break;
|
||||
html += new TextDecoder().decode(value);
|
||||
bytesRead += value.length;
|
||||
// Stop early if we've passed </head>
|
||||
if (html.includes('</head>')) break;
|
||||
}
|
||||
reader.cancel();
|
||||
|
||||
const meta = parseMetaTags(html, targetUrl);
|
||||
meta.trusted = trusted;
|
||||
|
||||
// Convert image URL to safe URL (proxy if untrusted)
|
||||
if (meta.image) {
|
||||
meta.image = await getSafeImageUrl(meta.image);
|
||||
}
|
||||
|
||||
// Convert image to safe URL
|
||||
if (meta.image) meta.image = await getSafeImageUrl(meta.image);
|
||||
|
||||
const resp = new Response(JSON.stringify(meta), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
return resp;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[link-preview] Error fetching URL:', err.message);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to fetch preview',
|
||||
message: err.message
|
||||
}), {
|
||||
// Don't expose internal error details to client
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch preview' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
import "@styles/DiscordLogs.css";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
// Auth + role check handled by middleware
|
||||
// Middleware redirects to /login if not authenticated or lacks 'discord' role
|
||||
const user = Astro.locals.user as any;
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
// Prevent browser caching of authenticated pages
|
||||
Astro.response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
||||
Astro.response.headers.set('Pragma', 'no-cache');
|
||||
---
|
||||
|
||||
<Base title="Discord Archive" description="Archived Discord channel logs">
|
||||
<section class="discord-logs-section">
|
||||
<div class="discord-logs-page">
|
||||
<Root child="DiscordLogs" client:only="react" />
|
||||
</div>
|
||||
<section class="page-section discord-logs-section">
|
||||
<Root child="DiscordLogs" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
@@ -24,9 +23,4 @@ if (!user) {
|
||||
body:has(.discord-logs-section) main.page-enter {
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
|
||||
.discord-logs-page {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,21 +16,12 @@ const whitelabel = WHITELABELS[host] ?? (detected ? WHITELABELS[detected.host] :
|
||||
|
||||
<Base>
|
||||
{whitelabel ? (
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="ReqForm" client:only="react">
|
||||
</Root>
|
||||
</div>
|
||||
<section class="page-section">
|
||||
<Root child="ReqForm" client:only="react" />
|
||||
</section>
|
||||
) : (
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root
|
||||
child="LyricSearch"
|
||||
client:only="react"
|
||||
|
||||
/>
|
||||
</div>
|
||||
<section class="page-section">
|
||||
<Root child="LyricSearch" client:only="react" />
|
||||
</section>
|
||||
)}
|
||||
</Base>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
---
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user || !user.roles.includes('lighting')) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
// Auth + role check handled by middleware
|
||||
// Middleware redirects to /login if not authenticated or lacks 'lighting' role
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Lighting" user?={user} client:only="react" />
|
||||
</div>
|
||||
<section class="page-section">
|
||||
<Root child="Lighting" user?={user} client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
@@ -8,9 +8,7 @@ const isLoggedIn = Boolean(user);
|
||||
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react">
|
||||
</Root>
|
||||
<section class="page-section">
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
@@ -4,10 +4,8 @@ import Root from "../components/AppLayout.jsx";
|
||||
import "@styles/MemeGrid.css";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Memes" client:only="react">
|
||||
</Root>
|
||||
<Base hideFooter>
|
||||
<section class="page-section">
|
||||
<Root child="Memes" client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
// The Base layout exposes runtime subsite state — no per-page detection needed
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
// Auth handled by middleware - user available in Astro.locals.user
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Player" user={user} client:only="react">
|
||||
</Root>
|
||||
<section class="page-section">
|
||||
<Root child="Player" user={user} client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
Reference in New Issue
Block a user