diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index 8107d19..d8a4011 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -364,7 +364,6 @@ Custom padding: 0.25rem 0.5rem; border-radius: 9999px; border: 1px solid; - text-transform: uppercase; letter-spacing: 0.025em; } diff --git a/src/assets/styles/nav.css b/src/assets/styles/nav.css index 58a88ff..44bd64b 100644 --- a/src/assets/styles/nav.css +++ b/src/assets/styles/nav.css @@ -29,10 +29,19 @@ } .mobile-menu-dropdown.open { - max-height: 500px; + max-height: none; + overflow: visible; + padding-bottom: 0.75rem; opacity: 1; } +.mobile-menu-dropdown a { + font-size: 0.95rem; + line-height: 1.25rem; + padding: 0.6rem 0.75rem; + border-radius: 12px; +} + @media (min-width: 768px) { .mobile-menu-dropdown { display: none; diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 705f314..84efb88 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -46,7 +46,8 @@ export interface RootProps { export default function Root({ child, user = undefined, ...props }: RootProps): React.ReactElement { window.toast = toast; - const theme = document.documentElement.getAttribute("data-theme") + const theme = document.documentElement.getAttribute("data-theme") ?? null; + const toastTheme = theme ?? undefined; const loggedIn = props.loggedIn ?? Boolean(user); usePrimeReactThemeSwitcher(theme); // Avoid adding the Player island for subsite requests. We expose a @@ -81,7 +82,7 @@ export default function Root({ child, user = undefined, ...props }: RootProps): let mounted = true; if (wantPlayer) { if (import.meta.env.DEV) { try { console.debug('[AppLayout] dynamic-import: requesting AudioPlayer'); } catch (e) { } } - import('./AudioPlayer.jsx') + import('./Radio.js') .then((mod) => { if (!mounted) return; // set the component factory @@ -102,7 +103,7 @@ export default function Root({ child, user = undefined, ...props }: RootProps): return ( @@ -114,22 +115,22 @@ export default function Root({ child, user = undefined, ...props }: RootProps): Work in progress... bugs are to be expected. */} {child == "LoginPage" && ()} - {child == "LyricSearch" && ()} + {child == "LyricSearch" && ()} {child == "Player" && !isSubsite && PlayerComp && ( - + )} - {child == "Memes" && } + {child == "Memes" && } {child == "DiscordLogs" && ( Loading...}> - + )} - {child == "qs2.MediaRequestForm" && } - {child == "qs2.RequestManagement" && } - {child == "ReqForm" && } - {child == "Lighting" && } + {child == "qs2.MediaRequestForm" && } + {child == "qs2.RequestManagement" && } + {child == "ReqForm" && } + {child == "Lighting" && } ); diff --git a/src/components/DiscordLogs.tsx b/src/components/DiscordLogs.tsx index 0522c3e..e7b308c 100644 --- a/src/components/DiscordLogs.tsx +++ b/src/components/DiscordLogs.tsx @@ -8,220 +8,233 @@ import { authFetch } from '@/utils/authFetch'; // ============================================================================ interface DiscordUser { - id: string; - username: string; - displayName?: string; - avatar?: string; - avatarUrl?: string; - color?: string | number; - isArchiveResolved?: boolean; - bot?: boolean; - isWebhook?: boolean; - isServerForwarded?: boolean; + id: string; + username: string; + displayName?: string; + avatar?: string; + avatarUrl?: string; + color?: string | number; + isArchiveResolved?: boolean; + bot?: boolean; + isWebhook?: boolean; + isServerForwarded?: boolean; } interface DiscordRole { - id: string; - name: string; - color?: string | number; + id: string; + name: string; + color?: string | number; } interface DiscordChannel { - id: string; - name: string; - type?: number; - guildId?: string; - guildName?: string; - guildIcon?: string; - parentId?: string; - position?: number; - topic?: string; - messageCount?: number; - threads?: DiscordChannel[]; + id: string; + name: string; + type?: number; + guildId?: string; + guildName?: string; + guildIcon?: string; + parentId?: string; + position?: number; + topic?: string; + messageCount?: number; + threads?: DiscordChannel[]; + categoryName?: string | null; + categoryPosition?: number; + guild?: DiscordGuild; } interface DiscordEmoji { - id: string; - name: string; - url?: string; - animated?: boolean; + id: string; + name: string; + url?: string; + animated?: boolean; } interface DiscordAttachment { - id: string; - filename?: string; - url: string; - size?: number; - content_type?: string; - width?: number; - height?: number; - originalUrl?: string; + id: string; + filename?: string; + url: string; + size?: number; + content_type?: string; + width?: number; + height?: number; + originalUrl?: string; } interface DiscordEmbed { - url?: string; - title?: string; - description?: string; - color?: number; - author?: { name?: string; url?: string; icon_url?: string }; - thumbnail?: { url?: string; width?: number; height?: number }; - image?: { url?: string; width?: number; height?: number }; - video?: { url?: string; width?: number; height?: number }; - footer?: { text?: string; icon_url?: string }; - fields?: Array<{ name: string; value: string; inline?: boolean }>; - timestamp?: string; - provider?: { name?: string; url?: string }; - type?: string; + url?: string; + title?: string; + description?: string; + color?: number; + author?: { name?: string; url?: string; icon_url?: string }; + thumbnail?: { url?: string; width?: number; height?: number }; + image?: { url?: string; width?: number; height?: number }; + video?: { url?: string; width?: number; height?: number }; + footer?: { text?: string; icon_url?: string }; + fields?: Array<{ name: string; value: string; inline?: boolean }>; + timestamp?: string; + provider?: { name?: string; url?: string }; + type?: string; } interface DiscordReaction { - emoji: { id?: string; name: string; animated?: boolean; url?: string }; - count: number; - me?: boolean; + emoji: { id?: string; name: string; animated?: boolean; url?: string }; + count: number; + me?: boolean; } interface DiscordPollAnswer { - answer_id: number; - poll_media: { text?: string; emoji?: DiscordEmoji }; - voteCount?: number; - // Runtime convenience properties - id?: number; - text?: string; - emoji?: DiscordEmoji; - voters?: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>; + answer_id: number; + poll_media: { text?: string; emoji?: DiscordEmoji }; + voteCount?: number; + // Runtime convenience properties + id?: number; + text?: string; + emoji?: DiscordEmoji; + voters?: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>; } interface DiscordPoll { - question: { text: string; emoji?: DiscordEmoji }; - question_text?: string; - answers: DiscordPollAnswer[]; - expiry?: string; - allow_multiselect?: boolean; - allowMultiselect?: boolean; - isFinalized?: boolean; - totalVotes?: number; - results?: { - answer_counts: Array<{ id: number; count: number; me_voted?: boolean }>; - }; + question: { text: string; emoji?: DiscordEmoji }; + question_text?: string; + answers: DiscordPollAnswer[]; + expiry?: string; + allow_multiselect?: boolean; + allowMultiselect?: boolean; + isFinalized?: boolean; + totalVotes?: number; + results?: { + answer_counts: Array<{ id: number; count: number; me_voted?: boolean }>; + }; } interface DiscordSticker { - id: string; - name: string; - format_type: number; - formatType?: number; - data?: unknown; - lottieData?: unknown; - url?: string; + id: string; + name: string; + format_type: number; + formatType?: number; + data?: unknown; + lottieData?: unknown; + url?: string; } interface DiscordMessage { - id: string; - author: DiscordUser; - content: string; - timestamp: string; - edited_timestamp?: string; - attachments?: DiscordAttachment[]; - embeds?: DiscordEmbed[]; - stickers?: DiscordSticker[]; - reactions?: DiscordReaction[]; - poll?: DiscordPoll | null; - referenced_message?: DiscordMessage | null; - type: number; - mentions?: DiscordUser[]; - mention_roles?: string[]; - pinned?: boolean; - tts?: boolean; + id: string; + author: DiscordUser; + content: string; + timestamp: string; + edited_timestamp?: string; + attachments?: DiscordAttachment[]; + embeds?: DiscordEmbed[]; + stickers?: DiscordSticker[]; + reactions?: DiscordReaction[]; + poll?: DiscordPoll | null; + referenced_message?: DiscordMessage | null; + type: number; + mentions?: DiscordUser[]; + mention_roles?: string[]; + pinned?: boolean; + tts?: boolean; +} + +interface MemberSummary { + id: string; + username?: string; + displayName?: string; + avatar?: string; + color?: string | number; + isBot?: boolean; } interface MemberGroup { - role?: DiscordRole; - members?: Array<{ id: string; color?: string | number }>; + role?: DiscordRole; + members?: MemberSummary[]; } interface MembersData { - groups?: MemberGroup[]; - roles?: Map | Record; + groups?: MemberGroup[]; + roles?: Map | Record; + totalMembers?: number; } interface DiscordGuild { - id: string; - name: string; - guildId?: string; - guildName?: string; - guildIcon?: string; - icon?: string; - roles?: DiscordRole[]; + id: string; + name: string; + guildId?: string; + guildName?: string; + guildIcon?: string; + icon?: string | null; + roles?: DiscordRole[]; } interface LinkPreviewData { - url: string; - type?: string; - title?: string; - description?: string; - image?: string; - video?: string; - videoId?: string; - siteName?: string; - trusted?: boolean; - themeColor?: string; - thumbnail?: string; + url: string; + type?: string; + title?: string; + description?: string; + image?: string; + video?: string; + videoId?: string; + siteName?: string; + trusted?: boolean; + themeColor?: string; + thumbnail?: string; } interface ParseOptions { - channelMap?: Map; - usersMap?: Record; - rolesMap?: Map | Record; - emojiCache?: Record; - onChannelClick?: (channelId: string) => void; + channelMap?: Map; + usersMap?: Record; + rolesMap?: Map | Record; + emojiCache?: Record; + onChannelClick?: (channelId: string) => void; } interface MessageProps { - message: DiscordMessage; - isFirstInGroup: boolean; - showTimestamp: boolean; - previewCache: Map; - onPreviewLoad: (url: string, preview: LinkPreviewData) => void; - channelMap: Map; - usersMap: Record; - emojiCache: Record; - members?: MembersData; - onChannelSelect?: (channelId: string | DiscordChannel) => void; - channelName?: string; - onReactionClick?: (e: React.MouseEvent, messageId: string, reaction: DiscordReaction) => void; - onPollVoterClick?: (e: React.MouseEvent, answer: DiscordPollAnswer) => void; - onContextMenu?: (e: React.MouseEvent, messageId: string, content: string) => void; - isSearchResult?: boolean; - onJumpToMessage?: (messageId: string, channelId?: string) => void; + message: DiscordMessage; + isFirstInGroup: boolean; + showTimestamp: boolean; + previewCache: Record; + onPreviewLoad: (url: string, preview: LinkPreviewData) => void; + channelMap: Map; + usersMap: Record; + emojiCache: Record; + members?: MembersData; + onChannelSelect?: (channelId: string | DiscordChannel) => void; + channelName?: string; + onReactionClick?: (e: React.MouseEvent, messageId: string, reaction: DiscordReaction) => void; + onPollVoterClick?: (e: React.MouseEvent, answer: DiscordPollAnswer) => void; + onContextMenu?: (e: React.MouseEvent, messageId: string, content: string) => void; + isSearchResult?: boolean; + onJumpToMessage?: (messageId: string, channelId?: string) => void; } interface AttachmentProps { - attachment: DiscordAttachment; + attachment: DiscordAttachment; } interface LinkPreviewProps { - url: string; - cachedPreview?: LinkPreviewData | null; - onPreviewLoad?: (url: string, preview: LinkPreviewData) => void; + url: string; + cachedPreview?: LinkPreviewData | null; + onPreviewLoad?: (url: string, preview: LinkPreviewData) => void; } interface LottieStickerProps { - data: unknown; - name: string; + data: unknown; + name: string; } interface ArchivedMessageResult { - originalAuthor: string; - originalTimestamp: string | null; - originalContent: string; - topic?: string; + originalAuthor: string; + originalTimestamp: string | null; + originalContent: string; + topic?: string; } // State types for popups interface ReactionPopupState { x: number; y: number; - emoji: { id?: string; name: string; animated?: boolean }; + emoji: { id?: string; name: string; animated?: boolean; url?: string }; users: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>; loading?: boolean; } @@ -663,7 +676,7 @@ const ARCHIVE_USERS = { * If members data is provided, looks up color from there * Returns user data if found, otherwise returns a basic object with just the username */ -function resolveArchivedUser(archivedUsername: string, usersMap?: Record, members?: MembersData): DiscordUser { +function resolveArchivedUser(archivedUsername: string, usersMap?: Record, members?: MembersData | null): DiscordUser { // First check hardcoded archive users if (ARCHIVE_USERS[archivedUsername]) { const archivedUser = ARCHIVE_USERS[archivedUsername]; @@ -2480,19 +2493,19 @@ export default function DiscordLogs() { try { const response = await authFetch('/api/discord/channels'); if (!response.ok) throw new Error('Failed to fetch channels'); - const data = await response.json(); + const data = await response.json() as DiscordChannel[]; // Separate categories (type 4), text channels (type 0), and threads (types 10, 11, 12) - const categories = {}; - const textChannels = []; - const threads = []; + const categories: Record = {}; + const textChannels: DiscordChannel[] = []; + const threads: DiscordChannel[] = []; - data.forEach(channel => { + (data as DiscordChannel[]).forEach((channel: DiscordChannel) => { if (channel.type === 4) { categories[channel.id] = { id: channel.id, name: channel.name, - position: channel.position, + position: channel.position ?? 0, guildId: channel.guildId, }; } else if (channel.type === 10 || channel.type === 11 || channel.type === 12) { @@ -2503,8 +2516,8 @@ export default function DiscordLogs() { }); // Create a map of parent channel ID to threads - const threadsByParent = {}; - threads.forEach(thread => { + const threadsByParent: Record = {}; + threads.forEach((thread: DiscordChannel) => { if (thread.parentId) { if (!threadsByParent[thread.parentId]) { threadsByParent[thread.parentId] = []; @@ -2514,10 +2527,10 @@ export default function DiscordLogs() { }); // Group channels by guild - const byGuild = {}; - const guildMap = {}; + const byGuild: Record = {}; + const guildMap: Record = {}; - textChannels.forEach(channel => { + textChannels.forEach((channel: DiscordChannel) => { const guildId = channel.guildId || 'unknown'; if (!byGuild[guildId]) { byGuild[guildId] = []; @@ -2525,10 +2538,10 @@ export default function DiscordLogs() { id: guildId, name: channel.guildName || 'Discord Archive', icon: channel.guildIcon || null, - }; + } as DiscordGuild; } - const categoryName = channel.parentId ? categories[channel.parentId]?.name : null; - const categoryPosition = channel.parentId ? categories[channel.parentId]?.position : -1; + const categoryName = channel.parentId ? categories[channel.parentId]?.name ?? null : null; + const categoryPosition = channel.parentId ? categories[channel.parentId]?.position ?? -1 : -1; // Get threads for this channel const channelThreads = threadsByParent[channel.id] || []; @@ -2543,22 +2556,22 @@ export default function DiscordLogs() { categoryName, categoryPosition, guild: guildMap[guildId], - messageCount: channel.messageCount || 0, + messageCount: channel.messageCount ?? 0, threads: channelThreads.map(t => ({ id: t.id, name: t.name, type: t.type, - messageCount: t.messageCount || 0, + messageCount: t.messageCount ?? 0, parentId: t.parentId, guildId: t.guildId, guildName: t.guildName, guildIcon: t.guildIcon, })), - }); + } as DiscordChannel); }); // Sort guilds - put "no place like ::1" first - const guildList = Object.values(guildMap).sort((a, b) => { + const guildList = (Object.values(guildMap) as DiscordGuild[]).sort((a, b) => { if (a.name === 'no place like ::1') return -1; if (b.name === 'no place like ::1') return 1; return a.name.localeCompare(b.name); @@ -2569,23 +2582,23 @@ export default function DiscordLogs() { byGuild[guildId].sort((a, b) => { // First sort by category position (no category = -1, comes first) if (a.categoryPosition !== b.categoryPosition) { - return a.categoryPosition - b.categoryPosition; + return (a.categoryPosition ?? -1) - (b.categoryPosition ?? -1); } // Then by channel position within category - return (a.position || 0) - (b.position || 0); + return (a.position ?? 0) - (b.position ?? 0); }); }); setGuilds(guildList); setChannelsByGuild(byGuild); - setChannels(data); + setChannels(data as DiscordChannel[]); // Check URL hash for deep linking const hash = window.location.hash.slice(1); // Remove # const [hashGuildId, hashChannelId, hashMessageId] = hash.split('/'); - let initialGuild = null; - let initialChannel = null; + let initialGuild: DiscordGuild | null = null; + let initialChannel: DiscordChannel | null = null; // Set target message ID for scrolling after messages load if (hashMessageId) { @@ -2596,7 +2609,7 @@ export default function DiscordLogs() { if (hashGuildId && guildMap[hashGuildId]) { initialGuild = guildMap[hashGuildId]; if (hashChannelId && byGuild[hashGuildId]) { - initialChannel = byGuild[hashGuildId].find(c => c.id === hashChannelId && c.messageCount > 0); + initialChannel = byGuild[hashGuildId].find(c => c.id === hashChannelId && (c.messageCount ?? 0) > 0) ?? null; } } @@ -2606,7 +2619,7 @@ export default function DiscordLogs() { } if (!initialChannel && initialGuild && byGuild[initialGuild.id]?.length > 0) { // Pick first channel with messages - initialChannel = byGuild[initialGuild.id].find(c => c.type !== 4 && c.messageCount > 0); + initialChannel = byGuild[initialGuild.id].find(c => c.type !== 4 && (c.messageCount ?? 0) > 0) ?? null; } if (initialGuild) { @@ -2629,20 +2642,22 @@ export default function DiscordLogs() { // Load members when guild or channel changes useEffect(() => { - if (!selectedGuild) return; + const guildId = selectedGuild?.id; + if (!guildId) return; async function fetchMembers() { setLoadingMembers(true); try { // Include channelId to filter members by channel visibility - let url = `/api/discord/members?guildId=${selectedGuild.id}`; - if (selectedChannel?.id) { - url += `&channelId=${selectedChannel.id}`; + let url = `/api/discord/members?guildId=${guildId}`; + const channelId = selectedChannel?.id; + if (channelId) { + url += `&channelId=${channelId}`; } const response = await authFetch(url); if (!response.ok) throw new Error('Failed to fetch members'); const data = await response.json(); - setMembers(data); + setMembers(data as MembersData); } catch (err) { console.error('Failed to fetch members:', err); setMembers(null); @@ -2656,7 +2671,8 @@ export default function DiscordLogs() { // Load messages from API when channel changes useEffect(() => { - if (!selectedChannel) return; + const channelId = selectedChannel?.id; + if (!channelId) return; // Reset topic expanded state when channel changes setTopicExpanded(false); @@ -2676,7 +2692,7 @@ export default function DiscordLogs() { try { // If we have a target message ID, use 'around' to fetch messages centered on it - let url = `/api/discord/messages?channelId=${selectedChannel.id}&limit=50`; + let url = `/api/discord/messages?channelId=${channelId}&limit=50`; if (targetMessageId) { url += `&around=${targetMessageId}`; } @@ -2695,20 +2711,20 @@ export default function DiscordLogs() { if (targetMessageId && messagesData.length === 0) { console.warn(`Target message ${targetMessageId} not found, loading latest messages`); pendingTargetMessageRef.current = null; - const fallbackResponse = await authFetch(`/api/discord/messages?channelId=${selectedChannel.id}&limit=50`); + const fallbackResponse = await authFetch(`/api/discord/messages?channelId=${channelId}&limit=50`); if (fallbackResponse.ok) { const fallbackData = await fallbackResponse.json(); const fallbackMessages = fallbackData.messages || fallbackData; const fallbackUsers = fallbackData.users || {}; const fallbackEmojis = fallbackData.emojiCache || {}; - const normalizedFallback = fallbackMessages.map(msg => ({ + const normalizedFallback = fallbackMessages.map((msg: DiscordMessage) => ({ ...msg, - referenced_message: msg.referencedMessage || msg.referenced_message, + referenced_message: (msg as any).referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, - content_type: att.contentType || att.content_type, + content_type: (att as any).contentType || att.content_type, })), - })); + } as DiscordMessage)); setMessages(normalizedFallback.reverse()); setUsersMap(fallbackUsers); setEmojiCache(fallbackEmojis); @@ -2720,14 +2736,14 @@ export default function DiscordLogs() { } // Normalize field names from API to component expectations - const normalizedMessages = messagesData.map(msg => ({ + const normalizedMessages = messagesData.map((msg: DiscordMessage) => ({ ...msg, - referenced_message: msg.referencedMessage || msg.referenced_message, + referenced_message: (msg as any).referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, - content_type: att.contentType || att.content_type, + content_type: (att as any).contentType || att.content_type, })), - })); + } as DiscordMessage)); // When using 'around', messages come back in ASC order already // When using default (no around), they come DESC so reverse them @@ -2797,14 +2813,15 @@ export default function DiscordLogs() { // Load more messages (pagination) const loadMoreMessages = useCallback(async () => { - if (loadingMore || !hasMoreMessages || messages.length === 0) return; + const channelId = selectedChannel?.id; + if (loadingMore || !hasMoreMessages || messages.length === 0 || !channelId) return; setLoadingMore(true); try { // Get the oldest message ID for pagination (messages are in ASC order, so first = oldest) const oldestMessage = messages[0]; const response = await authFetch( - `/api/discord/messages?channelId=${selectedChannel.id}&limit=50&before=${oldestMessage.id}` + `/api/discord/messages?channelId=${channelId}&limit=50&before=${oldestMessage.id}` ); if (!response.ok) throw new Error('Failed to fetch more messages'); const data = await response.json(); @@ -2814,14 +2831,14 @@ export default function DiscordLogs() { const usersData = data.users || {}; const emojiCacheData = data.emojiCache || {}; - const normalizedMessages = messagesData.map(msg => ({ + const normalizedMessages = messagesData.map((msg: DiscordMessage) => ({ ...msg, - referenced_message: msg.referencedMessage || msg.referenced_message, + referenced_message: (msg as any).referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, - content_type: att.contentType || att.content_type, + content_type: (att as any).contentType || att.content_type, })), - })); + } as DiscordMessage)); // Prepend older messages (reversed to maintain ASC order) setMessages(prev => [...normalizedMessages.reverse(), ...prev]); @@ -2835,7 +2852,7 @@ export default function DiscordLogs() { } finally { setLoadingMore(false); } - }, [loadingMore, hasMoreMessages, messages, selectedChannel]); + }, [loadingMore, hasMoreMessages, messages, selectedChannel?.id]); // Jump to first message in channel const jumpToFirstMessage = useCallback(async () => { @@ -2886,14 +2903,15 @@ export default function DiscordLogs() { // Load newer messages (when viewing historical/oldest messages) const loadNewerMessages = useCallback(async () => { - if (loadingNewer || !hasNewerMessages || messages.length === 0) return; + const channelId = selectedChannel?.id; + if (loadingNewer || !hasNewerMessages || messages.length === 0 || !channelId) return; setLoadingNewer(true); try { // Get the newest message ID currently loaded const newestMessage = messages[messages.length - 1]; const response = await authFetch( - `/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}` + `/api/discord/messages?channelId=${channelId}&limit=50&after=${newestMessage.id}` ); if (!response.ok) throw new Error('Failed to fetch newer messages'); const data = await response.json(); @@ -2902,14 +2920,14 @@ export default function DiscordLogs() { const usersData = data.users || {}; const emojiCacheData = data.emojiCache || {}; - const normalizedMessages = messagesData.map(msg => ({ + const normalizedMessages = messagesData.map((msg: DiscordMessage) => ({ ...msg, - referenced_message: msg.referencedMessage || msg.referenced_message, + referenced_message: (msg as any).referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, - content_type: att.contentType || att.content_type, + content_type: (att as any).contentType || att.content_type, })), - })); + } as DiscordMessage)); // Append newer messages setMessages(prev => [...prev, ...normalizedMessages]); @@ -2924,7 +2942,7 @@ export default function DiscordLogs() { } finally { setLoadingNewer(false); } - }, [loadingNewer, hasNewerMessages, messages, selectedChannel]); + }, [loadingNewer, hasNewerMessages, messages, selectedChannel?.id]); // Infinite scroll: load more when scrolling near the top or bottom useEffect(() => { @@ -2979,9 +2997,9 @@ export default function DiscordLogs() { `/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}` ); - let newMessages = []; - let newUsersData = {}; - let newEmojiCacheData = {}; + let newMessages: DiscordMessage[] = []; + let newUsersData: Record = {}; + let newEmojiCacheData: Record = {}; if (newMsgsResponse.ok) { const data = await newMsgsResponse.json(); @@ -2990,14 +3008,14 @@ export default function DiscordLogs() { newEmojiCacheData = data.emojiCache || {}; if (messagesData.length > 0) { - newMessages = messagesData.map(msg => ({ + newMessages = messagesData.map((msg: DiscordMessage) => ({ ...msg, - referenced_message: msg.referencedMessage || msg.referenced_message, + referenced_message: (msg as any).referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, - content_type: att.contentType || att.content_type, + content_type: (att as any).contentType || att.content_type, })), - })); + } as DiscordMessage)); } } @@ -3006,7 +3024,7 @@ export default function DiscordLogs() { `/api/discord/messages?channelId=${selectedChannel.id}&limit=100&editedSince=${encodeURIComponent(lastPollTimeRef.current)}` ); - let editedMessages = []; + let editedMessages: DiscordMessage[] = []; if (editedResponse.ok) { const editedData = await editedResponse.json(); const editedMessagesData = editedData.messages || editedData; @@ -3015,14 +3033,14 @@ export default function DiscordLogs() { newUsersData = { ...newUsersData, ...editedUsersData }; newEmojiCacheData = { ...newEmojiCacheData, ...editedEmojiCacheData }; - editedMessages = editedMessagesData.map(msg => ({ + editedMessages = editedMessagesData.map((msg: DiscordMessage) => ({ ...msg, - referenced_message: msg.referencedMessage || msg.referenced_message, + referenced_message: (msg as any).referencedMessage || msg.referenced_message, attachments: (msg.attachments || []).map(att => ({ ...att, - content_type: att.contentType || att.content_type, + content_type: (att as any).contentType || att.content_type, })), - })); + } as DiscordMessage)); } // Update last poll time for next iteration @@ -3037,7 +3055,7 @@ export default function DiscordLogs() { if (newMessages.length > 0 || editedMessages.length > 0) { setMessages(prev => { // Create a map of existing messages by ID - const msgMap = new Map(prev.map(m => [m.id, m])); + const msgMap = new Map(prev.map(m => [m.id, m])); // Update with edited messages (overwrite existing) for (const msg of editedMessages) { @@ -3085,14 +3103,14 @@ export default function DiscordLogs() { try { const response = await authFetch('/api/discord/channels'); if (!response.ok) return; - const data = await response.json(); + const data = await response.json() as DiscordChannel[]; // Separate categories (type 4), text channels (type 0), and threads (types 10, 11, 12) - const categories = {}; - const textChannels = []; - const threads = []; + const categories: Record = {}; + const textChannels: DiscordChannel[] = []; + const threads: DiscordChannel[] = []; - data.forEach(channel => { + data.forEach((channel: DiscordChannel) => { if (channel.type === 4) { categories[channel.id] = { id: channel.id, @@ -3108,8 +3126,8 @@ export default function DiscordLogs() { }); // Create a map of parent channel ID to threads - const threadsByParent = {}; - threads.forEach(thread => { + const threadsByParent: Record = {}; + threads.forEach((thread: DiscordChannel) => { if (thread.parentId) { if (!threadsByParent[thread.parentId]) { threadsByParent[thread.parentId] = []; @@ -3119,10 +3137,10 @@ export default function DiscordLogs() { }); // Rebuild guild/channel maps - const byGuild = {}; - const guildMap = {}; + const byGuild: Record = {}; + const guildMap: Record = {}; - textChannels.forEach(channel => { + textChannels.forEach((channel: DiscordChannel) => { const guildId = channel.guildId || 'unknown'; if (!byGuild[guildId]) { byGuild[guildId] = []; @@ -3163,7 +3181,7 @@ export default function DiscordLogs() { }); // Sort guilds - const guildList = Object.values(guildMap).sort((a, b) => { + const guildList: DiscordGuild[] = Object.values(guildMap).sort((a: DiscordGuild, b: DiscordGuild) => { if (a.name === 'no place like ::1') return -1; if (b.name === 'no place like ::1') return 1; return a.name.localeCompare(b.name); @@ -3171,9 +3189,9 @@ export default function DiscordLogs() { // Sort channels within each guild Object.keys(byGuild).forEach(guildId => { - byGuild[guildId].sort((a, b) => { - if (a.categoryPosition !== b.categoryPosition) { - return a.categoryPosition - b.categoryPosition; + byGuild[guildId].sort((a: DiscordChannel, b: DiscordChannel) => { + if ((a.categoryPosition ?? -1) !== (b.categoryPosition ?? -1)) { + return (a.categoryPosition ?? -1) - (b.categoryPosition ?? -1); } return (a.position || 0) - (b.position || 0); }); @@ -3241,15 +3259,17 @@ export default function DiscordLogs() { if (msg.author?.displayName?.toLowerCase().includes(query)) return true; // Check embed content - if (msg.embeds?.length > 0) { - for (const embed of msg.embeds) { + const embeds = msg.embeds ?? []; + if (embeds.length > 0) { + for (const embed of embeds) { if (embed.title?.toLowerCase().includes(query)) return true; if (embed.description?.toLowerCase().includes(query)) return true; if (embed.author?.name?.toLowerCase().includes(query)) return true; if (embed.footer?.text?.toLowerCase().includes(query)) return true; // Check embed fields - if (embed.fields?.length > 0) { - for (const field of embed.fields) { + const fields = embed.fields ?? []; + if (fields.length > 0) { + for (const field of fields) { if (field.name?.toLowerCase().includes(query)) return true; if (field.value?.toLowerCase().includes(query)) return true; } @@ -3279,15 +3299,29 @@ export default function DiscordLogs() { // Group messages by author and time window (5 minutes) // Messages are in ASC order (oldest first, newest at bottom), so we group accordingly // For archive channel, parse messages to get real author/timestamp for grouping +interface MessageGroupDivider { + type: 'divider'; + date: string | null; +} + +interface MessageGroupMessages { + type: 'messages'; + author: DiscordUser; + effectiveAuthor: DiscordUser; + messages: Array; +} + +type MessageGroup = MessageGroupDivider | MessageGroupMessages; + const groupedMessages = useMemo(() => { - const groups = []; - let currentGroup = null; - let lastDate = null; + const groups: MessageGroup[] = []; + let currentGroup: MessageGroupMessages | null = null; + let lastDate: string | null = null; filteredMessages.forEach((message) => { // For archive channel, parse to get real author and timestamp let effectiveAuthor = message.author; - let effectiveTimestamp = message.timestamp; + let effectiveTimestamp: string | null = message.timestamp ?? null; if (isArchiveChannel) { const parsed = parseArchivedMessage(message.content); @@ -3295,25 +3329,34 @@ export default function DiscordLogs() { // Use resolved user for proper grouping by real user ID const resolvedUser = resolveArchivedUser(parsed.originalAuthor, usersMap, members); effectiveAuthor = resolvedUser; - effectiveTimestamp = parsed.originalTimestamp; + effectiveTimestamp = parsed.originalTimestamp ?? null; } } - const messageDate = new Date(effectiveTimestamp).toDateString(); + const effectiveTs = effectiveTimestamp ?? message.timestamp ?? null; + const messageDate = effectiveTs ? new Date(effectiveTs).toDateString() : lastDate; // Check if we need a date divider (date changed from previous message) if (messageDate !== lastDate) { if (currentGroup) groups.push(currentGroup); - groups.push({ type: 'divider', date: effectiveTimestamp }); + groups.push({ type: 'divider', date: effectiveTs }); currentGroup = null; lastDate = messageDate; } + const lastMessage = currentGroup?.messages[currentGroup.messages.length - 1]; + const lastTimestamp = lastMessage?.effectiveTimestamp ?? lastMessage?.timestamp ?? null; + const timeGapExceeded = lastTimestamp && effectiveTs + ? Math.abs(new Date(lastTimestamp).getTime() - new Date(effectiveTs).getTime()) > 5 * 60 * 1000 + : true; + // For ASC order: check time diff from the LAST message in current group // (which is the most recent since we're iterating oldest to newest) const shouldStartNewGroup = !currentGroup || currentGroup.effectiveAuthor?.id !== effectiveAuthor?.id || - Math.abs(new Date(currentGroup.messages[currentGroup.messages.length - 1].effectiveTimestamp) - new Date(effectiveTimestamp)) > 5 * 60 * 1000; + timeGapExceeded; + + const messageWithEffective = { ...message, effectiveTimestamp: effectiveTs } as DiscordMessage & { effectiveTimestamp: string | null }; if (shouldStartNewGroup) { if (currentGroup) groups.push(currentGroup); @@ -3321,10 +3364,10 @@ export default function DiscordLogs() { type: 'messages', author: effectiveAuthor, effectiveAuthor, - messages: [{ ...message, effectiveTimestamp }], + messages: [messageWithEffective], }; - } else { - currentGroup.messages.push({ ...message, effectiveTimestamp }); + } else if (currentGroup) { + currentGroup.messages.push(messageWithEffective); } }); @@ -3332,6 +3375,8 @@ export default function DiscordLogs() { return groups; }, [filteredMessages, isArchiveChannel, usersMap, members]); + const memberGroups = members?.groups ?? []; + if (loading) { return (
@@ -3461,7 +3506,7 @@ export default function DiscordLogs() { setSelectedGuild(guild); const guildChannels = channelsByGuild[guild.id]; // Select first channel with messages - const firstAccessible = guildChannels?.find(c => c.messageCount > 0); + const firstAccessible = guildChannels?.find(c => (c.messageCount ?? 0) > 0); if (firstAccessible) { setSelectedChannel(firstAccessible); } @@ -3494,28 +3539,29 @@ export default function DiscordLogs() { {/* Collapsible categories state and handler at top level */} {/* ...existing code... */} {(() => { - let lastCategory = null; + let lastCategory: string | null | undefined; // Filter out channels with no messages (no access), but include if they have threads with messages const accessibleChannels = channelsByGuild[selectedGuild.id].filter(c => - c.messageCount > 0 || c.threads?.some(t => t.messageCount > 0) + (c.messageCount ?? 0) > 0 || c.threads?.some(t => (t.messageCount ?? 0) > 0) ); return accessibleChannels.map((channel) => { - const showCategoryHeader = channel.categoryName !== lastCategory; - lastCategory = channel.categoryName; + const catName = channel.categoryName ?? null; + const showCategoryHeader = catName !== lastCategory; + lastCategory = catName; // Filter threads that have messages - const accessibleThreads = channel.threads?.filter(t => t.messageCount > 0) || []; - const isCollapsed = channel.categoryName && collapsedCategories[channel.categoryName]; + const accessibleThreads = channel.threads?.filter(t => (t.messageCount ?? 0) > 0) || []; + const isCollapsed = catName ? collapsedCategories[catName] : false; return ( - {showCategoryHeader && channel.categoryName && ( -
handleCategoryToggle(channel.categoryName)} style={{ cursor: 'pointer', userSelect: 'none' }}> + {showCategoryHeader && catName && ( +
handleCategoryToggle(catName)} style={{ cursor: 'pointer', userSelect: 'none' }}> - {channel.categoryName} + {catName}
)} - {!isCollapsed && channel.messageCount > 0 && ( + {!isCollapsed && (channel.messageCount ?? 0) > 0 && ( + ID: {alt.id} + {idx < alts.length - 1 ? , : null} + + ))} +
+
+ )} + + ); + }; + + + // Handle autocomplete input changes (typing/selecting) + const handleArtistChange = (e) => { + if (typeof e.value === "string") { + setArtistInput(e.value); + setSelectedArtist(null); + } else if (e.value && typeof e.value === "object") { + setSelectedArtist(e.value); + setArtistInput(e.value.artist); + } else { + setArtistInput(""); + setSelectedArtist(null); + } + }; + + // Search button click handler + const handleSearch = async () => { + toast.dismiss(); + setIsSearching(true); + resetQueueState(); + setShuffleAlbums({}); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.removeAttribute("src"); + audioRef.current.load(); + } + setIsAudioPlaying(false); + setAudioLoadingTrackId(null); + setCurrentTrackId(null); + try { + if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current); + } catch (err) { + } + metadataFetchToastId.current = toast.info("Retrieving metadata...", + { + autoClose: false, + progress: 0, + closeOnClick: false, + } + ); + if (type === "artist") { + if (!selectedArtist) { + toast.error("Please select a valid artist from suggestions."); + setIsSearching(false); + return; + } + + setSelectedItem(selectedArtist.artist); + + try { + const res = await authFetch( + `${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}` + ); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); + + data.sort((a, b) => + (b.release_date || "").localeCompare(a.release_date || "") + ); + + setAlbums(data); + setShuffleAlbums({}); + setTracksByAlbum({}); + setExpandedAlbums([]); + + // Set selectedTracks for all albums as null (means tracks loading/not loaded) + setSelectedTracks( + data.reduce((acc, album) => { + acc[album.id] = null; + return acc; + }, {}) + ); + } catch (err) { + toast.error("Failed to fetch albums for artist."); + setAlbums([]); + setTracksByAlbum({}); + setSelectedTracks({}); + } + } else if (type === "album") { + if (!artistInput.trim() || !albumInput.trim()) { + toast.error("Artist and Album are required."); + setIsSearching(false); + return; + } + setSelectedItem(`${artistInput} - ${albumInput}`); + setAlbums([]); + setTracksByAlbum({}); + setSelectedTracks({}); + } else if (type === "track") { + if (!artistInput.trim() || !trackInput.trim()) { + toast.error("Artist and Track are required."); + setIsSearching(false); + return; + } + setSelectedItem(`${artistInput} - ${trackInput}`); + setAlbums([]); + setTracksByAlbum({}); + setSelectedTracks({}); + } + setIsSearching(false); + }; + + const handleTrackPlayPause = async (track, albumId = null, albumIndex = null) => { + const audio = audioRef.current; + if (!audio) return; + + if (typeof albumIndex === "number") { + ensureAlbumExpanded(albumIndex); + } + + if (currentTrackId === track.id) { + if (audio.paused) { + setIsAudioPlaying(true); + try { + await audio.play(); + } catch (error) { + setIsAudioPlaying(false); + console.error(error); + toast.error("Unable to resume playback."); + } + } else { + setIsAudioPlaying(false); + audio.pause(); + } + return; + } + + await playTrack(track, { fromQueue: false }); + }; + + const toggleAlbumShuffle = (albumId) => { + setShuffleAlbums((prev) => ({ ...prev, [albumId]: !prev[albumId] })); + }; + + const startAlbumPlayback = async (albumId, albumIndex) => { + const tracks = tracksByAlbum[albumId]; + if (!Array.isArray(tracks) || tracks.length === 0) { + toast.error("Tracks are still loading for this album."); + return; + } + + ensureAlbumExpanded(albumIndex); + + const shouldShuffle = !!shuffleAlbums[albumId]; + const queue = shouldShuffle ? shuffleArray(tracks) : [...tracks]; + setPlaybackQueue(queue); + playbackQueueRef.current = queue; + setQueueAlbumId(albumId); + queueAlbumIdRef.current = albumId; + setQueueIndex(0); + queueIndexRef.current = 0; + setAlbumPlaybackLoadingId(albumId); + + try { + await playTrack(queue[0], { fromQueue: true }); + setAlbumPlaybackLoadingId(null); + if (queue[1]) prefetchTrack(queue[1]); + } catch (err) { + setAlbumPlaybackLoadingId(null); + } + }; + + const handleAlbumPlayPause = async (albumId, albumIndex) => { + const audio = audioRef.current; + if (!audio) return; + + ensureAlbumExpanded(albumIndex); + + if (queueAlbumId === albumId && playbackQueue.length > 0) { + if (audio.paused) { + setIsAudioPlaying(true); + try { + await audio.play(); + } catch (error) { + setIsAudioPlaying(false); + console.error(error); + toast.error("Unable to resume album playback."); + } + } else { + setIsAudioPlaying(false); + audio.pause(); + } + return; + } + + await startAlbumPlayback(albumId, albumIndex); + }; + + const handleTrackDownload = async (track) => { + try { + const res = await authFetch(`${API_URL}/trip/get_track_by_id/${track.id}?quality=${quality}`); + if (!res.ok) throw new Error("Failed to fetch track URL"); + const data = await res.json(); + + if (!data.stream_url) { + throw new Error("No stream URL returned for this track."); + } + + const fileResponse = await fetch(data.stream_url, { + method: "GET", + mode: "cors", + credentials: "omit", + }); + + if (!fileResponse.ok) { + throw new Error(`Failed to fetch track file: ${fileResponse.status}`); + } + + const blob = await fileResponse.blob(); + const url = URL.createObjectURL(blob); + const artistName = track.artist || selectedArtist?.artist || "Unknown Artist"; + const urlPath = new URL(data.stream_url).pathname; + const extension = urlPath.split(".").pop().split("?")[0] || "flac"; + const filename = `${sanitizeFilename(artistName)} - ${sanitizeFilename(track.title)}.${extension}`; + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (error) { + console.error(error); + toast.error("Failed to download track."); + } + }; + + useEffect(() => { + if (typeof Audio === "undefined") { + return undefined; + } + + const audio = new Audio(); + audio.preload = "auto"; + audioRef.current = audio; + + const handleEnded = async () => { + const queue = playbackQueueRef.current; + const index = queueIndexRef.current; + + if (Array.isArray(queue) && queue.length > 0 && index !== null && index + 1 < queue.length) { + const nextIndex = index + 1; + setQueueIndex(nextIndex); + queueIndexRef.current = nextIndex; + setAlbumPlaybackLoadingId(queueAlbumIdRef.current); + try { + await playTrack(queue[nextIndex], { fromQueue: true }); + setAlbumPlaybackLoadingId(null); + const upcoming = queue[nextIndex + 1]; + if (upcoming) prefetchTrack(upcoming); + } catch (error) { + console.error("Failed to advance queue", error); + setAlbumPlaybackLoadingId(null); + resetQueueState(); + } + return; + } + + setIsAudioPlaying(false); + setCurrentTrackId(null); + resetQueueState(); + }; + + const updateProgress = () => { + setAudioProgress({ current: audio.currentTime || 0, duration: audio.duration || 0 }); + }; + + const handlePause = () => { + setIsAudioPlaying(false); + updateProgress(); + }; + + const handlePlay = () => { + setIsAudioPlaying(true); + updateProgress(); + }; + + const handleTimeUpdate = () => { + updateProgress(); + const queue = playbackQueueRef.current; + const index = queueIndexRef.current; + if (!Array.isArray(queue) || queue.length === 0 || index === null) return; + const nextTrack = queue[index + 1]; + if (!nextTrack) return; + + const duration = audio.duration || 0; + const currentTime = audio.currentTime || 0; + const progress = duration > 0 ? currentTime / duration : 0; + if ((duration > 0 && progress >= 0.7) || (duration === 0 && currentTime >= 15)) { + prefetchTrack(nextTrack); + } + }; + + audio.addEventListener("ended", handleEnded); + audio.addEventListener("pause", handlePause); + audio.addEventListener("play", handlePlay); + audio.addEventListener("timeupdate", handleTimeUpdate); + audio.addEventListener("loadedmetadata", updateProgress); + audio.addEventListener("seeking", updateProgress); + audio.addEventListener("seeked", updateProgress); + + return () => { + audio.pause(); + audio.removeAttribute("src"); + audio.load(); + audio.removeEventListener("ended", handleEnded); + audio.removeEventListener("pause", handlePause); + audio.removeEventListener("play", handlePlay); + audio.removeEventListener("timeupdate", handleTimeUpdate); + audio.removeEventListener("loadedmetadata", updateProgress); + audio.removeEventListener("seeking", updateProgress); + audio.removeEventListener("seeked", updateProgress); + Object.values(audioSourcesRef.current).forEach((url) => URL.revokeObjectURL(url)); + audioSourcesRef.current = {}; + }; + }, []); + + + useEffect(() => { + playbackQueueRef.current = playbackQueue; + }, [playbackQueue]); + + useEffect(() => { + queueIndexRef.current = queueIndex; + }, [queueIndex]); + + useEffect(() => { + queueAlbumIdRef.current = queueAlbumId; + }, [queueAlbumId]); + + useEffect(() => { + Object.values(audioSourcesRef.current).forEach((url) => URL.revokeObjectURL(url)); + audioSourcesRef.current = {}; + pendingTrackFetchesRef.current = {}; + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.removeAttribute("src"); + audioRef.current.load(); + } + setIsAudioPlaying(false); + setAudioLoadingTrackId(null); + setCurrentTrackId(null); + resetQueueState(); + }, [quality]); + + const allTracksLoaded = albums.every(({ id }) => Array.isArray(tracksByAlbum[id]) && tracksByAlbum[id].length > 0); + + const handleToggleAllAlbums = () => { + const allSelected = albums.every(({ id }) => { + const allTracks = tracksByAlbum[id] || []; + return selectedTracks[id]?.length === allTracks.length && allTracks.length > 0; + }); + + const newSelection = {}; + albums.forEach(({ id }) => { + const allTracks = tracksByAlbum[id] || []; + if (allSelected) { + // Uncheck all + newSelection[id] = []; + } else { + // Check all tracks in the album + newSelection[id] = allTracks.map(track => String(track.id)); + } + }); + setSelectedTracks(newSelection); + }; + + { + e.preventDefault(); + if (!allTracksLoaded) return; // prevent clicking before data ready + handleToggleAllAlbums(); + }} + className={`text-sm hover:underline cursor-pointer ${!allTracksLoaded ? "text-gray-400 dark:text-gray-500 pointer-events-none" : "text-blue-600" + }`} + > + Check / Uncheck All Albums + + + + + // Sequentially fetch tracks for albums not loaded yet + useEffect(() => { + if (type !== "artist" || albums.length === 0) return; + + let isCancelled = false; + const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]); + if (albumsToFetch.length === 0) return; + + const fetchTracksSequentially = async () => { + const minDelay = 650; // ms between API requests + setIsFetching(true); + + const totalAlbums = albumsToFetch.length; + + for (let index = 0; index < totalAlbums; index++) { + const album = albumsToFetch[index]; + + if (isCancelled) break; + + setLoadingAlbumId(album.id); + + try { + const now = Date.now(); + if (!fetchTracksSequentially.lastCall) fetchTracksSequentially.lastCall = 0; + const elapsed = now - fetchTracksSequentially.lastCall; + if (elapsed < minDelay) await delay(minDelay - elapsed); + fetchTracksSequentially.lastCall = Date.now(); + + const res = await authFetch(`${API_URL}/trip/get_tracks_by_album_id/${album.id}`); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); + + if (isCancelled) break; + + setTracksByAlbum((prev) => ({ ...prev, [album.id]: data })); + setSelectedTracks((prev) => ({ + ...prev, + [album.id]: data.map((t) => String(t.id)), + })); + } catch (err) { + toast.error(`Failed to fetch tracks for album ${album.album}.`); + setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] })); + setSelectedTracks((prev) => ({ ...prev, [album.id]: [] })); + } + + // Update progress toast + toast.update(metadataFetchToastId.current, { + progress: (index + 1) / totalAlbums, + render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`, + }); + } + + setLoadingAlbumId(null); + setIsFetching(false); + + // Finish the toast + toast.update(metadataFetchToastId.current, { + render: "Metadata retrieved!", + type: "success", + progress: 1, + autoClose: 1500, + }); + }; + + fetchTracksSequentially(); + + return () => { + isCancelled = true; + }; + }, [albums, type]); + + + + // Toggle individual track checkbox + const toggleTrack = (albumId, trackId) => { + setSelectedTracks((prev) => { + const current = new Set(prev[albumId] || []); + if (current.has(String(trackId))) current.delete(String(trackId)); + else current.add(String(trackId)); + return { ...prev, [albumId]: Array.from(current) }; + }); + }; + + // Toggle album checkbox (select/deselect all tracks in album) + const toggleAlbum = (albumId) => { + const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || []; + setSelectedTracks((prev) => { + const current = prev[albumId] || []; + const allSelected = current.length === allTracks.length; + return { + ...prev, + [albumId]: allSelected ? [] : [...allTracks], + }; + }); + }; + + // Attach scroll fix for autocomplete panel + const attachScrollFix = () => { + setTimeout(() => { + const panel = document.querySelector(".p-autocomplete-panel"); + const items = panel?.querySelector(".p-autocomplete-items"); + if (items) { + items.style.maxHeight = "200px"; + items.style.overflowY = "auto"; + items.style.overscrollBehavior = "contain"; + const wheelHandler = (e) => { + const delta = e.deltaY; + const atTop = items.scrollTop === 0; + const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight; + if ((delta < 0 && atTop) || (delta > 0 && atBottom)) { + e.preventDefault(); + } else { + e.stopPropagation(); + } + }; + items.removeEventListener("wheel", wheelHandler); + items.addEventListener("wheel", wheelHandler, { passive: false }); + } + }, 0); + }; + + // Submit request handler with progress indicator + const handleSubmitRequest = async () => { + if (isFetching) { + // tracks are not done being fetched + return toast.error("Still fetching track metadata, please wait a moment."); + } + setIsSubmitting(true); + try { + const allSelectedIds = Object.values(selectedTracks) + .filter(arr => Array.isArray(arr)) // skip null entries + .flat(); + + const response = await authFetch(`${API_URL}/trip/bulk_fetch`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + track_ids: allSelectedIds, + target: selectedArtist.artist, + quality: quality, + }), + }); + + if (!response.ok) { + throw new Error(`Server error: ${response.status}`); + } + + const data = await response.json(); + toast.success(`Request submitted! (${allSelectedIds.length} tracks)`); + } catch (err) { + console.error(err); + toast.error("Failed to submit request."); + } finally { + setIsSubmitting(false); + } + }; + + + return ( +
+ + + + {/* Disk Space Indicator - always visible */} + {diskSpace && ( +
+ + + + + + 90 ? 'text-red-500' : + diskSpace.usedPercent > 75 ? 'text-yellow-500' : 'text-green-600 dark:text-green-400' + }`}>{diskSpace.usedFormatted} used ·{' '} + 90 ? 'text-red-500' : + diskSpace.usedPercent > 75 ? 'text-yellow-500' : 'text-green-600 dark:text-green-400' + }`}>{diskSpace.availableFormatted} available + +
+
90 ? 'bg-red-500' : + diskSpace.usedPercent > 75 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${diskSpace.usedPercent}%` }} + /> +
+ ({diskSpace.usedPercent}% used) +
+ )} + +

New Request

+

Search for an artist to browse and select tracks for download.

+
+
+ + + + {(type === "album" || type === "track") && ( + + type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value) + } + placeholder={type === "album" ? "Album" : "Track"} + /> + )} +
+ + +
+ + +
+ + {type === "artist" && albums.length > 0 && ( + <> + + setExpandedAlbums(e.index)} + > + {albums.map(({ album, id, release_date }, albumIndex) => { + const allTracks = tracksByAlbum[id] || []; + const selected = selectedTracks[id]; + + // Album checkbox is checked if tracks not loaded (selected === null) + // or all tracks loaded and all selected + const allChecked = + selected === null || (selected?.length === allTracks.length && allTracks.length > 0); + const someChecked = + selected !== null && selected.length > 0 && selected.length < allTracks.length; + + return ( + + { + if (el) el.indeterminate = someChecked; + }} + onChange={() => toggleAlbum(id)} + onClick={(e) => e.stopPropagation()} + className="trip-checkbox cursor-pointer" + aria-label={`Select all tracks for album ${album}`} + /> +
e.stopPropagation()}> + + +
+ + {truncate(album, 32)} + {loadingAlbumId === id && } + + ({release_date}) + + {typeof tracksByAlbum[id] === 'undefined' ? ( + loadingAlbumId === id ? 'Loading...' : '...' + ) : ( + `${allTracks.length} track${allTracks.length !== 1 ? 's' : ''}` + )} + +
+ } + + > + {allTracks.length > 0 ? ( +
    + {allTracks.map((track) => { + const isCurrentTrack = currentTrackId === track.id; + const showProgress = isCurrentTrack && audioProgress.duration > 0; + const safeProgress = { + current: Math.min(audioProgress.current, audioProgress.duration || 0), + duration: audioProgress.duration || 0, + }; + + return ( +
  • +
    +
    + toggleTrack(id, track.id)} + className="trip-checkbox cursor-pointer" + aria-label={`Select track ${track.title} `} + /> + +
    +
    + + {truncate(track.title, 80)} + + {track.version && ( + + {track.version} + + )} +
    +
    + + {quality} + {track.duration && ( + {track.duration} + )} +
    +
    + {showProgress && ( +
    + { + if (!audioRef.current) return; + const nextValue = Number(e.target.value); + audioRef.current.currentTime = nextValue; + setAudioProgress((prev) => ({ ...prev, current: nextValue })); + }} + className="w-full h-1 cursor-pointer accent-blue-600" + aria-label={`Seek within ${track.title}`} + /> +
    + {formatTime(safeProgress.current)} + {formatTime(safeProgress.duration)} +
    +
    + )} +
  • + ); + })} +
+ ) : ( +
+ {tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."} +
+ )} + + ); + })} + +
+ +
+ + ) + } +
+
+ ); +} diff --git a/src/components/TRip/RequestManagement.tsx b/src/components/TRip/RequestManagement.tsx index 1ce9ad7..1bd946b 100644 --- a/src/components/TRip/RequestManagement.tsx +++ b/src/components/TRip/RequestManagement.tsx @@ -17,9 +17,9 @@ interface RequestJob { tracks: number; quality: string; status: string; - progress: number; + progress: number | string | null; type?: string; - tarball_path?: string; + tarball?: string; created_at?: string; updated_at?: string; [key: string]: unknown; @@ -40,9 +40,13 @@ export default function RequestManagement() { const pollingDetailRef = useRef | null>(null); + const resolveTarballPath = (job: RequestJob) => job.tarball; + const tarballUrl = (absPath: string | undefined, quality: string) => { if (!absPath) return null; const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz" + // If the backend already stores a fully qualified URL, return as-is + if (/^https?:\/\//i.test(absPath)) return absPath; return `${TAR_BASE_URL}/${quality}/${filename}`; }; @@ -172,24 +176,21 @@ export default function RequestManagement() { const formatProgress = (p: unknown) => { if (p === null || p === undefined || p === "") return "—"; - const num = Number(p); - if (Number.isNaN(num)) return "—"; - const pct = num > 1 ? Math.round(num) : num; + const pct = computePct(p); return `${pct}%`; }; const computePct = (p: unknown) => { if (p === null || p === undefined || p === "") return 0; const num = Number(p); - if (Number.isNaN(num)) return 0; - return Math.min(100, Math.max(0, num > 1 ? Math.round(num) : Math.round(num * 100))); + if (!Number.isFinite(num)) return 0; + const normalized = num > 1 ? num : num * 100; + return Math.min(100, Math.max(0, Math.round(normalized))); }; const progressBarTemplate = (rowData: RequestJob) => { const p = rowData.progress; - if (p === null || p === undefined || p === 0) return "—"; - const num = Number(p); - if (Number.isNaN(num)) return "—"; + if (p === null || p === undefined || p === "") return "—"; const pct = computePct(p); const getProgressColor = () => { @@ -341,7 +342,7 @@ export default function RequestManagement() { } body={(row: RequestJob) => { - const url = tarballUrl(row.tarball_path, row.quality || "FLAC"); + const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC"); if (!url) return "—"; const encodedURL = encodeURI(url); @@ -409,18 +410,25 @@ export default function RequestManagement() { Progress:
+ {(() => { + const pctDialog = computePct(selectedRequest.progress); + const status = selectedRequest.status; + const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : "bg-blue-500"; + return (
= 100 ? '999px' : 0, - borderBottomRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0 - }} - data-pct={computePct(selectedRequest.progress)} - aria-valuenow={Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))} - aria-valuemin={0} - aria-valuemax={100} - /> + className={`rm-progress-fill ${fillColor}`} + style={{ + ['--rm-progress' as string]: (pctDialog / 100).toString(), + borderTopRightRadius: pctDialog >= 100 ? '999px' : 0, + borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0 + }} + data-pct={pctDialog} + aria-valuenow={pctDialog} + aria-valuemin={0} + aria-valuemax={100} + /> + ); + })()}
{formatProgress(selectedRequest.progress)}
@@ -437,17 +445,17 @@ export default function RequestManagement() { {/* --- Tarball Card --- */} { - selectedRequest.tarball_path && ( + selectedRequest.tarball && ( diff --git a/src/components/req/ReqForm.tsx b/src/components/req/ReqForm.tsx index d5a418a..2641546 100644 --- a/src/components/req/ReqForm.tsx +++ b/src/components/req/ReqForm.tsx @@ -5,19 +5,46 @@ import { Button } from "@mui/joy"; import { AutoComplete } from "primereact/autocomplete"; import { InputText } from "primereact/inputtext"; +declare global { + interface Window { + _t?: string; + } +} + +type MediaType = 'movie' | 'tv' | string; + +interface SearchItem { + label: string; + year?: string; + mediaType?: MediaType; + poster_path?: string | null; + overview?: string; + title?: string; + type?: string; + requester?: string; +} + +interface SubmittedRequest { + title: string; + year: string; + type: string; + requester: string; + poster_path?: string | null; +} + export default function ReqForm() { - const [type, setType] = useState(""); - const [title, setTitle] = useState(""); - const [year, setYear] = useState(""); - const [requester, setRequester] = useState(""); - const [selectedItem, setSelectedItem] = useState(null); - const [selectedOverview, setSelectedOverview] = useState(""); - const [selectedTitle, setSelectedTitle] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [suggestions, setSuggestions] = useState([]); - const [posterLoading, setPosterLoading] = useState(true); - const [submittedRequest, setSubmittedRequest] = useState(null); // Track successful submission - const [csrfToken, setCsrfToken] = useState(null); + const [type, setType] = useState(""); + const [title, setTitle] = useState(""); + const [year, setYear] = useState(""); + const [requester, setRequester] = useState(""); + const [selectedItem, setSelectedItem] = useState(null); + const [selectedOverview, setSelectedOverview] = useState(""); + const [selectedTitle, setSelectedTitle] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [posterLoading, setPosterLoading] = useState(true); + const [submittedRequest, setSubmittedRequest] = useState(null); // Track successful submission + const [csrfToken, setCsrfToken] = useState(null); // Get CSRF token from window global on mount useEffect(() => { @@ -36,7 +63,7 @@ export default function ReqForm() { } }, [title, selectedTitle, selectedOverview, selectedItem, type]); - const searchTitles = async (event) => { + const searchTitles = async (event: { query: string }) => { const query = event.query; if (query.length < 2) { setSuggestions([]); @@ -48,7 +75,7 @@ export default function ReqForm() { if (!response.ok) { throw new Error(`API error: ${response.status}`); } - const data = await response.json(); + const data: SearchItem[] = await response.json(); setSuggestions(data); } catch (error) { console.error('Error fetching suggestions:', error); @@ -56,7 +83,7 @@ export default function ReqForm() { } }; - const handleSubmit = async (e) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) { toast.error("Please fill in the required fields."); @@ -101,7 +128,7 @@ export default function ReqForm() { year, type, requester, - poster_path: selectedItem?.poster_path, + poster_path: selectedItem?.poster_path ?? null, }); } catch (error) { console.error('Submission error:', error); @@ -126,13 +153,13 @@ export default function ReqForm() { const attachScrollFix = () => { setTimeout(() => { - const panel = document.querySelector(".p-autocomplete-panel"); - const items = panel?.querySelector(".p-autocomplete-items"); + const panel = document.querySelector(".p-autocomplete-panel"); + const items = panel?.querySelector(".p-autocomplete-items"); if (items) { items.style.maxHeight = "200px"; items.style.overflowY = "auto"; items.style.overscrollBehavior = "contain"; - const wheelHandler = (e) => { + const wheelHandler = (e: WheelEvent) => { const delta = e.deltaY; const atTop = items.scrollTop === 0; const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight; @@ -148,7 +175,7 @@ export default function ReqForm() { }, 0); }; - const formatMediaType = (mediaTypeValue) => { + const formatMediaType = (mediaTypeValue: MediaType | undefined) => { if (!mediaTypeValue) return ""; if (mediaTypeValue === "tv") return "TV Series"; if (mediaTypeValue === "movie") return "Movie"; @@ -239,16 +266,17 @@ export default function ReqForm() { delay={300} onChange={(e) => { // Handle both string input and object selection - const val = e.target?.value ?? e.value; - setTitle(typeof val === 'string' ? val : val?.label || ''); + const val = (e as any).target?.value ?? e.value; + setTitle(typeof val === 'string' ? val : (val as SearchItem | undefined)?.label || ''); }} - onSelect={(e) => { - setType(e.value.mediaType === 'tv' ? 'tv' : 'movie'); - setTitle(e.value.label); - setSelectedTitle(e.value.label); - setSelectedItem(e.value); - if (e.value.year) setYear(e.value.year); - setSelectedOverview(e.value.overview || ""); + onSelect={(e: { value: SearchItem }) => { + const item = e.value; + setType(item.mediaType === 'tv' ? 'tv' : 'movie'); + setTitle(item.label); + setSelectedTitle(item.label); + setSelectedItem(item); + if (item.year) setYear(item.year); + setSelectedOverview(item.overview || ""); }} placeholder="Enter movie or TV title" title="Enter movie or TV show title" diff --git a/src/config.ts b/src/config.ts index 6e10428..e2e879c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,7 +63,7 @@ export const RADIO_API_URL: string = "https://radio-api.codey.lol"; export const socialLinks: Record = { }; -export const MAJOR_VERSION: string = "0.5" +export const MAJOR_VERSION: string = "0.6" export const RELEASE_FLAG: string | null = null; export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod"; diff --git a/src/layouts/WhitelabelLayout.tsx b/src/layouts/WhitelabelLayout.tsx index 6de39f9..eca00b9 100644 --- a/src/layouts/WhitelabelLayout.tsx +++ b/src/layouts/WhitelabelLayout.tsx @@ -1,4 +1,5 @@ -import React, { ReactNode, CSSProperties } from 'react'; +import React from 'react'; +import type { ReactNode, CSSProperties } from 'react'; interface WhitelabelLayoutProps { children: ReactNode; diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 0000000..889a750 --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,511 @@ +import { defineMiddleware } from 'astro:middleware'; +import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES } from './config.js'; +import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js'; + +// Polyfill Headers.getSetCookie for environments where it's not present. +// Astro's Node adapter expects headers.getSetCookie() to exist when +// 'set-cookie' headers are present; in some Node runtimes Headers lacks it +// which leads to TypeError: headers.getSetCookie is not a function. +if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.prototype.getSetCookie !== 'function') { + try { + Object.defineProperty(globalThis.Headers.prototype, 'getSetCookie', { + value: function () { + const cookies = []; + for (const [name, val] of this.entries()) { + if (name && name.toLowerCase() === 'set-cookie') cookies.push(val); + } + return cookies; + }, + configurable: true, + writable: true, + }); + } catch (err) { + // If we can't patch Headers, swallow silently — code will still try to + // access getSetCookie, but our other guards handle missing function calls. + console.warn('[middleware] Failed to polyfill Headers.getSetCookie', err); + } +} + +const API_URL = "https://api.codey.lol"; +const AUTH_TIMEOUT_MS = 3000; // 3 second timeout for auth requests + +// Deduplication for concurrent refresh requests (prevents race condition where +// multiple SSR requests try to refresh simultaneously, causing 401s after the +// first one rotates the refresh token) +let refreshPromise = null; +let lastRefreshResult = null; +let lastRefreshTime = 0; +const REFRESH_RESULT_TTL = 5000; // Cache successful refresh result for 5 seconds + +// Auth check function (mirrors requireAuthHook logic but for middleware) +async function checkAuth(request) { + try { + const cookieHeader = request.headers.get("cookie") ?? ""; + + // Add timeout to prevent hanging + let controller = new AbortController; + let timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS); + let res; + try { + res = await fetch(`${API_URL}/auth/id`, { + headers: { Cookie: cookieHeader }, + credentials: "include", + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timeout); + console.error("[middleware] auth/id failed or timed out", err.name === 'AbortError' ? 'timeout' : err); + return { authenticated: false, user: null, cookies: null }; + } + clearTimeout(timeout); + + if (res.status === 401) { + // Check if we even have a refresh token before attempting refresh + if (!cookieHeader.includes('refresh_token=')) { + return { authenticated: false, user: null, cookies: null }; + } + + // Check if we have a recent successful refresh result we can reuse + const now = Date.now(); + if (lastRefreshResult && (now - lastRefreshTime) < REFRESH_RESULT_TTL) { + console.log(`[middleware] Reusing cached refresh result from ${now - lastRefreshTime}ms ago`); + return lastRefreshResult; + } + + // Deduplicate concurrent refresh requests + if (refreshPromise) { + console.log(`[middleware] Waiting for in-flight refresh request`); + return refreshPromise; + } + + // Start a new refresh request + refreshPromise = (async () => { + console.log(`[middleware] Starting token refresh...`); + let controller = new AbortController(); + let timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS); + let refreshRes; + try { + refreshRes = await fetch(`${API_URL}/auth/refresh`, { + method: "POST", + headers: { Cookie: cookieHeader }, + credentials: "include", + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timeout); + console.error("[middleware] auth/refresh failed or timed out", err.name === 'AbortError' ? 'timeout' : err); + return { authenticated: false, user: null, cookies: null }; + } + clearTimeout(timeout); + + if (!refreshRes.ok) { + // Log the response body for debugging + let errorDetail = ''; + try { + const errorBody = await refreshRes.text(); + errorDetail = ` - ${errorBody}`; + } catch {} + console.error(`[middleware] Token refresh failed ${refreshRes.status}${errorDetail}`); + return { authenticated: false, user: null, cookies: null }; + } + + console.log(`[middleware] Token refresh succeeded`); + + // Get refreshed cookies + let setCookies = []; + if (typeof refreshRes.headers.getSetCookie === 'function') { + setCookies = refreshRes.headers.getSetCookie(); + } else { + const setCookieHeader = refreshRes.headers.get("set-cookie"); + if (setCookieHeader) { + setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/); + } + } + + if (setCookies.length === 0) { + console.error("[middleware] No set-cookie headers in refresh response"); + return { authenticated: false, user: null, cookies: null }; + } + + // Build new cookie header for retry + const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; "); + + // Retry auth/id with new cookies and timeout + controller = new AbortController(); + timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS); + let retryRes; + try { + retryRes = await fetch(`${API_URL}/auth/id`, { + headers: { Cookie: newCookieHeader }, + credentials: "include", + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timeout); + console.error("[middleware] auth/id retry failed or timed out", err.name === 'AbortError' ? 'timeout' : err); + return { authenticated: false, user: null, cookies: null }; + } + clearTimeout(timeout); + + if (!retryRes.ok) { + console.error(`[middleware] auth/id retry failed with status ${retryRes.status}`); + return { authenticated: false, user: null, cookies: null }; + } + + const user = await retryRes.json(); + return { authenticated: true, user, cookies: setCookies }; + })(); + + // Clear the promise when done and cache the result + refreshPromise.then(result => { + if (result.authenticated) { + lastRefreshResult = result; + lastRefreshTime = Date.now(); + } + refreshPromise = null; + }).catch(() => { + refreshPromise = null; + }); + + return refreshPromise; + } + + if (!res.ok) { + return { authenticated: false, user: null, cookies: null }; + } + + const user = await res.json(); + return { authenticated: true, user, cookies: null }; + } catch (err) { + console.error("[middleware] Auth check error:", err); + return { authenticated: false, user: null, cookies: null }; + } +} + +// Check if a path matches any protected route and return the config +function getProtectedRouteConfig(pathname) { + // Normalize pathname for comparison (lowercase) + const normalizedPath = pathname.toLowerCase(); + + for (const route of PROTECTED_ROUTES) { + const routePath = typeof route === 'string' ? route : route.path; + const normalizedRoute = routePath.toLowerCase(); + const matches = normalizedPath === normalizedRoute || normalizedPath.startsWith(normalizedRoute + '/'); + if (matches) { + // Check if this path is excluded from protection + const excludes = (typeof route === 'object' && route.exclude) || []; + for (const excludePath of excludes) { + const normalizedExclude = excludePath.toLowerCase(); + if (normalizedPath === normalizedExclude || normalizedPath.startsWith(normalizedExclude + '/')) { + if (import.meta.env.DEV) console.log(`[middleware] Path ${pathname} excluded from protection by ${excludePath}`); + return null; // Excluded, not protected + } + } + return typeof route === 'string' ? { path: route, roles: null } : route; + } + } + return null; +} + +// Check if a path is explicitly public (but NOT if it matches a protected route) +function isPublicRoute(pathname) { + // If the path matches a protected route, it's NOT public + if (getProtectedRouteConfig(pathname)) { + return false; + } + + for (const route of PUBLIC_ROUTES) { + // For routes ending with /, match any path starting with that prefix + if (route.endsWith('/') && route !== '/') { + if (pathname.startsWith(route)) { + if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route} (prefix)`); + return true; + } + } else { + // For other routes, match exactly or as a path prefix (route + /) + if (pathname === route) { + if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route} (exact)`); + return true; + } + // Special case: don't treat / as a prefix for all paths + if (route !== '/' && pathname.startsWith(route + '/')) { + if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route}/ (subpath)`); + return true; + } + } + } + if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute(${pathname}) = false`); + return false; +} + +export const onRequest = defineMiddleware(async (context, next) => { + try { + const pathname = context.url.pathname; + + // Skip auth check for static assets + const skipAuthPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/images', '/_static']; + const shouldSkipAuth = skipAuthPrefixes.some(p => pathname.startsWith(p)); + + // Check if route is protected (requires auth) + const protectedConfig = getProtectedRouteConfig(pathname); + const isApiRoute = pathname.startsWith('/api/'); + if (import.meta.env.DEV) console.log(`[middleware] Path: ${pathname}, Protected: ${!!protectedConfig}, SkipAuth: ${shouldSkipAuth}`); + + // Always attempt auth for non-static routes to populate user info + if (!shouldSkipAuth) { + const { authenticated, user, cookies } = await checkAuth(context.request); + if (import.meta.env.DEV) console.log(`[middleware] Auth result: authenticated=${authenticated}`); + + // Expose authenticated user and refreshed cookies to downstream handlers/layouts + if (authenticated && user) { + context.locals.user = user; + if (cookies && cookies.length > 0) { + context.locals.refreshedCookies = cookies; + } + } + + // For protected routes, enforce authentication + if (protectedConfig && !isPublicRoute(pathname)) { + if (!authenticated) { + if (isApiRoute) { + // Return JSON 401 for API routes + return new Response(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + // Redirect to login with return URL + const returnUrl = encodeURIComponent(pathname + context.url.search); + if (import.meta.env.DEV) console.log(`[middleware] Auth required for ${pathname}, redirecting to login`); + return context.redirect(`/login?returnUrl=${returnUrl}`, 302); + } + + // Check role-based access if roles are specified + if (protectedConfig.roles && protectedConfig.roles.length > 0) { + const userRoles = user?.roles || []; + const isAdmin = userRoles.includes('admin'); + const hasRequiredRole = isAdmin || protectedConfig.roles.some(role => userRoles.includes(role)); + if (!hasRequiredRole) { + if (import.meta.env.DEV) console.log(`[middleware] User lacks required role for ${pathname}`); + if (isApiRoute) { + // Return JSON 403 for API routes + return new Response(JSON.stringify({ + error: 'Forbidden', + message: 'Insufficient permissions', + requiredRoles: protectedConfig.roles + }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + // Store required roles in locals for the login page to access + context.locals.accessDenied = true; + context.locals.requiredRoles = protectedConfig.roles; + context.locals.returnUrl = pathname + context.url.search; + // Rewrite to login page - this renders /login but keeps the URL and locals + return context.rewrite('/login'); + } + } + + if (import.meta.env.DEV) console.log(`[middleware] Auth OK for ${pathname}, user: ${user?.username || user?.id || 'unknown'}`); + } + } + + // Check the Host header to differentiate subdomains + // Build a headers map safely because Headers.get(':authority') throws + const headersMap = {}; + for (const [k, v] of context.request.headers) { + headersMap[k.toLowerCase()] = v; + } + const hostHeader = headersMap['host'] || ''; + // Node/http2 might store host as :authority (pseudo header); it appears under iteration as ':authority' + const authorityHeader = headersMap[':authority'] || ''; + // Fallback to context.url.hostname if available (some environments populate it) + const urlHost = context.url?.hostname || ''; + const host = (hostHeader || authorityHeader || urlHost).split(':')[0]; // normalize remove port + + const requestIp = (headersMap['x-forwarded-for']?.split(',')[0]?.trim()) + || headersMap['x-real-ip'] + || headersMap['cf-connecting-ip'] + || headersMap['forwarded']?.split(';').find(kv => kv.trim().startsWith('for='))?.split('=')[1]?.replace(/"/g, '') + || 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}`); + + + // When the host header is missing, log all headers for debugging and + // attempt to determine host from forwarded headers or a dev query header. + if (!host) { + if (import.meta.env.DEV) console.log('[middleware] WARNING: Host header missing. Dumping headers:'); + for (const [k, v] of context.request.headers) { + if (import.meta.env.DEV) console.log(`[middleware] header: ${k}=${v}`); + } + } + + // Determine if how the request says it wants the whitelabel + const forwardedHost = headersMap['x-forwarded-host'] || ''; + const xWhitelabel = headersMap['x-whitelabel'] || ''; + const forceWhitelabelQuery = context.url.searchParams.get('whitelabel'); + + // Make whitelabel detection dynamic based on `SUBSITES` mapping and incoming signals + // We'll detect by full host (host/forwarded/authority) or by short-name match + const subsiteHosts = Object.keys(SUBSITES || {}); + + const hostSubsite = getSubsiteByHost(host); + const forwardedSubsite = getSubsiteByHost(forwardedHost); + const authoritySubsite = getSubsiteByHost(authorityHeader); + const headerSignalSubsite = getSubsiteFromSignal(xWhitelabel); + const querySignalSubsite = getSubsiteFromSignal(forceWhitelabelQuery); + + const wantsSubsite = Boolean(hostSubsite || forwardedSubsite || authoritySubsite || headerSignalSubsite || querySignalSubsite); + + // Use central SUBSITES mapping + // import from config.js near top to ensure single source of truth + // (we import lazily here for compatibility with middleware runtime) + const subsites = SUBSITES || {}; + + // Check if the request matches a subsite + const subsitePath = subsites[host]; + if (subsitePath) { + const skipPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/api', '/robots.txt', '/_static']; + const shouldSkip = skipPrefixes.some((p) => context.url.pathname.startsWith(p)); + + if (!shouldSkip && !context.url.pathname.startsWith(subsitePath)) { + const newPath = `${subsitePath}${context.url.pathname}`; + if (import.meta.env.DEV) console.log(`[middleware] Rewriting ${host} ${context.url.pathname} -> ${newPath}`); + return context.rewrite(newPath); + } + } else { + // If the path appears to be a subsite path (like /subsites/req) but the host isn't a subsite, + // block so the main site doesn't accidentally serve that content. + const allPaths = Object.values(subsites || {}); + const pathLooksLikeSubsite = allPaths.some((p) => context.url.pathname.startsWith(p)); + if (pathLooksLikeSubsite) { + if (import.meta.env.DEV) console.log(`[middleware] Blocking subsite path on main domain: ${host}${context.url.pathname}`); + return new Response('Not found', { status: 404 }); + } + } + + // Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc) + const isMainDomain = !wantsSubsite; + if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) { + // Immediately return a 404 for /req on the main domain + if (Object.values(subsites || {}).includes(context.url.pathname)) { + if (import.meta.env.DEV) console.log(`[middleware] Blocking subsite root on main domain: ${hostHeader}${context.url.pathname}`); + return new Response('Not found', { status: 404 }); + } + if (import.meta.env.DEV) console.log(`[middleware] Blocking subsite wildcard path on main domain: ${hostHeader}${context.url.pathname}`); + return new Response('Not found', { status: 404 }); + } + + // Explicitly handle the forceWhitelabelQuery to rewrite the path + if (forceWhitelabelQuery) { + const forced = getSubsiteFromSignal(forceWhitelabelQuery); + const subsitePath = forced?.path || (`/${forceWhitelabelQuery}`.startsWith('/') ? `/${forceWhitelabelQuery}` : `/${forceWhitelabelQuery}`); + if (import.meta.env.DEV) console.log(`[middleware] Forcing whitelabel via query: ${forceWhitelabelQuery} -> rewrite to ${subsitePath}${context.url.pathname}`); + context.url.pathname = `${subsitePath}${context.url.pathname}`; + } + + // Pass the whitelabel value explicitly to context.locals + // set a normalized whitelabel short name if available + const chosen = querySignalSubsite?.short || headerSignalSubsite?.short || hostSubsite?.short || forwardedSubsite?.short || authoritySubsite?.short || null; + context.locals.whitelabel = chosen; + context.locals.isSubsite = Boolean(wantsSubsite); + if (import.meta.env.DEV) console.log(`[middleware] Setting context.locals.whitelabel: ${context.locals.whitelabel}`); + + // Final debug: show the final pathname we hand to Astro + if (import.meta.env.DEV) console.log(`[middleware] Final pathname: ${context.url.pathname}`); + + // Let Astro handle the request first + const response = await next(); + + // Add security headers to response + const securityHeaders = new Headers(response.headers); + + // Prevent clickjacking + securityHeaders.set('X-Frame-Options', 'SAMEORIGIN'); + + // Prevent MIME type sniffing + securityHeaders.set('X-Content-Type-Options', 'nosniff'); + + // XSS protection (legacy, but still useful) + securityHeaders.set('X-XSS-Protection', '1; mode=block'); + + // Referrer policy - send origin only on cross-origin requests + securityHeaders.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // HSTS - enforce HTTPS (only in production) + if (!import.meta.env.DEV) { + securityHeaders.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + // Permissions policy - restrict sensitive APIs + securityHeaders.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + + // Content Security Policy - restrict resource loading + // Note: 'unsafe-inline' and 'unsafe-eval' needed for React/Astro hydration + // In production, consider using nonces or hashes for stricter CSP + const cspDirectives = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://www.youtube-nocookie.com https://s.ytimg.com https://challenges.cloudflare.com https://static.cloudflareinsights.com", + // Allow Cloudflare's inline event handlers (for Turnstile/challenges) + "script-src-attr 'unsafe-inline'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com data:", + "img-src 'self' data: blob: https: http:", + "media-src 'self' blob: https:", + "connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:", + // Allow YouTube for video embeds and Cloudflare for challenges/Turnstile + "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", + "upgrade-insecure-requests", + ].join('; '); + securityHeaders.set('Content-Security-Policy', cspDirectives); + + // Forward any refreshed auth cookies to the client + if (context.locals.refreshedCookies && context.locals.refreshedCookies.length > 0) { + const newResponse = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: securityHeaders, + }); + context.locals.refreshedCookies.forEach(cookie => { + newResponse.headers.append('set-cookie', cookie.trim()); + }); + + // If it's a 404, redirect to home + if (newResponse.status === 404 && !context.url.pathname.startsWith('/api/')) { + if (import.meta.env.DEV) console.log(`404 redirect: ${context.url.pathname} -> /`); + return context.redirect('/', 302); + } + + return newResponse; + } + + // If it's a 404, redirect to home + if (response.status === 404 && !context.url.pathname.startsWith('/api/')) { + if (import.meta.env.DEV) console.log(`404 redirect: ${context.url.pathname} -> /`); + return context.redirect('/', 302); + } + + // Return response with security headers + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: securityHeaders, + }); + } catch (error) { + // Handle any middleware errors by redirecting to home + console.error('Middleware error:', error); + return context.redirect('/', 302); + } +}); \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index c00cd31..6ec8145 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,30 @@ import { defineMiddleware } from 'astro:middleware'; import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, type ProtectedRoute } from './config.ts'; import { getSubsiteByHost, getSubsiteFromSignal, type SubsiteInfo } from './utils/subsites.ts'; +declare module 'astro' { + interface Locals { + user?: AuthUser; + refreshedCookies?: string[]; + accessDenied?: boolean; + requiredRoles?: string[]; + returnUrl?: string; + whitelabel?: string | null; + isSubsite?: boolean; + } +} + +declare module 'astro:middleware' { + interface Locals { + user?: AuthUser; + refreshedCookies?: string[]; + accessDenied?: boolean; + requiredRoles?: string[]; + returnUrl?: string; + whitelabel?: string | null; + isSubsite?: boolean; + } +} + export interface AuthUser { id?: string; username?: string; @@ -313,9 +337,9 @@ export const onRequest = defineMiddleware(async (context, next) => { }); } // Store required roles in locals for the login page to access - context.locals.accessDenied = true; - context.locals.requiredRoles = protectedConfig.roles; - context.locals.returnUrl = pathname + context.url.search; + (context.locals as any).accessDenied = true; + (context.locals as any).requiredRoles = protectedConfig.roles; + (context.locals as any).returnUrl = pathname + context.url.search; // Rewrite to login page - this renders /login but keeps the URL and locals return context.rewrite('/login'); } @@ -374,8 +398,8 @@ export const onRequest = defineMiddleware(async (context, next) => { const hostSubsite = getSubsiteByHost(host); const forwardedSubsite = getSubsiteByHost(forwardedHost); const authoritySubsite = getSubsiteByHost(authorityHeader); - const headerSignalSubsite = getSubsiteFromSignal(xWhitelabel); - const querySignalSubsite = getSubsiteFromSignal(forceWhitelabelQuery); + const headerSignalSubsite = getSubsiteFromSignal(xWhitelabel || undefined); + const querySignalSubsite = getSubsiteFromSignal(forceWhitelabelQuery || undefined); const wantsSubsite = Boolean(hostSubsite || forwardedSubsite || authoritySubsite || headerSignalSubsite || querySignalSubsite); @@ -420,7 +444,7 @@ export const onRequest = defineMiddleware(async (context, next) => { // Explicitly handle the forceWhitelabelQuery to rewrite the path if (forceWhitelabelQuery) { - const forced = getSubsiteFromSignal(forceWhitelabelQuery); + const forced = getSubsiteFromSignal(forceWhitelabelQuery || undefined); const subsitePath = forced?.path || (`/${forceWhitelabelQuery}`.startsWith('/') ? `/${forceWhitelabelQuery}` : `/${forceWhitelabelQuery}`); if (import.meta.env.DEV) console.log(`[middleware] Forcing whitelabel via query: ${forceWhitelabelQuery} -> rewrite to ${subsitePath}${context.url.pathname}`); context.url.pathname = `${subsitePath}${context.url.pathname}`; @@ -433,7 +457,6 @@ export const onRequest = defineMiddleware(async (context, next) => { // Also make it explicit whether this request maps to a subsite (any configured SUBSITES value) // Middleware already resolved `wantsSubsite` which is true if host/forwarded/authority/header/query indicate a subsite. // Expose a simple boolean so server-rendered pages/layouts or components can opt-out of loading/hydrating - // heavyweight subsystems (like the AudioPlayer) when the request is for a subsite. context.locals.isSubsite = Boolean(wantsSubsite); if (import.meta.env.DEV) console.log(`[middleware] Setting context.locals.whitelabel: ${context.locals.whitelabel}`); diff --git a/src/pages/api/discord/cached-video.ts b/src/pages/api/discord/cached-video.ts index 219bb2b..905823c 100644 --- a/src/pages/api/discord/cached-video.ts +++ b/src/pages/api/discord/cached-video.ts @@ -120,6 +120,10 @@ export async function GET({ request }: APIContext): Promise { } // Partial content + if (result.start == null || result.end == null) { + console.error('[cached-video] Missing range bounds in partial response'); + return new Response('Invalid range', { status: 500 }); + } headers.set('Content-Range', `bytes ${result.start}-${result.end}/${result.total}`); headers.set('Content-Length', String(result.end - result.start + 1)); return new Response(result.stream, { status: 206, headers }); diff --git a/src/pages/api/discord/channels.ts b/src/pages/api/discord/channels.ts index 3e025a0..8aa19b2 100644 --- a/src/pages/api/discord/channels.ts +++ b/src/pages/api/discord/channels.ts @@ -27,9 +27,9 @@ interface ChannelRow { export async function GET({ request }: APIContext): Promise { // Rate limit check const rateCheck = checkRateLimit(request, { - limit: 20, + limit: 60, windowMs: 1000, - burstLimit: 100, + burstLimit: 240, burstWindowMs: 10_000, }); diff --git a/src/pages/api/discord/members.ts b/src/pages/api/discord/members.ts index 478cfee..73e785a 100644 --- a/src/pages/api/discord/members.ts +++ b/src/pages/api/discord/members.ts @@ -22,7 +22,12 @@ const ADMINISTRATOR = 0x8n; // 8 * 3. Apply member-specific overwrites (allow/deny) * 4. Check if VIEW_CHANNEL permission is granted */ -async function getChannelVisibleMembers(channelId, guildId) { +interface RoleOverwrite { + allow: bigint; + deny: bigint; +} + +async function getChannelVisibleMembers(channelId: string, guildId: string): Promise> { // Get guild info including owner const guildInfo = await sql` SELECT owner_id FROM guilds WHERE guild_id = ${guildId} @@ -43,8 +48,8 @@ async function getChannelVisibleMembers(channelId, guildId) { `; // Build overwrite lookups - const roleOverwrites = new Map(); - const memberOverwrites = new Map(); + 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); @@ -67,16 +72,16 @@ async function getChannelVisibleMembers(channelId, guildId) { const allRoles = await sql` SELECT role_id, permissions FROM roles WHERE guild_id = ${guildId} `; - const rolePermissions = new Map(); + const rolePermissions = new Map(); for (const role of allRoles) { rolePermissions.set(role.role_id.toString(), BigInt(role.permissions || 0)); } - const visibleMemberIds = new Set(); + const visibleMemberIds = new Set(); for (const member of members) { const userId = member.user_id.toString(); - const memberRoles = (member.roles || []).map(r => r.toString()); + const memberRoles = (member.roles || []).map((r: bigint | string) => r.toString()); // Owner always has access if (ownerId && member.user_id.toString() === ownerId.toString()) { @@ -138,9 +143,9 @@ async function getChannelVisibleMembers(channelId, guildId) { export async function GET({ request }) { // Rate limit check const rateCheck = checkRateLimit(request, { - limit: 20, + limit: 80, windowMs: 1000, - burstLimit: 100, + burstLimit: 320, burstWindowMs: 10_000, }); @@ -187,7 +192,7 @@ export async function GET({ request }) { } // If channelId provided, get visible members for that channel - let visibleMemberIds = null; + let visibleMemberIds: Set | null = null; if (channelId) { visibleMemberIds = await getChannelVisibleMembers(channelId, guildId); } @@ -226,14 +231,22 @@ export async function GET({ request }) { `; // Create role lookup map - const roleMap = {}; + interface RoleInfo { + id: string; + name: string; + color: string | null; + position: number; + hoist: boolean; + } + + const roleMap: Record = {}; 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 + color: role.color ? `#${Number(role.color).toString(16).padStart(6, '0')}` : null, + position: Number(role.position ?? 0), + hoist: Boolean(role.hoist), // "hoist" means the role is displayed separately in member list }; }); @@ -243,11 +256,24 @@ export async function GET({ request }) { : members; // Format members with their highest role for color - const formattedMembers = filteredMembers.map(member => { - const memberRoles = (member.roles || []).map(r => r.toString()); + interface FormattedMember { + id: string; + username: string; + displayName: string; + avatar: string | null; + isBot: boolean; + color: string | null; + primaryRoleId: string | null; + primaryRoleName: string | null; + roles: string[]; + } + + const formattedMembers: FormattedMember[] = filteredMembers.map(member => { + const memberRoles = (member.roles || []).map((r: bigint | string) => r.toString()); // Find highest positioned role with color for display - let displayRole = null; + let displayRole: RoleInfo | null = null; + let displayRoleColor: string | null = null; let highestPosition = -1; memberRoles.forEach(roleId => { @@ -256,6 +282,7 @@ export async function GET({ request }) { highestPosition = role.position; if (role.color && role.color !== '#000000') { displayRole = role; + displayRoleColor = role.color; } } }); @@ -263,7 +290,7 @@ export async function GET({ request }) { // Find all hoisted roles for grouping const hoistedRoles = memberRoles .map(rid => roleMap[rid]) - .filter(r => r && r.hoist) + .filter((r): r is RoleInfo => Boolean(r && r.hoist)) .sort((a, b) => b.position - a.position); const primaryRole = hoistedRoles[0] || displayRole || null; @@ -284,7 +311,7 @@ export async function GET({ request }) { displayName: member.nickname || member.global_name || member.username, avatar: avatarUrl, isBot: member.is_bot || false, - color: displayRole?.color || null, + color: displayRoleColor, primaryRoleId: primaryRole?.id || null, primaryRoleName: primaryRole?.name || null, roles: memberRoles, @@ -292,8 +319,8 @@ export async function GET({ request }) { }); // Group members by their primary (highest hoisted) role - const membersByRole = {}; - const noRoleMembers = []; + const membersByRole: Record = {}; + const noRoleMembers: FormattedMember[] = []; formattedMembers.forEach(member => { if (member.primaryRoleId) { @@ -316,7 +343,7 @@ export async function GET({ request }) { // 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 }, + role: { id: 'none', name: 'Members', color: null, position: -1, hoist: false }, members: noRoleMembers, }); } diff --git a/src/pages/api/discord/messages.ts b/src/pages/api/discord/messages.ts index d3e620b..753621a 100644 --- a/src/pages/api/discord/messages.ts +++ b/src/pages/api/discord/messages.ts @@ -99,9 +99,9 @@ function getSafeImageUrl(originalUrl: string | null, baseUrl: string): string | export async function GET({ request }: APIContext) { // Rate limit check const rateCheck = checkRateLimit(request, { - limit: 30, + limit: 100, windowMs: 1000, - burstLimit: 150, + burstLimit: 400, burstWindowMs: 10_000, }); @@ -920,7 +920,7 @@ export async function GET({ request }: APIContext) { } const ext = stickerExtensions[sticker.format_type] || 'png'; // Use cached sticker image if available, otherwise fall back to Discord CDN - let stickerUrl = null; + let stickerUrl: string | null = null; if (sticker.cached_image_id) { const sig = signImageId(sticker.cached_image_id); stickerUrl = `${baseUrl}/api/discord/cached-image?id=${sticker.cached_image_id}&sig=${sig}`; diff --git a/src/pages/api/discord/search.ts b/src/pages/api/discord/search.ts index a7e43ce..bde4f40 100644 --- a/src/pages/api/discord/search.ts +++ b/src/pages/api/discord/search.ts @@ -182,7 +182,20 @@ export async function GET({ request }) { LEFT JOIN video_cache vc ON a.cached_video_id = vc.video_id WHERE a.message_id = ANY(${messageIds}) ORDER BY a.attachment_id - `; + ` as unknown as Array<{ + message_id: string; + attachment_id: string; + filename: string | null; + url: string; + proxy_url: string | null; + content_type: string | null; + size: number | null; + width: number | null; + height: number | null; + cached_image_id: number | null; + cached_video_id: number | null; + video_file_path: string | null; + }>; // Fetch cached video URLs for video attachments const videoUrls = attachments @@ -193,9 +206,9 @@ export async function GET({ request }) { SELECT source_url, video_id FROM video_cache WHERE source_url = ANY(${videoUrls}) - ` : []; + ` as unknown as Array<{ source_url: string; video_id: number }> : []; - const videoCacheMap = {}; + const videoCacheMap: Record = {}; cachedVideos.forEach(v => { videoCacheMap[v.source_url] = v.video_id; }); @@ -234,7 +247,13 @@ export async function GET({ request }) { .filter(m => m.reference_message_id) .map(m => m.reference_message_id); - let referencedMessages = []; + let referencedMessages: Array<{ + message_id: string; + content: string; + author_id: string; + author_username: string; + author_display_name: string; + }> = []; if (referencedIds.length > 0) { referencedMessages = await sql` SELECT diff --git a/src/pages/api/image-proxy.ts b/src/pages/api/image-proxy.ts index 8908da4..d770bf4 100644 --- a/src/pages/api/image-proxy.ts +++ b/src/pages/api/image-proxy.ts @@ -263,7 +263,8 @@ export async function GET({ request }) { return proxyResponse; } catch (err) { - console.error('[image-proxy] Error fetching image:', err.message); + const message = err instanceof Error ? err.message : String(err); + console.error('[image-proxy] Error fetching image:', message); return new Response('Failed to fetch image', { status: 500 }); } } diff --git a/src/pages/api/link-preview.ts b/src/pages/api/link-preview.ts index 7991afa..3d1d8d1 100644 --- a/src/pages/api/link-preview.ts +++ b/src/pages/api/link-preview.ts @@ -24,6 +24,7 @@ interface LinkPreviewMeta { type: string | null; video: string | null; themeColor: string | null; + trusted: boolean; } // Trusted domains that can be loaded client-side @@ -73,6 +74,7 @@ function parseMetaTags(html: string, url: string): LinkPreviewMeta { type: null, video: null, themeColor: null, + trusted: false, }; const decode = str => str?.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g, @@ -253,6 +255,13 @@ export async function GET({ request }) { } // Read first 50KB + if (!response.body) { + return new Response(JSON.stringify({ error: 'Empty response body' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + const reader = response.body.getReader(); let html = ''; let bytesRead = 0; @@ -281,7 +290,8 @@ export async function GET({ request }) { return resp; } catch (err) { - console.error('[link-preview] Error fetching URL:', err.message); + const message = err instanceof Error ? err.message : String(err); + console.error('[link-preview] Error fetching URL:', message); // Don't expose internal error details to client return new Response(JSON.stringify({ error: 'Failed to fetch preview' }), { status: 500, diff --git a/src/pages/api/search.ts b/src/pages/api/search.ts index f08811f..864e007 100644 --- a/src/pages/api/search.ts +++ b/src/pages/api/search.ts @@ -30,7 +30,7 @@ interface SearchSuggestion { export async function GET({ request }: APIContext): Promise { const host = request.headers.get('host'); - const subsite = getSubsiteByHost(host); + const subsite = getSubsiteByHost(host || undefined); if (!subsite || subsite.short !== 'req') { return new Response('Not found', { status: 404 }); diff --git a/src/pages/lighting.astro b/src/pages/lighting.astro index eb81e6c..6b983e5 100644 --- a/src/pages/lighting.astro +++ b/src/pages/lighting.astro @@ -7,7 +7,7 @@ import Root from "@/components/AppLayout.jsx"; const user = Astro.locals.user as any; --- - +
diff --git a/src/pages/login.astro b/src/pages/login.astro index 5a8d787..cbc19c2 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -9,7 +9,7 @@ const accessDenied = Astro.locals.accessDenied || false; const requiredRoles = Astro.locals.requiredRoles || []; --- - +
diff --git a/src/pages/memes.astro b/src/pages/memes.astro index 5b7f6f0..387bcfc 100644 --- a/src/pages/memes.astro +++ b/src/pages/memes.astro @@ -4,7 +4,7 @@ import Root from "../components/AppLayout.jsx"; import "@styles/MemeGrid.css"; --- - +
diff --git a/src/utils/apiAuth.ts b/src/utils/apiAuth.ts index 25c47fb..e0304e5 100644 --- a/src/utils/apiAuth.ts +++ b/src/utils/apiAuth.ts @@ -76,7 +76,7 @@ export async function requireApiAuth(request: Request): Promise { } clearTimeout(timeout); - let newSetCookieHeader = null; + let newSetCookieHeader: string[] | null = null; // If unauthorized, try to refresh the token if (res.status === 401) { diff --git a/src/utils/db.ts b/src/utils/db.ts index 3aa618a..0319e08 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -5,7 +5,7 @@ import postgres from 'postgres'; import type { Sql } from 'postgres'; // Database connection configuration -const sql: Sql = postgres({ +const sql: 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', diff --git a/src/utils/subsites.ts b/src/utils/subsites.ts index 517045f..4a5c7ff 100644 --- a/src/utils/subsites.ts +++ b/src/utils/subsites.ts @@ -1,4 +1,4 @@ -import { SUBSITES, WHITELABELS, WhitelabelConfig } from '../config.ts'; +import { SUBSITES, WHITELABELS, type WhitelabelConfig } from '../config.ts'; export interface SubsiteInfo { host: string;