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:
2025-12-03 13:27:37 -05:00
parent c3f0197115
commit 55e4c5ff0c
20 changed files with 7066 additions and 9 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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%;

View File

@@ -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" />}

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 },

View File

@@ -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.

View 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 });
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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 });
}
}

View 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/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' },
});
}
}

View 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
View 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 });
}

View File

@@ -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
View 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;