feat(api): add endpoints for fetching reaction users and searching messages
- Implemented GET endpoint to fetch users who reacted with a specific emoji on a message. - Added validation for messageId and emoji parameters. - Enhanced user data retrieval with display names and avatar URLs. - Created a search endpoint for Discord messages with support for content and embed searches. - Included pagination and rate limiting for search results. feat(api): introduce image proxy and link preview endpoints - Developed an image proxy API to securely fetch images from untrusted domains. - Implemented HMAC signing for image URLs to prevent abuse. - Created a link preview API to fetch Open Graph metadata from URLs. - Added support for trusted domains and safe image URL generation. style(pages): create Discord logs page with authentication - Added a new page for displaying archived Discord channel logs. - Integrated authentication check to ensure user access. refactor(utils): enhance API authentication and database connection - Improved API authentication helper to manage user sessions and token refresh. - Established a PostgreSQL database connection utility for Discord logs.
This commit is contained in:
2068
src/assets/styles/DiscordLogs.css
Normal file
2068
src/assets/styles/DiscordLogs.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -367,6 +367,10 @@ Custom
|
||||
background-color: oklch(from rgba(255, 255, 255, 2.0) calc(l - 0.02) c h);
|
||||
}
|
||||
|
||||
.discord-logs-container {
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
|
||||
.random-msg {
|
||||
padding-top: 10px;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -14,6 +14,7 @@ const LoginPage = lazy(() => import('./Login.jsx'));
|
||||
const LyricSearch = lazy(() => import('./LyricSearch'));
|
||||
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx'));
|
||||
const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx'));
|
||||
const DiscordLogs = lazy(() => import('./DiscordLogs.jsx'));
|
||||
// NOTE: Player is intentionally NOT imported at module initialization.
|
||||
// We create the lazy import inside the component at render-time only when
|
||||
// we are on the main site and the Player island should be rendered. This
|
||||
@@ -98,6 +99,11 @@ export default function Root({ child, user = undefined, ...props }) {
|
||||
</Suspense>
|
||||
)}
|
||||
{child == "Memes" && <Memes client:only="react" />}
|
||||
{child == "DiscordLogs" && (
|
||||
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
|
||||
<DiscordLogs client:only="react" />
|
||||
</Suspense>
|
||||
)}
|
||||
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
|
||||
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
|
||||
{child == "ReqForm" && <ReqForm client:only="react" />}
|
||||
|
||||
2584
src/components/DiscordLogs.jsx
Normal file
2584
src/components/DiscordLogs.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@ export default function Lighting() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[60vh] flex justify-center items-center mt-12">
|
||||
<div className="w-full min-h-[60vh] flex justify-center items-center mt-12 mb-12">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="max-w-md w-full p-8 rounded-xl shadow bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 flex flex-col items-center justify-center"
|
||||
|
||||
@@ -76,9 +76,15 @@ if (import.meta.env.DEV) {
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
class="antialiased flex flex-col items-center justify-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
|
||||
class="antialiased flex flex-col items-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
|
||||
scrollbar-hide"
|
||||
style={`--brand-color: ${whitelabel?.brandColor ?? '#111827'}`}>
|
||||
|
||||
<!-- Nav is outside main to allow full-width -->
|
||||
<div class="w-full">
|
||||
{whitelabel ? <SubNav whitelabel={whitelabel} subsite={detectedSubsite} /> : <Nav />}
|
||||
</div>
|
||||
|
||||
<main
|
||||
class="page-enter flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-3xl w-full">
|
||||
<noscript>
|
||||
@@ -86,7 +92,6 @@ if (import.meta.env.DEV) {
|
||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||
</div>
|
||||
</noscript>
|
||||
{whitelabel ? <SubNav whitelabel={whitelabel} subsite={detectedSubsite} /> : <Nav />}
|
||||
<slot />
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
@@ -21,6 +21,7 @@ const navItems = [
|
||||
{ label: "Memes", href: "/memes" },
|
||||
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
||||
{ label: "Lighting", href: "/lighting", auth: true },
|
||||
{ label: "Discord Logs", href: "/discord-logs", auth: true },
|
||||
{ label: "Status", href: "https://status.boatson.boats", icon: "external" },
|
||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||
{ label: "Login", href: "/login", guestOnly: true },
|
||||
|
||||
@@ -24,9 +24,13 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
|| context.request.headers.get('x-client-ip')
|
||||
|| 'unknown';
|
||||
|
||||
// Cloudflare geo data (available in production)
|
||||
const cfCountry = headersMap['cf-ipcountry'] || null;
|
||||
const userAgent = headersMap['user-agent'] || null;
|
||||
|
||||
// Debug info for incoming requests
|
||||
if (import.meta.env.DEV) console.log(`[middleware] incoming: host=${hostHeader} ip=${requestIp} path=${context.url.pathname}`);
|
||||
else console.log(`[middleware] request from ${requestIp}: ${context.url.pathname}`);
|
||||
else console.log(`[middleware] request from ${requestIp}${cfCountry ? ` (${cfCountry})` : ''}${userAgent ? ` [${userAgent}]` : ''}: ${context.url.pathname}`);
|
||||
|
||||
// When the host header is missing, log all headers for debugging and
|
||||
// attempt to determine host from forwarded headers or a dev query header.
|
||||
|
||||
69
src/pages/api/discord/cached-image.js
Normal file
69
src/pages/api/discord/cached-image.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const url = new URL(request.url);
|
||||
const imageId = url.searchParams.get('id');
|
||||
const sourceUrl = url.searchParams.get('url');
|
||||
|
||||
if (!imageId && !sourceUrl) {
|
||||
return new Response('Missing id or url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
// Validate imageId is a valid integer if provided
|
||||
if (imageId && !/^\d+$/.test(imageId)) {
|
||||
return new Response('Invalid image id', { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
let image;
|
||||
|
||||
if (imageId) {
|
||||
// Look up by image_id
|
||||
const result = await sql`
|
||||
SELECT image_data, content_type, source_url
|
||||
FROM image_cache
|
||||
WHERE image_id = ${imageId}
|
||||
`;
|
||||
image = result[0];
|
||||
} else {
|
||||
// Look up by source_url
|
||||
const result = await sql`
|
||||
SELECT image_data, content_type, source_url
|
||||
FROM image_cache
|
||||
WHERE source_url = ${sourceUrl}
|
||||
`;
|
||||
image = result[0];
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
return new Response('Image not found in cache', { status: 404 });
|
||||
}
|
||||
|
||||
// image_data is a Buffer (bytea)
|
||||
const imageBuffer = image.image_data;
|
||||
const contentType = image.content_type || 'image/png';
|
||||
|
||||
return new Response(imageBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': imageBuffer.length.toString(),
|
||||
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year since it's immutable
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error serving cached image:', error);
|
||||
return new Response('Failed to serve image', { status: 500 });
|
||||
}
|
||||
}
|
||||
89
src/pages/api/discord/channels.js
Normal file
89
src/pages/api/discord/channels.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* API endpoint to fetch Discord channels from database
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth, createApiResponse } from '../../../utils/apiAuth.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const guildId = url.searchParams.get('guildId');
|
||||
|
||||
// Validate snowflake format
|
||||
if (!isValidSnowflake(guildId)) {
|
||||
return createApiResponse({ error: 'Invalid guildId format' }, 400);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (guildId) {
|
||||
// Fetch channels for specific guild with message counts
|
||||
channels = await sql`
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.name,
|
||||
c.type,
|
||||
c.position,
|
||||
c.parent_id,
|
||||
c.topic,
|
||||
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
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
WHERE c.guild_id = ${guildId}
|
||||
AND c.type IN (0, 4)
|
||||
ORDER BY c.position ASC, c.name ASC
|
||||
`;
|
||||
} else {
|
||||
// Fetch all text channels and categories with message counts
|
||||
channels = await sql`
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.name,
|
||||
c.type,
|
||||
c.position,
|
||||
c.parent_id,
|
||||
c.topic,
|
||||
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
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
WHERE c.type IN (0, 4)
|
||||
ORDER BY g.name ASC, c.position ASC, c.name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// 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,
|
||||
}));
|
||||
|
||||
return createApiResponse(formattedChannels, 200, setCookieHeader);
|
||||
} catch (error) {
|
||||
console.error('Error fetching channels:', error);
|
||||
return createApiResponse({ error: 'Failed to fetch channels' }, 500, setCookieHeader);
|
||||
}
|
||||
}
|
||||
307
src/pages/api/discord/members.js
Normal file
307
src/pages/api/discord/members.js
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* API endpoint to fetch Discord guild members with roles
|
||||
* Optionally filters by channel visibility using permission overwrites
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
|
||||
// Discord permission flags
|
||||
const VIEW_CHANNEL = 0x400n; // 1024
|
||||
const ADMINISTRATOR = 0x8n; // 8
|
||||
|
||||
/**
|
||||
* Calculate which members can view a specific channel
|
||||
* Emulates Discord's permission calculation:
|
||||
* 1. Start with @everyone role permissions
|
||||
* 2. Apply role-based overwrites (allow/deny)
|
||||
* 3. Apply member-specific overwrites (allow/deny)
|
||||
* 4. Check if VIEW_CHANNEL permission is granted
|
||||
*/
|
||||
async function getChannelVisibleMembers(channelId, guildId) {
|
||||
// Get guild info including owner
|
||||
const guildInfo = await sql`
|
||||
SELECT owner_id FROM guilds WHERE guild_id = ${guildId}
|
||||
`;
|
||||
const ownerId = guildInfo[0]?.owner_id;
|
||||
|
||||
// Get @everyone role (same ID as guild)
|
||||
const everyoneRole = await sql`
|
||||
SELECT role_id, permissions FROM roles WHERE role_id = ${guildId}
|
||||
`;
|
||||
const basePermissions = everyoneRole[0]?.permissions ? BigInt(everyoneRole[0].permissions) : 0n;
|
||||
|
||||
// Get all channel permission overwrites
|
||||
const overwrites = await sql`
|
||||
SELECT target_id, target_type, allow_permissions, deny_permissions
|
||||
FROM channel_permission_overwrites
|
||||
WHERE channel_id = ${channelId}
|
||||
`;
|
||||
|
||||
// Build overwrite lookups
|
||||
const roleOverwrites = new Map();
|
||||
const memberOverwrites = new Map();
|
||||
for (const ow of overwrites) {
|
||||
const targetId = ow.target_id.toString();
|
||||
const allow = BigInt(ow.allow_permissions);
|
||||
const deny = BigInt(ow.deny_permissions);
|
||||
if (ow.target_type === 'role') {
|
||||
roleOverwrites.set(targetId, { allow, deny });
|
||||
} else {
|
||||
memberOverwrites.set(targetId, { allow, deny });
|
||||
}
|
||||
}
|
||||
|
||||
// Get all guild members with their roles
|
||||
const members = await sql`
|
||||
SELECT gm.user_id, gm.roles
|
||||
FROM guild_members gm
|
||||
WHERE gm.guild_id = ${guildId}
|
||||
`;
|
||||
|
||||
// Get all role permissions for the guild
|
||||
const allRoles = await sql`
|
||||
SELECT role_id, permissions FROM roles WHERE guild_id = ${guildId}
|
||||
`;
|
||||
const rolePermissions = new Map();
|
||||
for (const role of allRoles) {
|
||||
rolePermissions.set(role.role_id.toString(), BigInt(role.permissions || 0));
|
||||
}
|
||||
|
||||
const visibleMemberIds = new Set();
|
||||
|
||||
for (const member of members) {
|
||||
const userId = member.user_id.toString();
|
||||
const memberRoles = (member.roles || []).map(r => r.toString());
|
||||
|
||||
// Owner always has access
|
||||
if (ownerId && member.user_id.toString() === ownerId.toString()) {
|
||||
visibleMemberIds.add(userId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any role has ADMINISTRATOR
|
||||
let isAdmin = false;
|
||||
for (const roleId of memberRoles) {
|
||||
const perms = rolePermissions.get(roleId) || 0n;
|
||||
if (perms & ADMINISTRATOR) {
|
||||
isAdmin = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
visibleMemberIds.add(userId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate effective permissions
|
||||
let permissions = basePermissions;
|
||||
|
||||
// Apply @everyone role overwrite first
|
||||
const everyoneOverwrite = roleOverwrites.get(guildId.toString());
|
||||
if (everyoneOverwrite) {
|
||||
permissions = (permissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
|
||||
}
|
||||
|
||||
// Apply role overwrites (combined)
|
||||
let allowCombined = 0n;
|
||||
let denyCombined = 0n;
|
||||
for (const roleId of memberRoles) {
|
||||
const ow = roleOverwrites.get(roleId);
|
||||
if (ow) {
|
||||
allowCombined |= ow.allow;
|
||||
denyCombined |= ow.deny;
|
||||
}
|
||||
}
|
||||
permissions = (permissions & ~denyCombined) | allowCombined;
|
||||
|
||||
// Apply member-specific overwrite (highest priority)
|
||||
const memberOverwrite = memberOverwrites.get(userId);
|
||||
if (memberOverwrite) {
|
||||
permissions = (permissions & ~memberOverwrite.deny) | memberOverwrite.allow;
|
||||
}
|
||||
|
||||
// Check VIEW_CHANNEL permission
|
||||
if (permissions & VIEW_CHANNEL) {
|
||||
visibleMemberIds.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return visibleMemberIds;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(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 }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const guildId = url.searchParams.get('guildId');
|
||||
const channelId = url.searchParams.get('channelId'); // Optional: filter by channel visibility
|
||||
|
||||
if (!guildId) {
|
||||
return createResponse({ error: 'guildId is required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake formats
|
||||
if (!isValidSnowflake(guildId) || !isValidSnowflake(channelId)) {
|
||||
return createResponse({ error: 'Invalid ID format' }, 400);
|
||||
}
|
||||
|
||||
// If channelId provided, get visible members for that channel
|
||||
let visibleMemberIds = null;
|
||||
if (channelId) {
|
||||
visibleMemberIds = await getChannelVisibleMembers(channelId, guildId);
|
||||
}
|
||||
|
||||
// Fetch all members for the guild with their user info
|
||||
const members = await sql`
|
||||
SELECT
|
||||
gm.user_id,
|
||||
gm.nickname,
|
||||
gm.roles,
|
||||
gm.guild_avatar_url,
|
||||
gm.cached_guild_avatar_id,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.global_name,
|
||||
u.avatar_url,
|
||||
u.cached_avatar_id,
|
||||
u.is_bot
|
||||
FROM guild_members gm
|
||||
JOIN users u ON gm.user_id = u.user_id
|
||||
WHERE gm.guild_id = ${guildId}
|
||||
ORDER BY u.username ASC
|
||||
`;
|
||||
|
||||
// Fetch all roles for the guild
|
||||
const roles = await sql`
|
||||
SELECT
|
||||
role_id,
|
||||
name,
|
||||
color,
|
||||
position,
|
||||
hoist
|
||||
FROM roles
|
||||
WHERE guild_id = ${guildId}
|
||||
ORDER BY position DESC
|
||||
`;
|
||||
|
||||
// Create role lookup map
|
||||
const roleMap = {};
|
||||
roles.forEach(role => {
|
||||
roleMap[role.role_id.toString()] = {
|
||||
id: role.role_id.toString(),
|
||||
name: role.name,
|
||||
color: role.color ? `#${role.color.toString(16).padStart(6, '0')}` : null,
|
||||
position: role.position,
|
||||
hoist: role.hoist, // "hoist" means the role is displayed separately in member list
|
||||
};
|
||||
});
|
||||
|
||||
// Filter members by channel visibility if channelId was provided
|
||||
const filteredMembers = visibleMemberIds
|
||||
? members.filter(m => visibleMemberIds.has(m.user_id.toString()))
|
||||
: members;
|
||||
|
||||
// Format members with their highest role for color
|
||||
const formattedMembers = filteredMembers.map(member => {
|
||||
const memberRoles = (member.roles || []).map(r => r.toString());
|
||||
|
||||
// Find highest positioned role with color for display
|
||||
let displayRole = null;
|
||||
let highestPosition = -1;
|
||||
|
||||
memberRoles.forEach(roleId => {
|
||||
const role = roleMap[roleId];
|
||||
if (role && role.position > highestPosition) {
|
||||
highestPosition = role.position;
|
||||
if (role.color && role.color !== '#000000') {
|
||||
displayRole = role;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find all hoisted roles for grouping
|
||||
const hoistedRoles = memberRoles
|
||||
.map(rid => roleMap[rid])
|
||||
.filter(r => r && r.hoist)
|
||||
.sort((a, b) => b.position - a.position);
|
||||
|
||||
const primaryRole = hoistedRoles[0] || displayRole || null;
|
||||
|
||||
// Prefer cached avatar, then guild avatar, then user avatar
|
||||
const cachedAvatarId = member.cached_guild_avatar_id || member.cached_avatar_id;
|
||||
let avatarUrl;
|
||||
if (cachedAvatarId) {
|
||||
avatarUrl = `/api/discord/cached-image?id=${cachedAvatarId}`;
|
||||
} else {
|
||||
avatarUrl = member.guild_avatar_url || member.avatar_url;
|
||||
}
|
||||
|
||||
return {
|
||||
id: member.user_id.toString(),
|
||||
username: member.username,
|
||||
displayName: member.nickname || member.global_name || member.username,
|
||||
avatar: avatarUrl,
|
||||
isBot: member.is_bot || false,
|
||||
color: displayRole?.color || null,
|
||||
primaryRoleId: primaryRole?.id || null,
|
||||
primaryRoleName: primaryRole?.name || null,
|
||||
roles: memberRoles,
|
||||
};
|
||||
});
|
||||
|
||||
// Group members by their primary (highest hoisted) role
|
||||
const membersByRole = {};
|
||||
const noRoleMembers = [];
|
||||
|
||||
formattedMembers.forEach(member => {
|
||||
if (member.primaryRoleId) {
|
||||
if (!membersByRole[member.primaryRoleId]) {
|
||||
membersByRole[member.primaryRoleId] = {
|
||||
role: roleMap[member.primaryRoleId],
|
||||
members: [],
|
||||
};
|
||||
}
|
||||
membersByRole[member.primaryRoleId].members.push(member);
|
||||
} else {
|
||||
noRoleMembers.push(member);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort groups by role position (highest first)
|
||||
const sortedGroups = Object.values(membersByRole)
|
||||
.sort((a, b) => b.role.position - a.role.position);
|
||||
|
||||
// Add "Online" or default group for members without hoisted roles
|
||||
if (noRoleMembers.length > 0) {
|
||||
sortedGroups.push({
|
||||
role: { id: 'none', name: 'Members', color: null, position: -1 },
|
||||
members: noRoleMembers,
|
||||
});
|
||||
}
|
||||
|
||||
return createResponse({
|
||||
roles: Object.values(roleMap).sort((a, b) => b.position - a.position),
|
||||
groups: sortedGroups,
|
||||
totalMembers: formattedMembers.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching members:', error);
|
||||
return createResponse({ error: 'Failed to fetch members' }, 500);
|
||||
}
|
||||
}
|
||||
667
src/pages/api/discord/messages.js
Normal file
667
src/pages/api/discord/messages.js
Normal file
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* API endpoint to fetch Discord messages from database
|
||||
* Includes user info, attachments, embeds, and reactions
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const IMAGE_PROXY_SECRET = process.env.IMAGE_PROXY_SECRET || 'dev-secret-change-me';
|
||||
|
||||
// Trusted domains that don't need proxying
|
||||
const TRUSTED_DOMAINS = new Set([
|
||||
'cdn.discordapp.com',
|
||||
'media.discordapp.net',
|
||||
'i.imgur.com',
|
||||
'imgur.com',
|
||||
'i.redd.it',
|
||||
'preview.redd.it',
|
||||
'youtube.com',
|
||||
'i.ytimg.com',
|
||||
'img.youtube.com',
|
||||
'youtu.be',
|
||||
'twitter.com',
|
||||
'pbs.twimg.com',
|
||||
'abs.twimg.com',
|
||||
'instagram.com',
|
||||
'github.com',
|
||||
'raw.githubusercontent.com',
|
||||
'avatars.githubusercontent.com',
|
||||
'user-images.githubusercontent.com',
|
||||
]);
|
||||
|
||||
function isTrustedDomain(url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return TRUSTED_DOMAINS.has(hostname) ||
|
||||
Array.from(TRUSTED_DOMAINS).some(domain => hostname.endsWith('.' + domain));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function generateSignature(url) {
|
||||
return crypto
|
||||
.createHmac('sha256', IMAGE_PROXY_SECRET)
|
||||
.update(url)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best URL for an image - prefer cached version if available
|
||||
* @param {number|null} cachedImageId - The cached image ID from database
|
||||
* @param {string|null} originalUrl - The original CDN URL
|
||||
* @param {string} baseUrl - Base URL for constructing cached image URLs
|
||||
* @returns {string|null} The URL to use
|
||||
*/
|
||||
function getCachedOrProxyUrl(cachedImageId, originalUrl, baseUrl) {
|
||||
// Prefer cached image if available
|
||||
if (cachedImageId) {
|
||||
return `${baseUrl}/api/discord/cached-image?id=${cachedImageId}`;
|
||||
}
|
||||
|
||||
// Fall back to original URL with proxy if needed
|
||||
if (!originalUrl) return null;
|
||||
|
||||
if (isTrustedDomain(originalUrl)) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
const signature = generateSignature(originalUrl);
|
||||
const encodedUrl = encodeURIComponent(originalUrl);
|
||||
return `${baseUrl}/api/image-proxy?url=${encodedUrl}&sig=${signature}`;
|
||||
}
|
||||
|
||||
function getSafeImageUrl(originalUrl, baseUrl) {
|
||||
if (!originalUrl) return null;
|
||||
|
||||
if (isTrustedDomain(originalUrl)) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
const signature = generateSignature(originalUrl);
|
||||
const encodedUrl = encodeURIComponent(originalUrl);
|
||||
return `${baseUrl}/api/image-proxy?url=${encodedUrl}&sig=${signature}`;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(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 }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const channelId = url.searchParams.get('channelId');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100);
|
||||
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 baseUrl = `${url.protocol}//${url.host}`;
|
||||
|
||||
if (!channelId) {
|
||||
return createResponse({ error: 'channelId is required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake formats
|
||||
if (!isValidSnowflake(channelId) || !isValidSnowflake(before) || !isValidSnowflake(after) || !isValidSnowflake(around)) {
|
||||
return createResponse({ error: 'Invalid ID format' }, 400);
|
||||
}
|
||||
|
||||
// Get guild_id from channel
|
||||
const channelInfo = await sql`
|
||||
SELECT guild_id FROM channels WHERE channel_id = ${channelId}
|
||||
`;
|
||||
const guildId = channelInfo[0]?.guild_id;
|
||||
|
||||
// Build the query with optional pagination
|
||||
// Joins guild_members for nickname and gets highest role color
|
||||
let messages;
|
||||
|
||||
if (before) {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_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.message_id < ${before}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else if (after) {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
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.message_id > ${after}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else if (around) {
|
||||
// Fetch messages around a specific message ID (half before, half after)
|
||||
const halfLimit = Math.floor(limit / 2);
|
||||
messages = await sql`
|
||||
(
|
||||
SELECT
|
||||
m.message_id,
|
||||
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.message_id <= ${around}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${halfLimit + 1}
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT
|
||||
m.message_id,
|
||||
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,
|
||||
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.message_id > ${around}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT ${halfLimit}
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
} else {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
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,
|
||||
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 DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
|
||||
// Messages are already in DESC order (newest first) - no need to reverse
|
||||
|
||||
if (messages.length === 0) {
|
||||
return createResponse({ messages: [], users: {} });
|
||||
}
|
||||
|
||||
// Get all message IDs for fetching related data
|
||||
const messageIds = messages.map(m => m.message_id);
|
||||
|
||||
// Get unique author IDs for fetching cached avatar info
|
||||
const authorIds = [...new Set(messages.map(m => m.author_id).filter(Boolean))];
|
||||
|
||||
// Fetch cached avatar IDs for all authors
|
||||
const userAvatarCache = {};
|
||||
if (authorIds.length > 0) {
|
||||
const avatarInfo = await sql`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.cached_avatar_id,
|
||||
gm.cached_guild_avatar_id
|
||||
FROM users u
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE u.user_id = ANY(${authorIds})
|
||||
`;
|
||||
for (const info of avatarInfo) {
|
||||
userAvatarCache[info.user_id.toString()] = {
|
||||
cachedAvatarId: info.cached_guild_avatar_id || info.cached_avatar_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract all mentioned user IDs from message content
|
||||
const mentionedUserIds = new Set();
|
||||
const userMentionRegex = /<@!?(\d+)>/g;
|
||||
for (const msg of messages) {
|
||||
if (msg.content) {
|
||||
let match;
|
||||
while ((match = userMentionRegex.exec(msg.content)) !== null) {
|
||||
mentionedUserIds.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch mentioned users with their display names and colors
|
||||
let mentionedUsers = [];
|
||||
if (mentionedUserIds.size > 0) {
|
||||
const mentionedIds = Array.from(mentionedUserIds);
|
||||
mentionedUsers = await sql`
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
(
|
||||
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 color
|
||||
FROM users u
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
WHERE u.user_id = ANY(${mentionedIds})
|
||||
`;
|
||||
}
|
||||
|
||||
// Build users map for mentions
|
||||
const usersMap = {};
|
||||
for (const user of mentionedUsers) {
|
||||
const colorHex = user.color ? `#${user.color.toString(16).padStart(6, '0')}` : null;
|
||||
usersMap[user.user_id.toString()] = {
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
color: colorHex,
|
||||
};
|
||||
}
|
||||
|
||||
// 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})
|
||||
`;
|
||||
|
||||
// Fetch embeds for all messages
|
||||
const embeds = await sql`
|
||||
SELECT
|
||||
embed_id, message_id, embed_type, title, description, url,
|
||||
color, timestamp as embed_timestamp,
|
||||
footer_text, footer_icon_url, cached_footer_icon_id,
|
||||
image_url, image_width, image_height, cached_image_id,
|
||||
thumbnail_url, thumbnail_width, thumbnail_height, cached_thumbnail_id,
|
||||
video_url, video_width, video_height,
|
||||
provider_name, provider_url,
|
||||
author_name, author_url, author_icon_url, cached_author_icon_id
|
||||
FROM embeds
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch reactions for all messages (aggregate counts since each reaction is a row)
|
||||
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
|
||||
`;
|
||||
|
||||
// Fetch stickers for all messages
|
||||
const stickers = await sql`
|
||||
SELECT
|
||||
ms.message_id,
|
||||
s.sticker_id,
|
||||
s.name,
|
||||
s.format_type
|
||||
FROM message_stickers ms
|
||||
JOIN stickers s ON ms.sticker_id = s.sticker_id
|
||||
WHERE ms.message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch referenced messages for replies
|
||||
const referencedIds = messages
|
||||
.filter(m => m.reference_message_id)
|
||||
.map(m => m.reference_message_id);
|
||||
|
||||
let referencedMessages = [];
|
||||
if (referencedIds.length > 0) {
|
||||
referencedMessages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.content,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.avatar_url as author_avatar,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar
|
||||
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.message_id = ANY(${referencedIds})
|
||||
`;
|
||||
}
|
||||
|
||||
// Index related data by message_id for quick lookup
|
||||
const attachmentsByMessage = {};
|
||||
const embedsByMessage = {};
|
||||
const reactionsByMessage = {};
|
||||
const stickersByMessage = {};
|
||||
const referencedById = {};
|
||||
|
||||
for (const att of attachments) {
|
||||
if (!attachmentsByMessage[att.message_id]) {
|
||||
attachmentsByMessage[att.message_id] = [];
|
||||
}
|
||||
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,
|
||||
size: att.size,
|
||||
width: att.width,
|
||||
height: att.height,
|
||||
});
|
||||
}
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (!embedsByMessage[embed.message_id]) {
|
||||
embedsByMessage[embed.message_id] = [];
|
||||
}
|
||||
embedsByMessage[embed.message_id].push({
|
||||
type: embed.embed_type || 'rich',
|
||||
title: embed.title,
|
||||
description: embed.description,
|
||||
url: embed.url,
|
||||
color: embed.color,
|
||||
timestamp: embed.embed_timestamp,
|
||||
footer: embed.footer_text ? {
|
||||
text: embed.footer_text,
|
||||
iconUrl: getCachedOrProxyUrl(embed.cached_footer_icon_id, embed.footer_icon_url, baseUrl),
|
||||
} : null,
|
||||
image: embed.image_url ? {
|
||||
url: getCachedOrProxyUrl(embed.cached_image_id, embed.image_url, baseUrl),
|
||||
width: embed.image_width,
|
||||
height: embed.image_height,
|
||||
} : null,
|
||||
thumbnail: embed.thumbnail_url ? {
|
||||
url: getCachedOrProxyUrl(embed.cached_thumbnail_id, embed.thumbnail_url, baseUrl),
|
||||
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,
|
||||
provider: embed.provider_name ? {
|
||||
name: embed.provider_name,
|
||||
url: embed.provider_url,
|
||||
} : null,
|
||||
author: embed.author_name ? {
|
||||
name: embed.author_name,
|
||||
url: embed.author_url,
|
||||
iconUrl: getCachedOrProxyUrl(embed.cached_author_icon_id, embed.author_icon_url, baseUrl),
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const reaction of reactions) {
|
||||
if (!reactionsByMessage[reaction.message_id]) {
|
||||
reactionsByMessage[reaction.message_id] = [];
|
||||
}
|
||||
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 },
|
||||
count: parseInt(reaction.count, 10),
|
||||
});
|
||||
}
|
||||
|
||||
// Sticker format types: 1=PNG, 2=APNG, 3=Lottie, 4=GIF
|
||||
const stickerExtensions = { 1: 'png', 2: 'png', 3: 'json', 4: 'gif' };
|
||||
for (const sticker of stickers) {
|
||||
if (!stickersByMessage[sticker.message_id]) {
|
||||
stickersByMessage[sticker.message_id] = [];
|
||||
}
|
||||
const ext = stickerExtensions[sticker.format_type] || 'png';
|
||||
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`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const ref of referencedMessages) {
|
||||
// Handle avatar URL for referenced message author
|
||||
const refAvatarSource = ref.author_guild_avatar || ref.author_avatar;
|
||||
let refAvatarUrl;
|
||||
if (refAvatarSource) {
|
||||
if (refAvatarSource.startsWith('http')) {
|
||||
refAvatarUrl = refAvatarSource;
|
||||
} else {
|
||||
refAvatarUrl = `https://cdn.discordapp.com/avatars/${ref.author_id}/${refAvatarSource}.png?size=32`;
|
||||
}
|
||||
}
|
||||
|
||||
referencedById[ref.message_id] = {
|
||||
id: ref.message_id.toString(),
|
||||
content: ref.content,
|
||||
author: {
|
||||
id: ref.author_id?.toString(),
|
||||
username: ref.author_username,
|
||||
displayName: ref.author_display_name,
|
||||
avatar: refAvatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build the formatted response
|
||||
const formattedMessages = messages.map(msg => {
|
||||
// Check for cached avatar first
|
||||
const authorId = msg.author_id?.toString();
|
||||
const cachedInfo = userAvatarCache[authorId];
|
||||
|
||||
let avatarUrl;
|
||||
if (cachedInfo?.cachedAvatarId) {
|
||||
// Use cached avatar from database
|
||||
avatarUrl = `${baseUrl}/api/discord/cached-image?id=${cachedInfo.cachedAvatarId}`;
|
||||
} else {
|
||||
// Handle avatar URL - prefer guild avatar, then user avatar
|
||||
const avatarSource = msg.author_guild_avatar || msg.author_avatar;
|
||||
if (avatarSource) {
|
||||
// If it's already a full URL, use it directly
|
||||
if (avatarSource.startsWith('http')) {
|
||||
avatarUrl = avatarSource;
|
||||
} else {
|
||||
// Otherwise, construct the CDN URL from the hash
|
||||
// Guild avatars use a different path
|
||||
if (msg.author_guild_avatar && guildId) {
|
||||
avatarUrl = `https://cdn.discordapp.com/guilds/${guildId}/users/${msg.author_id}/avatars/${msg.author_guild_avatar}.png`;
|
||||
} else {
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${msg.author_id}/${avatarSource}.png`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${(parseInt(msg.author_discriminator || '0', 10) || parseInt(msg.author_id, 10)) % 5}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert color from decimal to hex if present
|
||||
const colorHex = msg.author_color ? `#${msg.author_color.toString(16).padStart(6, '0')}` : null;
|
||||
|
||||
// Determine if this is a server-forwarded webhook message
|
||||
// Server-forwarded messages have a webhook_id AND reference_guild_id differs from current guild
|
||||
const isWebhook = !!msg.webhook_id;
|
||||
const isServerForwarded = isWebhook && msg.reference_guild_id &&
|
||||
msg.reference_guild_id.toString() !== msg.message_guild_id?.toString();
|
||||
|
||||
return {
|
||||
id: msg.message_id.toString(),
|
||||
content: msg.content,
|
||||
timestamp: msg.created_at?.toISOString(),
|
||||
editedTimestamp: msg.edited_at?.toISOString() || null,
|
||||
author: {
|
||||
id: msg.author_id?.toString(),
|
||||
username: msg.author_username || 'Unknown User',
|
||||
displayName: msg.author_display_name || msg.author_username || 'Unknown User',
|
||||
discriminator: msg.author_discriminator || '0000',
|
||||
avatar: avatarUrl,
|
||||
bot: msg.author_bot || false,
|
||||
isWebhook: isWebhook,
|
||||
isServerForwarded: isServerForwarded,
|
||||
color: colorHex,
|
||||
},
|
||||
attachments: attachmentsByMessage[msg.message_id] || [],
|
||||
embeds: embedsByMessage[msg.message_id] || [],
|
||||
stickers: stickersByMessage[msg.message_id] || [],
|
||||
reactions: reactionsByMessage[msg.message_id] || [],
|
||||
referencedMessage: msg.reference_message_id
|
||||
? referencedById[msg.reference_message_id] || null
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return createResponse({ messages: formattedMessages, users: usersMap });
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
return createResponse({ error: 'Failed to fetch messages' }, 500);
|
||||
}
|
||||
}
|
||||
113
src/pages/api/discord/reaction-users.js
Normal file
113
src/pages/api/discord/reaction-users.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* API endpoint to fetch users who reacted with a specific emoji on a message
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(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 }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const messageId = url.searchParams.get('messageId');
|
||||
const emojiName = url.searchParams.get('emojiName');
|
||||
const emojiId = url.searchParams.get('emojiId'); // null for unicode emoji
|
||||
|
||||
if (!messageId || !emojiName) {
|
||||
return createResponse({ error: 'messageId and emojiName are required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake formats
|
||||
if (!isValidSnowflake(messageId) || !isValidSnowflake(emojiId)) {
|
||||
return createResponse({ error: 'Invalid ID format' }, 400);
|
||||
}
|
||||
|
||||
// Get the guild_id from the message's channel for proper display names
|
||||
const messageInfo = await sql`
|
||||
SELECT c.guild_id
|
||||
FROM messages m
|
||||
JOIN channels c ON m.channel_id = c.channel_id
|
||||
WHERE m.message_id = ${messageId}
|
||||
`;
|
||||
const guildId = messageInfo[0]?.guild_id;
|
||||
|
||||
// Fetch users who reacted with this emoji
|
||||
let users;
|
||||
if (emojiId) {
|
||||
users = await sql`
|
||||
SELECT
|
||||
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
|
||||
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}
|
||||
WHERE r.message_id = ${messageId}
|
||||
AND r.emoji_name = ${emojiName}
|
||||
AND r.emoji_id = ${emojiId}
|
||||
AND r.is_removed = FALSE
|
||||
ORDER BY r.added_at ASC
|
||||
`;
|
||||
} else {
|
||||
users = await sql`
|
||||
SELECT
|
||||
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
|
||||
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}
|
||||
WHERE r.message_id = ${messageId}
|
||||
AND r.emoji_name = ${emojiName}
|
||||
AND r.emoji_id IS NULL
|
||||
AND r.is_removed = FALSE
|
||||
ORDER BY r.added_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
const formattedUsers = users.map(u => {
|
||||
// Prefer cached avatar
|
||||
const cachedAvatarId = u.cached_guild_avatar_id || u.cached_avatar_id;
|
||||
let avatar;
|
||||
if (cachedAvatarId) {
|
||||
avatar = `/api/discord/cached-image?id=${cachedAvatarId}`;
|
||||
} else {
|
||||
avatar = u.guild_avatar || u.avatar_url;
|
||||
}
|
||||
|
||||
return {
|
||||
id: u.user_id.toString(),
|
||||
username: u.username,
|
||||
displayName: u.display_name,
|
||||
avatar,
|
||||
};
|
||||
});
|
||||
|
||||
return createResponse(formattedUsers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reaction users:', error);
|
||||
return createResponse({ error: 'Failed to fetch reaction users' }, 500);
|
||||
}
|
||||
}
|
||||
286
src/pages/api/discord/search.js
Normal file
286
src/pages/api/discord/search.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* API endpoint to search Discord messages
|
||||
* Searches message content and embed content
|
||||
*/
|
||||
import sql from '../../../utils/db.js';
|
||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(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 }),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const channelId = url.searchParams.get('channelId');
|
||||
const query = url.searchParams.get('q');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100);
|
||||
|
||||
if (!channelId) {
|
||||
return createResponse({ error: 'channelId is required' }, 400);
|
||||
}
|
||||
|
||||
// Validate snowflake format
|
||||
if (!isValidSnowflake(channelId)) {
|
||||
return createResponse({ error: 'Invalid channelId format' }, 400);
|
||||
}
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
return createResponse({ error: 'Search query must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
const searchPattern = `%${query}%`;
|
||||
|
||||
// Search messages by content, author username, or embed content
|
||||
const messages = await sql`
|
||||
SELECT DISTINCT
|
||||
m.message_id,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_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
|
||||
LEFT JOIN embeds e ON m.message_id = e.message_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.is_deleted = FALSE
|
||||
AND (
|
||||
m.content ILIKE ${searchPattern}
|
||||
OR u.username ILIKE ${searchPattern}
|
||||
OR u.global_name ILIKE ${searchPattern}
|
||||
OR gm.nickname ILIKE ${searchPattern}
|
||||
OR e.title ILIKE ${searchPattern}
|
||||
OR e.description ILIKE ${searchPattern}
|
||||
OR e.author_name ILIKE ${searchPattern}
|
||||
)
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
if (messages.length === 0) {
|
||||
return createResponse({ messages: [], totalCount: 0 });
|
||||
}
|
||||
|
||||
// Get message IDs for fetching related data
|
||||
const messageIds = messages.map(m => m.message_id);
|
||||
|
||||
// Fetch attachments
|
||||
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
|
||||
`;
|
||||
|
||||
// Fetch embeds
|
||||
const embeds = await sql`
|
||||
SELECT
|
||||
embed_id, message_id, embed_type, title, description, url,
|
||||
color, timestamp as embed_timestamp,
|
||||
footer_text, footer_icon_url,
|
||||
image_url, image_width, image_height,
|
||||
thumbnail_url, thumbnail_width, thumbnail_height,
|
||||
video_url, video_width, video_height,
|
||||
provider_name, provider_url,
|
||||
author_name, author_url, author_icon_url
|
||||
FROM embeds
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch reactions
|
||||
const reactions = await sql`
|
||||
SELECT
|
||||
message_id,
|
||||
emoji_id,
|
||||
emoji_name,
|
||||
emoji_animated,
|
||||
COUNT(*) as count
|
||||
FROM reactions
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
AND is_removed = FALSE
|
||||
GROUP BY message_id, emoji_id, emoji_name, emoji_animated
|
||||
`;
|
||||
|
||||
// Fetch referenced messages (for replies)
|
||||
const referencedIds = messages
|
||||
.filter(m => m.reference_message_id)
|
||||
.map(m => m.reference_message_id);
|
||||
|
||||
let referencedMessages = [];
|
||||
if (referencedIds.length > 0) {
|
||||
referencedMessages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.content,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name
|
||||
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.message_id = ANY(${referencedIds})
|
||||
`;
|
||||
}
|
||||
|
||||
// Build referenced message map
|
||||
const referencedMap = {};
|
||||
referencedMessages.forEach(ref => {
|
||||
referencedMap[ref.message_id] = {
|
||||
id: ref.message_id,
|
||||
content: ref.content,
|
||||
author: {
|
||||
id: ref.author_id,
|
||||
username: ref.author_username,
|
||||
displayName: ref.author_display_name,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Group attachments, embeds, and reactions by message
|
||||
const attachmentsByMessage = {};
|
||||
attachments.forEach(att => {
|
||||
if (!attachmentsByMessage[att.message_id]) {
|
||||
attachmentsByMessage[att.message_id] = [];
|
||||
}
|
||||
attachmentsByMessage[att.message_id].push({
|
||||
id: att.attachment_id,
|
||||
filename: att.filename,
|
||||
url: att.url,
|
||||
proxyUrl: att.proxy_url,
|
||||
contentType: att.content_type,
|
||||
size: att.size,
|
||||
width: att.width,
|
||||
height: att.height,
|
||||
});
|
||||
});
|
||||
|
||||
const embedsByMessage = {};
|
||||
embeds.forEach(emb => {
|
||||
if (!embedsByMessage[emb.message_id]) {
|
||||
embedsByMessage[emb.message_id] = [];
|
||||
}
|
||||
embedsByMessage[emb.message_id].push({
|
||||
type: emb.embed_type,
|
||||
title: emb.title,
|
||||
description: emb.description,
|
||||
url: emb.url,
|
||||
color: emb.color,
|
||||
timestamp: emb.embed_timestamp,
|
||||
footer: emb.footer_text ? {
|
||||
text: emb.footer_text,
|
||||
iconUrl: emb.footer_icon_url,
|
||||
} : null,
|
||||
image: emb.image_url ? {
|
||||
url: emb.image_url,
|
||||
width: emb.image_width,
|
||||
height: emb.image_height,
|
||||
} : null,
|
||||
thumbnail: emb.thumbnail_url ? {
|
||||
url: emb.thumbnail_url,
|
||||
width: emb.thumbnail_width,
|
||||
height: emb.thumbnail_height,
|
||||
} : null,
|
||||
video: emb.video_url ? {
|
||||
url: emb.video_url,
|
||||
width: emb.video_width,
|
||||
height: emb.video_height,
|
||||
} : null,
|
||||
provider: emb.provider_name ? {
|
||||
name: emb.provider_name,
|
||||
url: emb.provider_url,
|
||||
} : null,
|
||||
author: emb.author_name ? {
|
||||
name: emb.author_name,
|
||||
url: emb.author_url,
|
||||
iconUrl: emb.author_icon_url,
|
||||
} : null,
|
||||
fields: [],
|
||||
});
|
||||
});
|
||||
|
||||
const reactionsByMessage = {};
|
||||
reactions.forEach(r => {
|
||||
if (!reactionsByMessage[r.message_id]) {
|
||||
reactionsByMessage[r.message_id] = [];
|
||||
}
|
||||
reactionsByMessage[r.message_id].push({
|
||||
emoji: {
|
||||
id: r.emoji_id,
|
||||
name: r.emoji_name,
|
||||
animated: r.emoji_animated,
|
||||
},
|
||||
count: r.count,
|
||||
});
|
||||
});
|
||||
|
||||
// Format response
|
||||
const formattedMessages = messages.map(msg => ({
|
||||
id: msg.message_id,
|
||||
content: msg.content,
|
||||
timestamp: msg.created_at,
|
||||
editedAt: msg.edited_at,
|
||||
author: {
|
||||
id: msg.author_id,
|
||||
username: msg.author_username,
|
||||
discriminator: msg.author_discriminator,
|
||||
avatar: msg.author_guild_avatar || msg.author_avatar,
|
||||
displayName: msg.author_display_name,
|
||||
bot: msg.author_bot,
|
||||
color: msg.author_color ? `#${msg.author_color.toString(16).padStart(6, '0')}` : null,
|
||||
},
|
||||
attachments: attachmentsByMessage[msg.message_id] || [],
|
||||
embeds: embedsByMessage[msg.message_id] || [],
|
||||
reactions: reactionsByMessage[msg.message_id] || [],
|
||||
referencedMessage: msg.reference_message_id ? referencedMap[msg.reference_message_id] : null,
|
||||
}));
|
||||
|
||||
return createResponse({
|
||||
messages: formattedMessages,
|
||||
totalCount: formattedMessages.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
return createResponse({ error: 'Failed to search messages' }, 500);
|
||||
}
|
||||
}
|
||||
229
src/pages/api/image-proxy.js
Normal file
229
src/pages/api/image-proxy.js
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Server-side image proxy API endpoint
|
||||
* Proxies images from untrusted domains to prevent user IP exposure
|
||||
* Uses HMAC signatures to prevent abuse
|
||||
*/
|
||||
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
generateNonce,
|
||||
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';
|
||||
|
||||
// Max image size to proxy (25MB - needed for animated GIFs)
|
||||
const MAX_IMAGE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
// Allowed content types
|
||||
const ALLOWED_CONTENT_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/bmp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
'image/avif',
|
||||
'image/apng',
|
||||
];
|
||||
|
||||
// Image extensions for fallback content-type detection
|
||||
const IMAGE_EXTENSIONS = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.avif': 'image/avif',
|
||||
'.apng': 'image/apng',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get content type from URL extension
|
||||
*/
|
||||
function getContentTypeFromUrl(url) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname.toLowerCase();
|
||||
for (const [ext, type] of Object.entries(IMAGE_EXTENSIONS)) {
|
||||
if (pathname.endsWith(ext)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC signature for a URL
|
||||
*/
|
||||
export async function signImageUrl(imageUrl) {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(SIGNING_SECRET),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(imageUrl));
|
||||
const signatureHex = Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return signatureHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature for a URL
|
||||
*/
|
||||
async function verifySignature(imageUrl, signature) {
|
||||
const expectedSignature = await signImageUrl(imageUrl);
|
||||
return signature === expectedSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed proxy URL for an image
|
||||
*/
|
||||
export async function createSignedProxyUrl(imageUrl) {
|
||||
const signature = await signImageUrl(imageUrl);
|
||||
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 20,
|
||||
windowMs: 1000,
|
||||
burstLimit: 100,
|
||||
burstWindowMs: 10_000,
|
||||
});
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const response = new Response('Rate limit exceeded', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '1' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const imageUrl = url.searchParams.get('url');
|
||||
const signature = url.searchParams.get('sig');
|
||||
|
||||
if (!imageUrl) {
|
||||
return new Response('Missing url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
if (!signature) {
|
||||
return new Response('Missing signature', { status: 403 });
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const isValid = await verifySignature(imageUrl, signature);
|
||||
if (!isValid) {
|
||||
return new Response('Invalid signature', { status: 403 });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(imageUrl);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response('Invalid URL', { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(imageUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ImageProxy/1.0)',
|
||||
'Accept': 'image/*',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response('Failed to fetch image', { status: 502 });
|
||||
}
|
||||
|
||||
let contentType = response.headers.get('content-type') || '';
|
||||
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
||||
|
||||
// Validate content type - must be an image
|
||||
let isAllowedType = ALLOWED_CONTENT_TYPES.some(type => contentType.startsWith(type));
|
||||
|
||||
// If server returned generic binary type, try to infer from URL extension
|
||||
if (!isAllowedType && (contentType.includes('octet-stream') || contentType === '')) {
|
||||
const inferredType = getContentTypeFromUrl(imageUrl);
|
||||
if (inferredType) {
|
||||
contentType = inferredType;
|
||||
isAllowedType = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAllowedType) {
|
||||
console.error(`[image-proxy] Invalid content type: ${contentType} for URL: ${imageUrl}`);
|
||||
return new Response(`Invalid content type: ${contentType}`, { status: 400 });
|
||||
}
|
||||
|
||||
// Check size limit
|
||||
if (contentLength > MAX_IMAGE_SIZE) {
|
||||
return new Response('Image too large', { status: 413 });
|
||||
}
|
||||
|
||||
// Stream the image through
|
||||
const imageData = await response.arrayBuffer();
|
||||
|
||||
// Double-check size after download
|
||||
if (imageData.byteLength > MAX_IMAGE_SIZE) {
|
||||
return new Response('Image too large', { status: 413 });
|
||||
}
|
||||
|
||||
const proxyResponse = new Response(imageData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': imageData.byteLength.toString(),
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
|
||||
if (!hadCookie) {
|
||||
proxyResponse.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
|
||||
return proxyResponse;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[image-proxy] Error fetching image:', err.message);
|
||||
return new Response('Failed to fetch image', { status: 500 });
|
||||
}
|
||||
}
|
||||
399
src/pages/api/link-preview.js
Normal file
399
src/pages/api/link-preview.js
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Server-side link preview API endpoint
|
||||
* Fetches Open Graph / meta data for URLs to prevent user IP exposure
|
||||
* Returns signed proxy URLs for images from untrusted domains
|
||||
*/
|
||||
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
generateNonce,
|
||||
createNonceCookie,
|
||||
} from '../../utils/rateLimit.js';
|
||||
import { signImageUrl } from './image-proxy.js';
|
||||
|
||||
// Trusted domains that can be loaded client-side (embed-safe providers)
|
||||
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',
|
||||
'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',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a URL is from a trusted domain
|
||||
*/
|
||||
function isTrustedDomain(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return TRUSTED_DOMAINS.has(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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,
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
siteName: null,
|
||||
type: null,
|
||||
video: null,
|
||||
themeColor: null,
|
||||
};
|
||||
|
||||
// Helper to extract content from meta tags
|
||||
const getMetaContent = (pattern) => {
|
||||
const match = html.match(pattern);
|
||||
return match ? decodeHTMLEntities(match[1]) : null;
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Theme color
|
||||
meta.themeColor = getMetaContent(/<meta[^>]+name=["']theme-color["'][^>]+content=["']([^"']+)["']/i)
|
||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']theme-color["']/i);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
} 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
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 10,
|
||||
windowMs: 1000,
|
||||
burstLimit: 50,
|
||||
burstWindowMs: 10_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' }
|
||||
: { error: 'Rate limit exceeded' };
|
||||
const response = new Response(JSON.stringify(errorMsg), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '1',
|
||||
},
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
recordRequest(request, 1000);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const targetUrl = url.searchParams.get('url');
|
||||
|
||||
if (!targetUrl) {
|
||||
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(targetUrl);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; DiscordBot/2.0; +https://discordapp.com)',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to fetch URL',
|
||||
status: response.status
|
||||
}), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Handle image URLs directly - return safe (possibly proxied) URL
|
||||
if (contentType.startsWith('image/')) {
|
||||
const safeImageUrl = await getSafeImageUrl(targetUrl);
|
||||
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',
|
||||
},
|
||||
});
|
||||
if (!hadCookie) {
|
||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Handle video URLs directly (no proxy for video - too large)
|
||||
if (contentType.startsWith('video/')) {
|
||||
// Only allow trusted video sources
|
||||
if (!trusted) {
|
||||
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 resp = new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
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
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Read only the first 50KB to get meta tags (they're usually in <head>)
|
||||
const reader = response.body.getReader();
|
||||
let html = '';
|
||||
let bytesRead = 0;
|
||||
const maxBytes = 50 * 1024;
|
||||
|
||||
while (bytesRead < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
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);
|
||||
}
|
||||
|
||||
const resp = new Response(JSON.stringify(meta), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
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
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/pages/discord-logs.astro
Normal file
32
src/pages/discord-logs.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
---
|
||||
|
||||
<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>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for Discord logs */
|
||||
body:has(.discord-logs-section) main.page-enter {
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
|
||||
.discord-logs-page {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
117
src/utils/apiAuth.js
Normal file
117
src/utils/apiAuth.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* API route authentication helper
|
||||
* Validates user session for protected API endpoints
|
||||
*/
|
||||
import { API_URL } from '@/config';
|
||||
|
||||
/**
|
||||
* Check if the request has a valid authentication session
|
||||
* @param {Request} request - The incoming request
|
||||
* @returns {Promise<{user: object|null, error: Response|null, setCookieHeader: string|null}>}
|
||||
*/
|
||||
export async function requireApiAuth(request) {
|
||||
try {
|
||||
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||
|
||||
if (!cookieHeader) {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to get user identity
|
||||
let res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
let newSetCookieHeader = null;
|
||||
|
||||
// If unauthorized, try to refresh the token
|
||||
if (res.status === 401) {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Session expired' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Capture the Set-Cookie header from the refresh response to forward to client
|
||||
newSetCookieHeader = refreshRes.headers.get('set-cookie');
|
||||
|
||||
let newCookieHeader = cookieHeader;
|
||||
|
||||
if (newSetCookieHeader) {
|
||||
const cookiesArray = newSetCookieHeader.split(/,(?=\s*\w+=)/);
|
||||
newCookieHeader = cookiesArray.map(c => c.split(';')[0]).join('; ');
|
||||
} else {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Session refresh failed' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication failed' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
return { user, error: null, setCookieHeader: newSetCookieHeader };
|
||||
} catch (err) {
|
||||
console.error('API auth error:', err);
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a response with optional Set-Cookie header forwarding
|
||||
* @param {any} data - Response data
|
||||
* @param {number} status - HTTP status code
|
||||
* @param {string|null} setCookieHeader - Set-Cookie header from auth refresh
|
||||
*/
|
||||
export function createApiResponse(data, status = 200, setCookieHeader = null) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (setCookieHeader) {
|
||||
headers['Set-Cookie'] = setCookieHeader;
|
||||
}
|
||||
return new Response(JSON.stringify(data), { status, headers });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
// Track in-flight refresh to avoid duplicate requests
|
||||
let refreshPromise = null;
|
||||
|
||||
// Auth fetch wrapper
|
||||
export const authFetch = async (url, options = {}, retry = true) => {
|
||||
@@ -9,12 +11,19 @@ export const authFetch = async (url, options = {}, retry = true) => {
|
||||
});
|
||||
|
||||
if (res.status === 401 && retry) {
|
||||
// attempt refresh
|
||||
// attempt refresh (non-blocking if already in progress)
|
||||
try {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
// Reuse existing refresh promise if one is in flight
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
}).finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
const refreshRes = await refreshPromise;
|
||||
|
||||
if (!refreshRes.ok) throw new Error("Refresh failed");
|
||||
|
||||
@@ -51,6 +60,56 @@ export async function refreshAccessToken(cookieHeader) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authentication is valid before making API requests.
|
||||
* Makes a lightweight auth check against our own API and refreshes if needed.
|
||||
* Returns true if auth is valid, false if user needs to log in.
|
||||
*/
|
||||
export async function ensureAuth() {
|
||||
try {
|
||||
// Try a lightweight request to our own API that requires auth
|
||||
// Using HEAD or a simple endpoint to minimize overhead
|
||||
const res = await fetch('/api/discord/channels', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// Try to refresh the token via our external auth API
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
const refreshRes = await refreshPromise;
|
||||
if (refreshRes.ok) {
|
||||
// Retry the auth check after refresh
|
||||
const retryRes = await fetch('/api/discord/channels', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
return retryRes.ok;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other errors (500, etc.) - assume auth is OK but server issue
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
// Network error - don't redirect, let the actual API calls handle it
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleLogout() {
|
||||
document.cookie.split(";").forEach((cookie) => {
|
||||
const name = cookie.split("=")[0].trim();
|
||||
|
||||
18
src/utils/db.js
Normal file
18
src/utils/db.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* PostgreSQL database connection for Discord logs
|
||||
*/
|
||||
import postgres from 'postgres';
|
||||
|
||||
// Database connection configuration
|
||||
const sql = postgres({
|
||||
host: import.meta.env.DISCORD_DB_HOST || 'localhost',
|
||||
port: parseInt(import.meta.env.DISCORD_DB_PORT || '5432', 10),
|
||||
database: import.meta.env.DISCORD_DB_NAME || 'discord',
|
||||
username: import.meta.env.DISCORD_DB_USER || 'discord',
|
||||
password: import.meta.env.DISCORD_DB_PASSWORD || '',
|
||||
max: 10, // Max connections in pool
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
export default sql;
|
||||
Reference in New Issue
Block a user