begin js(x) to ts(x)

This commit is contained in:
2025-12-19 11:59:00 -05:00
parent 564bfefa4a
commit 823c8b52b3
51 changed files with 1342 additions and 584 deletions

View File

@@ -1,4 +1,5 @@
import React, { Suspense, lazy, useState, useMemo, useEffect } from 'react';
import type { ComponentType } from 'react';
import Memes from './Memes.jsx';
import Lighting from './Lighting.jsx';
import { toast } from 'react-toastify';
@@ -22,7 +23,28 @@ const DiscordLogs = lazy(() => import('./DiscordLogs.jsx'));
// identified as subsites.
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
export default function Root({ child, user = undefined, ...props }) {
declare global {
interface Window {
toast: typeof toast;
__IS_SUBSITE?: boolean;
}
}
export interface User {
id?: string;
username?: string;
roles?: string[];
[key: string]: unknown;
}
export interface RootProps {
child: string;
user?: User;
loggedIn?: boolean;
[key: string]: unknown;
}
export default function Root({ child, user = undefined, ...props }: RootProps): React.ReactElement {
window.toast = toast;
const theme = document.documentElement.getAttribute("data-theme")
const loggedIn = props.loggedIn ?? Boolean(user);
@@ -54,7 +76,7 @@ export default function Root({ child, user = undefined, ...props }) {
// Use dynamic import+state on the client to avoid React.lazy identity
// churn and to surface any import-time errors. Since Root is used via
// client:only, this code runs in the browser and can safely import.
const [PlayerComp, setPlayerComp] = useState(null);
const [PlayerComp, setPlayerComp] = useState<ComponentType<{ user?: User }> | null>(null);
useEffect(() => {
let mounted = true;
if (wantPlayer) {
@@ -68,7 +90,7 @@ export default function Root({ child, user = undefined, ...props }) {
})
.catch((err) => {
console.error('[AppLayout] AudioPlayer import failed', err);
if (mounted) setPlayerComp(() => null);
if (mounted) setPlayerComp(null);
});
} else {
// unload if we no longer want the player

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
import type Hls from "hls.js";
import "@styles/player.css";
import "@/components/TRip/RequestManagement.css";
import { metaData } from "../config";
@@ -15,6 +16,20 @@ import { authFetch } from "@/utils/authFetch";
import { requireAuthHook } from "@/hooks/requireAuthHook";
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
interface LyricLine {
timestamp: number;
line: string;
}
interface PlayerProps {
user?: {
id?: string;
username?: string;
roles?: string[];
[key: string]: unknown;
};
}
const STATIONS = {
main: { label: "Main" },
@@ -25,7 +40,7 @@ const STATIONS = {
};
export default function Player({ user }) {
export default function Player({ user }: PlayerProps) {
// Global CSS now contains the paginator / dialog datatable dark rules.
const [isQueueVisible, setQueueVisible] = useState(false);
@@ -42,19 +57,19 @@ export default function Player({ user }) {
'.p-dialog .p-datatable-tbody'
];
const elements = selectors
const elements: Element[] = selectors
.map(selector => document.querySelector(selector))
.filter(Boolean);
.filter((el): el is Element => el !== null);
// Also allow scrolling on the entire dialog
const dialog = document.querySelector('.p-dialog');
if (dialog) elements.push(dialog);
elements.forEach(element => {
element.style.overflowY = 'auto';
element.style.overscrollBehavior = 'contain';
(element as HTMLElement).style.overflowY = 'auto';
(element as HTMLElement).style.overscrollBehavior = 'contain';
const wheelHandler = (e) => {
const wheelHandler: EventListener = (e) => {
e.stopPropagation();
// Allow normal scrolling within the modal
};
@@ -99,7 +114,7 @@ export default function Player({ user }) {
const [trackGenre, setTrackGenre] = useState("");
const [trackAlbum, setTrackAlbum] = useState("");
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
const [lyrics, setLyrics] = useState([]);
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
const [elapsedTime, setElapsedTime] = useState(0);
// Update currentLyricIndex as song progresses
@@ -129,15 +144,15 @@ export default function Player({ user }) {
const [requestInputSong, setRequestInputSong] = useState("");
const [requestInputUuid, setRequestInputUuid] = useState("");
const audioElement = useRef(null);
const hlsInstance = useRef(null);
const currentTrackUuid = useRef(null);
const baseTrackElapsed = useRef(0);
const lastUpdateTimestamp = useRef(Date.now());
const activeStationRef = useRef(activeStation);
const wsInstance = useRef(null);
const audioElement = useRef<HTMLAudioElement | null>(null);
const hlsInstance = useRef<Hls | null>(null);
const currentTrackUuid = useRef<string | null>(null);
const baseTrackElapsed = useRef<number>(0);
const lastUpdateTimestamp = useRef<number>(Date.now());
const activeStationRef = useRef<string>(activeStation);
const wsInstance = useRef<WebSocket | null>(null);
const formatTime = (seconds) => {
const formatTime = (seconds: number): string => {
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
@@ -187,16 +202,16 @@ export default function Player({ user }) {
const hls = new Hls({
lowLatencyMode: false,
abrEnabled: false,
// abrEnabled: false, // ABR not in current HLS.js types
liveSyncDuration: 0.6, // seconds behind live edge target
liveMaxLatencyDuration: 3.0, // max allowed latency before catchup
liveCatchUpPlaybackRate: 1.02,
// liveCatchUpPlaybackRate: 1.02, // Not in current HLS.js types
// maxBufferLength: 30,
// maxMaxBufferLength: 60,
// maxBufferHole: 2.0,
// manifestLoadingTimeOut: 5000,
// fragLoadingTimeOut: 10000, // playback speed when catching up
});
} as Partial<typeof Hls.DefaultConfig>);
hlsInstance.current = hls;
hls.attachMedia(audio);
@@ -238,8 +253,8 @@ export default function Player({ user }) {
const activeElement = document.querySelector('.lrc-line.active');
const lyricsContainer = document.querySelector('.lrc-text');
if (activeElement && lyricsContainer) {
lyricsContainer.style.maxHeight = '220px';
lyricsContainer.style.overflowY = 'auto';
(lyricsContainer as HTMLElement).style.maxHeight = '220px';
(lyricsContainer as HTMLElement).style.overflowY = 'auto';
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 0);
@@ -268,11 +283,11 @@ export default function Player({ user }) {
document.title = `${metaData.title} - Radio [${activeStation}]`;
}, [activeStation]);
const parseLrcString = useCallback((lrcString) => {
const parseLrcString = useCallback((lrcString: string | undefined | null): LyricLine[] => {
if (!lrcString || typeof lrcString !== 'string') return [];
const lines = lrcString.split('\n').filter(line => line.trim());
const parsedLyrics = [];
const parsedLyrics: LyricLine[] = [];
for (const line of lines) {
const match = line.match(/\[(\d{2}):(\d{2}\.\d{2})\]\s*(.+)/);
@@ -752,18 +767,19 @@ export default function Player({ user }) {
fetchTypeahead(e.query);
}}
onChange={e => {
setRequestInput(e.target.value);
setRequestInput(e.target.value ?? '');
}}
onShow={() => {
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;
(items as HTMLElement).style.maxHeight = '200px';
(items as HTMLElement).style.overflowY = 'auto';
(items as HTMLElement).style.overscrollBehavior = 'contain';
const wheelHandler: EventListener = (e) => {
const wheelEvent = e as WheelEvent;
const delta = wheelEvent.deltaY;
const atTop = items.scrollTop === 0;
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
@@ -865,8 +881,8 @@ export default function Player({ user }) {
first={queuePage * queueRows}
totalRecords={queueTotalRecords}
onPage={(e) => {
setQueuePage(e.page);
fetchQueue(e.page, queueRows, queueSearch);
setQueuePage(e.page ?? 0);
fetchQueue(e.page ?? 0, queueRows, queueSearch);
}}
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries"

View File

@@ -5,22 +5,28 @@ import React, {
useEffect,
useState,
} from "react";
import type { ReactNode } from "react";
import { CssVarsProvider, CssBaseline } from "@mui/joy";
import type { EmotionCache } from "@emotion/react";
import { CacheProvider } from "@emotion/react";
import createCache from "@emotion/cache";
import { default as $ } from "jquery";
export function JoyUIRootIsland({ children }) {
const cache = React.useRef();
interface JoyUIRootIslandProps {
children: ReactNode;
}
export function JoyUIRootIsland({ children }: JoyUIRootIslandProps): React.ReactElement {
const cache = React.useRef<EmotionCache | null>(null);
if (!cache.current) {
cache.current = createCache({ key: "joy" });
}
React.useEffect(() => {
const current = cache.current;
return () => current.sheet.flush();
return () => current?.sheet.flush();
}, []);
return (
<CacheProvider value={cache.current}>
<CacheProvider value={cache.current!}>
<CssVarsProvider>{children}</CssVarsProvider>
</CacheProvider>
);

View File

@@ -1,7 +1,264 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react';
import type { AnimationItem } from 'lottie-web';
import { ProgressSpinner } from 'primereact/progressspinner';
import { authFetch } from '@/utils/authFetch';
// ============================================================================
// Type Definitions
// ============================================================================
interface DiscordUser {
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;
}
interface DiscordChannel {
id: string;
name: string;
type?: number;
guildId?: string;
guildName?: string;
guildIcon?: string;
parentId?: string;
position?: number;
topic?: string;
messageCount?: number;
threads?: DiscordChannel[];
}
interface DiscordEmoji {
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;
}
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;
}
interface DiscordReaction {
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 }>;
}
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 }>;
};
}
interface DiscordSticker {
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;
}
interface MemberGroup {
role?: DiscordRole;
members?: Array<{ id: string; color?: string | number }>;
}
interface MembersData {
groups?: MemberGroup[];
roles?: Map<string, DiscordRole> | Record<string, DiscordRole>;
}
interface DiscordGuild {
id: string;
name: string;
guildId?: string;
guildName?: string;
guildIcon?: string;
icon?: string;
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;
}
interface ParseOptions {
channelMap?: Map<string, DiscordChannel>;
usersMap?: Record<string, DiscordUser>;
rolesMap?: Map<string, DiscordRole> | Record<string, DiscordRole>;
emojiCache?: Record<string, DiscordEmoji>;
onChannelClick?: (channelId: string) => void;
}
interface MessageProps {
message: DiscordMessage;
isFirstInGroup: boolean;
showTimestamp: boolean;
previewCache: Map<string, LinkPreviewData>;
onPreviewLoad: (url: string, preview: LinkPreviewData) => void;
channelMap: Map<string, DiscordChannel>;
usersMap: Record<string, DiscordUser>;
emojiCache: Record<string, DiscordEmoji>;
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;
}
interface LinkPreviewProps {
url: string;
cachedPreview?: LinkPreviewData | null;
onPreviewLoad?: (url: string, preview: LinkPreviewData) => void;
}
interface LottieStickerProps {
data: unknown;
name: string;
}
interface ArchivedMessageResult {
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 };
users: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>;
loading?: boolean;
}
interface PollVoterPopupState {
x: number;
y: number;
answer: DiscordPollAnswer;
voters: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>;
}
interface ContextMenuState {
x: number;
y: number;
messageId: string;
content: string;
}
interface ChannelContextMenuState {
x: number;
y: number;
channel: DiscordChannel;
}
interface ImageModalState {
url: string;
alt?: string;
}
interface SearchResult {
id: string;
content: string;
author: DiscordUser;
timestamp: string;
channel_id?: string;
}
// ============================================================================
// Discord Message Type Constants
// https://discord.com/developers/docs/resources/channel#message-object-message-types
@@ -39,7 +296,11 @@ const MESSAGE_TYPE_GUILD_APPLICATION_PREMIUM_SUBSCRIPTION = 32;
const MESSAGE_TYPE_POLL_RESULT = 46;
// Image modal context for child components to trigger modal
const ImageModalContext = createContext(null);
interface ImageModalData {
url: string;
alt?: string;
}
const ImageModalContext = createContext<((data: ImageModalData) => void) | null>(null);
// Trusted domains that can be embedded directly without server-side proxy
const TRUSTED_DOMAINS = new Set([
@@ -79,7 +340,7 @@ const AUDIO_EXTENSIONS = /\.(mp3|wav|ogg|flac|m4a|aac)(\?.*)?$/i;
/**
* Check if URL is from a trusted domain
*/
function isTrustedDomain(url) {
function isTrustedDomain(url: string): boolean {
try {
const parsed = new URL(url);
return TRUSTED_DOMAINS.has(parsed.hostname);
@@ -91,7 +352,7 @@ function isTrustedDomain(url) {
/**
* Check if a URL is already a proxy URL (from our server)
*/
function isProxyUrl(url) {
function isProxyUrl(url: string | undefined | null): boolean {
if (!url) return false;
return url.startsWith('/api/image-proxy');
}
@@ -99,14 +360,14 @@ function isProxyUrl(url) {
/**
* Extract YouTube video ID
*/
function getYouTubeId(url) {
function getYouTubeId(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.hostname === 'youtu.be') {
return parsed.pathname.slice(1);
}
if (parsed.hostname.includes('youtube.com')) {
return parsed.searchParams.get('v') || parsed.pathname.split('/').pop();
return parsed.searchParams.get('v') || parsed.pathname.split('/').pop() || null;
}
} catch {
return null;
@@ -114,10 +375,20 @@ function getYouTubeId(url) {
return null;
}
/**
* Convert color (string or number) to CSS color string
*/
function toColorString(color: string | number | undefined): string | undefined {
if (color === undefined || color === null) return undefined;
if (typeof color === 'string') return color;
// Discord integer colors are decimal RGB values
return `#${color.toString(16).padStart(6, '0')}`;
}
/**
* Format file size
*/
function formatFileSize(bytes) {
function formatFileSize(bytes: number | undefined): string {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
@@ -132,11 +403,11 @@ function formatFileSize(bytes) {
/**
* Format Discord timestamp
*/
function formatTimestamp(timestamp, format = 'full') {
function formatTimestamp(timestamp: string | Date, format: 'full' | 'time' | 'short' = 'full'): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isYesterday = new Date(now - 86400000).toDateString() === date.toDateString();
const isYesterday = new Date(now.getTime() - 86400000).toDateString() === date.toDateString();
const time = date.toLocaleTimeString(undefined, {
hour: 'numeric',
@@ -145,6 +416,14 @@ function formatTimestamp(timestamp, format = 'full') {
});
if (format === 'time') return time;
if (format === 'short') {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
@@ -159,7 +438,7 @@ function formatTimestamp(timestamp, format = 'full') {
/**
* Format date for divider
*/
function formatDateDivider(timestamp) {
function formatDateDivider(timestamp: string | Date): string {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
@@ -173,8 +452,8 @@ function formatDateDivider(timestamp) {
* Decode HTML entities safely. Uses the DOM if available (client-side), otherwise
* falls back to a robust regex for SSR environments.
*/
function decodeHtmlEntities(str) {
if (!str) return str;
function decodeHtmlEntities(str: string | null | undefined): string {
if (!str) return '';
try {
if (typeof document !== 'undefined') {
const tx = document.createElement('textarea');
@@ -205,7 +484,7 @@ function decodeHtmlEntities(str) {
*
* Returns { originalAuthor, originalTimestamp, originalContent, topic } or null if not parseable
*/
function parseArchivedMessage(content, depth = 0) {
function parseArchivedMessage(content: string | null | undefined, depth: number = 0): ArchivedMessageResult | null {
if (!content || depth > 3) return null; // Prevent infinite recursion
// The format is:
@@ -384,7 +663,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, usersMap, members) {
function resolveArchivedUser(archivedUsername: string, usersMap?: Record<string, DiscordUser>, members?: MembersData): DiscordUser {
// First check hardcoded archive users
if (ARCHIVE_USERS[archivedUsername]) {
const archivedUser = ARCHIVE_USERS[archivedUsername];
@@ -450,7 +729,7 @@ function resolveArchivedUser(archivedUsername, usersMap, members) {
* @param {Object} options.emojiCache - Map of emoji IDs to cached emoji objects { url, animated }
* @param {Function} options.onChannelClick - Callback when channel is clicked
*/
function parseDiscordMarkdown(text, options = {}) {
function parseDiscordMarkdown(text: string | null | undefined, options: ParseOptions = {}): string {
if (!text) return '';
try {
@@ -632,14 +911,14 @@ function parseDiscordMarkdown(text, options = {}) {
// Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
parsed = parsed.replace(/&lt;@&amp;(\d+)&gt;/g, (_, roleId) => {
try {
let role = null;
let role: DiscordRole | null | undefined = null;
if (rolesMap) {
if (typeof rolesMap.get === 'function') {
role = rolesMap.get(roleId);
} else if (rolesMap[roleId]) {
role = rolesMap[roleId];
} else if (rolesMap[String(roleId)]) {
role = rolesMap[String(roleId)];
if (typeof (rolesMap as Map<string, DiscordRole>).get === 'function') {
role = (rolesMap as Map<string, DiscordRole>).get(roleId);
} else if ((rolesMap as Record<string, DiscordRole>)[roleId]) {
role = (rolesMap as Record<string, DiscordRole>)[roleId];
} else if ((rolesMap as Record<string, DiscordRole>)[String(roleId)]) {
role = (rolesMap as Record<string, DiscordRole>)[String(roleId)];
}
}
const roleName = role?.name || 'role';
@@ -704,7 +983,7 @@ function parseDiscordMarkdown(text, options = {}) {
/**
* Extract URLs from text
*/
function extractUrls(text) {
function extractUrls(text: string | null | undefined): string[] {
if (!text) return [];
const urlRegex = /(https?:\/\/[^\s<]+)/g;
const matches = text.match(urlRegex);
@@ -715,8 +994,8 @@ function extractUrls(text) {
// Link Preview Component
// ============================================================================
const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoad }) {
const [preview, setPreview] = useState(cachedPreview || null);
const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoad }: LinkPreviewProps) {
const [preview, setPreview] = useState<LinkPreviewData | null>(cachedPreview || null);
const [loading, setLoading] = useState(!cachedPreview);
const [error, setError] = useState(false);
const [imageError, setImageError] = useState(false);
@@ -873,7 +1152,7 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
alt="Linked image"
className="discord-attachment-image"
loading="lazy"
onError={(e) => e.target.style.display = 'none'}
onError={(e) => (e.target as HTMLElement).style.display = 'none'}
/>
</div>
);
@@ -938,9 +1217,9 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
// Lottie Sticker Component
// ============================================================================
const LottieSticker = memo(function LottieSticker({ data, name }) {
const containerRef = useRef(null);
const animationRef = useRef(null);
const LottieSticker = memo(function LottieSticker({ data, name }: LottieStickerProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const animationRef = useRef<AnimationItem | null>(null);
useEffect(() => {
if (!containerRef.current || !data) return;
@@ -953,7 +1232,7 @@ const LottieSticker = memo(function LottieSticker({ data, name }) {
}
animationRef.current = lottie.default.loadAnimation({
container: containerRef.current,
container: containerRef.current!,
renderer: 'svg',
loop: true,
autoplay: true,
@@ -992,7 +1271,7 @@ const LottieSticker = memo(function LottieSticker({ data, name }) {
// Attachment Component
// ============================================================================
const Attachment = memo(function Attachment({ attachment }) {
const Attachment = memo(function Attachment({ attachment }: AttachmentProps) {
const { filename, url, size, content_type, width, height } = attachment;
const openImageModal = useContext(ImageModalContext);
@@ -1098,7 +1377,7 @@ const DiscordMessage = memo(function DiscordMessage({
onContextMenu,
isSearchResult,
onJumpToMessage
}) {
}: MessageProps) {
const {
id,
author,
@@ -1252,7 +1531,7 @@ const DiscordMessage = memo(function DiscordMessage({
</div>
<div className="discord-poll-result-content">
<div className="discord-poll-result-header">
<span className="discord-username" style={pollAuthor?.color ? { color: pollAuthor.color } : undefined}>
<span className="discord-username" style={pollAuthor?.color ? { color: toColorString(pollAuthor.color) } : undefined}>
{pollAuthor?.displayName || pollAuthor?.username || 'Unknown'}
</span>
<span className="discord-poll-title">s poll <strong>{pollTitle}</strong> has closed</span>
@@ -1380,7 +1659,7 @@ const DiscordMessage = memo(function DiscordMessage({
{getSystemIcon()}
</div>
<span className="discord-system-content">
<span className="discord-username" style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}>
<span className="discord-username" style={displayAuthor?.color ? { color: toColorString(displayAuthor.color) } : undefined}>
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'}
</span>
{' '}
@@ -1478,7 +1757,7 @@ const DiscordMessage = memo(function DiscordMessage({
)}
<span
className={`discord-username ${(displayAuthor?.bot || displayAuthor?.isWebhook) ? 'discord-bot-user' : ''}`}
style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}
style={displayAuthor?.color ? { color: toColorString(displayAuthor.color) } : undefined}
>
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown User'}
</span>
@@ -1566,35 +1845,35 @@ const DiscordMessage = memo(function DiscordMessage({
</div>
<div className="discord-poll-answers">
{poll.answers.map((answer) => {
const percentage = poll.totalVotes > 0
? Math.round((answer.voteCount / poll.totalVotes) * 100)
const percentage = (poll.totalVotes ?? 0) > 0
? Math.round(((answer.voteCount ?? 0) / (poll.totalVotes ?? 1)) * 100)
: 0;
return (
<div key={answer.id} className="discord-poll-answer" onClick={(e) => onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}>
<div key={answer.id ?? answer.answer_id} className="discord-poll-answer" onClick={(e) => onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}>
<div
className="discord-poll-answer-bar"
style={{ width: `${percentage}%` }}
/>
<div className="discord-poll-answer-content">
{answer.emoji && (
answer.emoji.id ? (
{(answer.emoji ?? answer.poll_media?.emoji) && (
(answer.emoji ?? answer.poll_media?.emoji)?.id ? (
<img
src={emojiCache[answer.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
alt={answer.emoji.name}
src={emojiCache[(answer.emoji ?? answer.poll_media?.emoji)!.id!]?.url || `https://cdn.discordapp.com/emojis/${(answer.emoji ?? answer.poll_media?.emoji)!.id}.${(answer.emoji ?? answer.poll_media?.emoji)!.animated ? 'gif' : 'png'}`}
alt={(answer.emoji ?? answer.poll_media?.emoji)?.name}
className="discord-poll-answer-emoji"
/>
) : (
<span className="discord-poll-answer-emoji">{answer.emoji.name}</span>
<span className="discord-poll-answer-emoji">{(answer.emoji ?? answer.poll_media?.emoji)?.name}</span>
)
)}
<span className="discord-poll-answer-text">{answer.text}</span>
<span className="discord-poll-answer-text">{answer.text ?? answer.poll_media?.text}</span>
<span className="discord-poll-answer-votes">
{answer.voteCount} ({percentage}%)
{answer.voteCount ?? 0} ({percentage}%)
</span>
</div>
{answer.voters?.length > 0 && (
{(answer.voters?.length ?? 0) > 0 && (
<div className="discord-poll-voters">
{answer.voters.slice(0, 10).map((voter) => (
{answer.voters!.slice(0, 10).map((voter) => (
<div
key={voter.id}
className="discord-poll-voter"
@@ -1608,9 +1887,9 @@ const DiscordMessage = memo(function DiscordMessage({
)}
</div>
))}
{answer.voters.length > 10 && (
{(answer.voters?.length ?? 0) > 10 && (
<span className="discord-poll-voters-more">
+{answer.voters.length - 10}
+{(answer.voters?.length ?? 0) - 10}
</span>
)}
</div>
@@ -1675,9 +1954,9 @@ const DiscordMessage = memo(function DiscordMessage({
/>
)}
{/* Embed fields */}
{embed.fields?.length > 0 && (
{(embed.fields?.length ?? 0) > 0 && (
<div className="discord-embed-fields">
{embed.fields.map((field, fieldIdx) => (
{embed.fields!.map((field, fieldIdx) => (
<div
key={fieldIdx}
className={`discord-embed-field ${field.inline ? 'inline' : ''}`}
@@ -1703,7 +1982,7 @@ const DiscordMessage = memo(function DiscordMessage({
src={embed.thumbnail.url}
alt=""
className="discord-embed-thumbnail"
onError={(e) => e.target.style.display = 'none'}
onError={(e) => (e.target as HTMLElement).style.display = 'none'}
/>
)}
</div>
@@ -1814,18 +2093,18 @@ const DiscordMessage = memo(function DiscordMessage({
// ============================================================================
export default function DiscordLogs() {
const messagesContainerRef = useRef(null);
const pendingTargetMessageRef = useRef(null); // For deep-linking - used in fetch, cleared after scroll
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const pendingTargetMessageRef = useRef<string | null>(null); // For deep-linking - used in fetch, cleared after scroll
const scrollToBottomRef = useRef(false); // Flag to trigger scroll to bottom after initial load
const [channels, setChannels] = useState([]);
const [channelsByGuild, setChannelsByGuild] = useState({});
const [guilds, setGuilds] = useState([]);
const [selectedGuild, setSelectedGuild] = useState(null);
const [selectedChannel, setSelectedChannel] = useState(null);
const [messages, setMessages] = useState([]);
const [usersMap, setUsersMap] = useState({}); // Map of user ID -> { displayName, username, color }
const [emojiCache, setEmojiCache] = useState({}); // Map of emoji ID -> { url, animated }
const [members, setMembers] = useState(null); // { groups: [...], roles: [...] }
const [channels, setChannels] = useState<DiscordChannel[]>([]);
const [channelsByGuild, setChannelsByGuild] = useState<Record<string, DiscordChannel[]>>({});
const [guilds, setGuilds] = useState<DiscordGuild[]>([]);
const [selectedGuild, setSelectedGuild] = useState<DiscordGuild | null>(null);
const [selectedChannel, setSelectedChannel] = useState<DiscordChannel | null>(null);
const [messages, setMessages] = useState<DiscordMessage[]>([]);
const [usersMap, setUsersMap] = useState<Record<string, DiscordUser>>({}); // Map of user ID -> { displayName, username, color }
const [emojiCache, setEmojiCache] = useState<Record<string, DiscordEmoji>>({}); // Map of emoji ID -> { url, animated }
const [members, setMembers] = useState<MembersData | null>(null); // { groups: [...], roles: [...] }
const [loadingMembers, setLoadingMembers] = useState(false);
const [memberListExpanded, setMemberListExpanded] = useState(false); // Default to collapsed
const [linkCopied, setLinkCopied] = useState(false);
@@ -1835,23 +2114,23 @@ export default function DiscordLogs() {
const [hasMoreMessages, setHasMoreMessages] = useState(true);
const [hasNewerMessages, setHasNewerMessages] = useState(false); // When viewing historical messages
const [loadingNewer, setLoadingNewer] = useState(false);
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState(null); // Server-side search results
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null); // Server-side search results
const [searchLoading, setSearchLoading] = useState(false);
const [previewCache, setPreviewCache] = useState({});
const [imageModal, setImageModal] = useState(null); // { url, alt }
const [reactionPopup, setReactionPopup] = useState(null); // { x, y, emoji, users, loading }
const [pollVoterPopup, setPollVoterPopup] = useState(null); // { x, y, answer, voters }
const [contextMenu, setContextMenu] = useState(null); // { x, y, messageId, content }
const [channelContextMenu, setChannelContextMenu] = useState(null); // { x, y, channel }
const [previewCache, setPreviewCache] = useState<Record<string, LinkPreviewData>>({});
const [imageModal, setImageModal] = useState<ImageModalState | null>(null); // { url, alt }
const [reactionPopup, setReactionPopup] = useState<ReactionPopupState | null>(null); // { x, y, emoji, users, loading }
const [pollVoterPopup, setPollVoterPopup] = useState<PollVoterPopupState | null>(null); // { x, y, answer, voters }
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); // { x, y, messageId, content }
const [channelContextMenu, setChannelContextMenu] = useState<ChannelContextMenuState | null>(null); // { x, y, channel }
const [topicExpanded, setTopicExpanded] = useState(false); // Show full channel topic
const [refetchCounter, setRefetchCounter] = useState(0); // Counter to force re-fetch messages
const lastPollTimeRef = useRef(new Date().toISOString()); // Track last poll time for edit detection
// Collapsible categories in sidebar
const [collapsedCategories, setCollapsedCategories] = useState({});
const handleCategoryToggle = useCallback((catName) => {
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
const handleCategoryToggle = useCallback((catName: string) => {
setCollapsedCategories(prev => ({ ...prev, [catName]: !prev[catName] }));
}, []);

View File

@@ -1,15 +1,23 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { API_URL } from '../config.js';
import { authFetch } from '../utils/authFetch.js';
import { API_URL } from '../config.ts';
import { authFetch } from '../utils/authFetch.ts';
import Wheel from '@uiw/react-color-wheel';
interface LightingState {
power: string;
red: number;
blue: number;
green: number;
brightness: number;
}
export default function Lighting() {
const [state, setState] = useState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
const [state, setState] = useState<LightingState>({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [pending, setPending] = useState(false);
const debounceRef = useRef(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
authFetch(`${API_URL}/lighting/state`)
@@ -127,22 +135,22 @@ export default function Lighting() {
overflow: 'hidden'
}}>
<Wheel
hsva={{ h: 0, s: 0, v: 100, a: 1 }}
color={{ h: 0, s: 0, v: 100, a: 1 }}
onChange={(color) => {
const { h, s, v } = color.hsva;
// Convert percentages to decimals and handle undefined v
const rgb = hsvToRgb(
const { red, green, blue } = hsvToRgb(
h / 360, // hue: 0-360 -> 0-1
s / 100, // saturation: 0-100 -> 0-1
(v ?? 100) / 100 // value: 0-100 -> 0-1, default to 1 if undefined
);
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', rgb);
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', { red, green, blue });
// Auto power on when changing color
updateLighting({
...state,
red: rgb.red,
green: rgb.green,
blue: rgb.blue,
red,
green,
blue,
power: state.power === 'off' ? 'on' : state.power
});
}}

View File

@@ -1,25 +1,31 @@
import React, { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, type FormEvent } from "react";
import Button from "@mui/joy/Button";
import { toast } from "react-toastify";
import { API_URL } from "@/config";
function getCookie(name) {
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return decodeURIComponent(parts.pop().split(';').shift());
if (parts.length === 2) return decodeURIComponent(parts.pop()!.split(';').shift()!);
return null;
}
function clearCookie(name) {
function clearCookie(name: string): void {
document.cookie = `${name}=; Max-Age=0; path=/;`;
}
export default function LoginPage({ loggedIn = false, accessDenied = false, requiredRoles = [] }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
interface LoginPageProps {
loggedIn?: boolean;
accessDenied?: boolean;
requiredRoles?: string[] | string;
}
const passwordRef = useRef();
export default function LoginPage({ loggedIn = false, accessDenied = false, requiredRoles = [] }: LoginPageProps): React.ReactElement {
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (passwordRef.current && password === "") {
@@ -27,7 +33,7 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
}
}, []);
async function handleSubmit(e) {
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setLoading(true);

View File

@@ -7,7 +7,8 @@ import React, {
useState,
useCallback,
} from "react";
import { toast } from 'react-toastify';
import type { RefObject } from "react";
import { toast, type Id } from 'react-toastify';
import DOMPurify from 'isomorphic-dompurify';
import Box from '@mui/joy/Box';
import Button from "@mui/joy/Button";
@@ -21,6 +22,49 @@ import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
import { API_URL } from '../config';
// Type definitions
interface YouTubeVideo {
video_id: string;
title: string;
channel: string;
duration: string;
thumbnail?: string | null;
}
interface LyricsResult {
lyrics: string;
artist: string;
song: string;
source?: string;
src?: string;
[key: string]: unknown;
}
interface UICheckboxProps {
id: string;
label: string;
value?: string;
onToggle?: (source: string) => void;
}
interface UICheckboxHandle {
setChecked: (val: boolean) => void;
checked: boolean;
}
interface Suggestion {
label: string;
value: string;
artist?: string;
song?: string;
}
interface LyricSearchInputFieldProps {
id: string;
placeholder: string;
setShowLyrics: (show: boolean) => void;
}
// Sanitize HTML from external sources to prevent XSS
const sanitizeHtml = (html) => {
if (!html) return '';
@@ -65,22 +109,22 @@ export default function LyricSearch() {
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
export function LyricSearchInputField({ id, placeholder, setShowLyrics }: LyricSearchInputFieldProps) {
const [value, setValue] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [excludedSources, setExcludedSources] = useState([]);
const [lyricsResult, setLyricsResult] = useState(null);
const [excludedSources, setExcludedSources] = useState<string[]>([]);
const [lyricsResult, setLyricsResult] = useState<LyricsResult | null>(null);
const [textSize, setTextSize] = useState("normal");
const [inputStatus, setInputStatus] = useState("hint");
const [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration }
const [youtubeVideo, setYoutubeVideo] = useState<YouTubeVideo | null>(null);
const [youtubeLoading, setYoutubeLoading] = useState(false);
const [highlightedVerse, setHighlightedVerse] = useState(null);
const [highlightedVerse, setHighlightedVerse] = useState<string | null>(null);
const [isLyricsVisible, setIsLyricsVisible] = useState(false);
const searchToastRef = useRef(null);
const autoCompleteRef = useRef(null);
const autoCompleteInputRef = useRef(null);
const searchButtonRef = useRef(null);
const searchToastRef = useRef<Id | null>(null);
const autoCompleteRef = useRef<any>(null);
const autoCompleteInputRef = useRef<HTMLInputElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement | null>(null);
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
const statusLabels = {
hint: "Format: Artist - Song",
@@ -180,7 +224,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}, []);
// Toggle exclusion state for checkboxes
const toggleExclusion = (source) => {
const toggleExclusion = (source: string) => {
const lower = source.toLowerCase();
setExcludedSources((prev) =>
prev.includes(lower)
@@ -197,12 +241,13 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const items = panel?.querySelector(".p-autocomplete-items");
if (items) {
items.style.maxHeight = "200px";
items.style.overflowY = "auto";
items.style.overscrollBehavior = "contain";
(items as HTMLElement).style.maxHeight = "200px";
(items as HTMLElement).style.overflowY = "auto";
(items as HTMLElement).style.overscrollBehavior = "contain";
const wheelHandler = (e) => {
const delta = e.deltaY;
const wheelHandler: EventListener = (e) => {
const wheelEvent = e as WheelEvent;
const delta = wheelEvent.deltaY;
const atTop = items.scrollTop === 0;
const atBottom =
items.scrollTop + items.clientHeight >= items.scrollHeight;
@@ -220,7 +265,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}, 0);
};
const evaluateSearchValue = useCallback((searchValue, shouldUpdate = true) => {
const evaluateSearchValue = useCallback((searchValue: string, shouldUpdate = true) => {
const trimmed = searchValue?.trim() || "";
let status = "hint";
@@ -269,10 +314,10 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
if (panel) {
panel.style.display = 'none';
panel.style.opacity = '0';
panel.style.visibility = 'hidden';
panel.style.pointerEvents = 'none';
(panel as HTMLElement).style.display = 'none';
(panel as HTMLElement).style.opacity = '0';
(panel as HTMLElement).style.visibility = 'hidden';
(panel as HTMLElement).style.pointerEvents = 'none';
}
}, 10);
}, []);
@@ -355,9 +400,9 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
autoClose: 2500,
toastId: "lyrics-success-toast",
});
} catch (error) {
} catch (error: unknown) {
dismissSearchToast();
toast.error(error.message, {
toast.error((error as Error)?.message || 'An error occurred', {
icon: () => "😕",
autoClose: 5000,
toastId: "lyrics-error-toast",
@@ -371,7 +416,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}
};
const handleKeyDown = (e) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
hideAutocompletePanel();
@@ -595,7 +640,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}
export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
export const UICheckbox = forwardRef<UICheckboxHandle, UICheckboxProps>(function UICheckbox(props, ref) {
const [checked, setChecked] = useState(false);
useImperativeHandle(ref, () => ({
@@ -604,7 +649,7 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
}));
const verifyExclusions = () => {
const checkboxes = document.querySelectorAll(".exclude-chip");
const checkboxes = document.querySelectorAll(".exclude-chip") as NodeListOf<HTMLElement>;
const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
if (checkedCount === 3) {
@@ -643,7 +688,12 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
});
export function LyricResultBox(opts = {}) {
interface LyricResultBoxOpts {
theme?: string;
}
export function LyricResultBox(opts: LyricResultBoxOpts = {}) {
const theme = opts.theme || 'dark';
return (
<div>
<Box className={`lyrics-card lyrics-card-${theme}`} sx={{ p: 2 }}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useEffect, useState, useRef, useCallback, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { ProgressSpinner } from 'primereact/progressspinner';
import { Dialog } from 'primereact/dialog';
import { Image } from 'primereact/image';
@@ -10,27 +10,40 @@ import { API_URL } from '../config';
const MEME_API_URL = `${API_URL}/memes/list_memes`;
const BASE_IMAGE_URL = "https://codey.lol/meme";
const Memes = () => {
const [images, setImages] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [selectedImage, setSelectedImage] = useState(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [imageLoading, setImageLoading] = useState({});
const observerRef = useRef();
const touchStartRef = useRef(null);
const touchEndRef = useRef(null);
const theme = document.documentElement.getAttribute("data-theme")
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
interface MemeImage {
id: string;
url: string;
filename?: string;
timestamp?: string;
[key: string]: unknown;
}
const prefetchImage = useCallback((img) => {
interface MemeCache {
pagesLoaded: Set<number>;
items: MemeImage[];
}
const Memes: React.FC = () => {
const [images, setImages] = useState<MemeImage[]>([]);
const [page, setPage] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true);
const [selectedImage, setSelectedImage] = useState<MemeImage | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [imageLoading, setImageLoading] = useState<Record<string, boolean>>({});
const observerRef = useRef<IntersectionObserver | null>(null);
const touchStartRef = useRef<number | null>(null);
const touchEndRef = useRef<number | null>(null);
const theme = document.documentElement.getAttribute("data-theme")
const cacheRef = useRef<MemeCache>({ pagesLoaded: new Set(), items: [] });
const prefetchImage = useCallback((img: MemeImage | undefined): void => {
if (!img || typeof window === "undefined") return;
const preload = new window.Image();
preload.src = img.url;
}, []);
const handleNavigate = useCallback((direction) => {
const handleNavigate = useCallback((direction: number): void => {
setSelectedIndex((prev) => {
const newIndex = prev + direction;
if (newIndex < 0 || newIndex >= images.length) {
@@ -47,7 +60,7 @@ const Memes = () => {
if (!selectedImage) {
return;
}
const handleKeyDown = (event) => {
const handleKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
handleNavigate(-1);
@@ -79,7 +92,7 @@ const Memes = () => {
}
}
}, []);
const persistCache = useCallback((nextPage) => {
const persistCache = useCallback((nextPage: number): void => {
sessionStorage.setItem("memes-cache", JSON.stringify({
items: cacheRef.current.items,
pagesLoaded: Array.from(cacheRef.current.pagesLoaded),
@@ -88,7 +101,7 @@ const Memes = () => {
}));
}, [hasMore]);
const loadImages = async (pageNum, attempt = 0) => {
const loadImages = async (pageNum: number, attempt: number = 0): Promise<void> => {
if (loading || !hasMore || cacheRef.current.pagesLoaded.has(pageNum)) return;
setLoading(true);
try {

View File

@@ -0,0 +1,19 @@
import React from "react";
import Alert from "@mui/joy/Alert";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
export default function RadioBanner(): React.ReactElement {
return (
<div>
<h2 style={{ textAlign: 'center', marginBottom: '1rem' }}>Radio</h2>
<Alert
variant="soft"
color="danger"
sx={{ fontSize: "1.0rem", textAlign: "center", mt: 4, mb: 4, px: 3, py: 2 }}
startDecorator={<ErrorOutlineIcon fontSize="large" />}
>
Maintenance in progress. Please check back soon!
</Alert>
</div>
);
}

View File

@@ -1,12 +1,16 @@
import { useState, useEffect } from "react";
import { API_URL } from "../config";
export default function RandomMsg() {
const [randomMsg, setRandomMsg] = useState("");
const [responseTime, setResponseTime] = useState(null);
const [showResponseTime, setShowResponseTime] = useState(false);
interface RandomMsgResponse {
msg?: string;
}
const getRandomMsg = async () => {
export default function RandomMsg(): React.ReactElement {
const [randomMsg, setRandomMsg] = useState<string>("");
const [responseTime, setResponseTime] = useState<number | null>(null);
const [showResponseTime, setShowResponseTime] = useState<boolean>(false);
const getRandomMsg = async (): Promise<void> => {
try {
const start = performance.now();
const response = await fetch(`${API_URL}/randmsg`, {

View File

@@ -4,10 +4,10 @@
* This is a minimal version that avoids importing from shared modules
* that would pull in heavy CSS (AppLayout, Components, etc.)
*/
import React, { Suspense, lazy } from 'react';
import React, { Suspense, lazy, ReactNode } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import { CssVarsProvider } from "@mui/joy";
import { CacheProvider } from "@emotion/react";
import { CacheProvider, EmotionCache } from "@emotion/react";
import createCache from "@emotion/cache";
import { PrimeReactProvider } from "primereact/api";
@@ -17,9 +17,13 @@ import 'primereact/resources/primereact.min.css';
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
interface MinimalJoyWrapperProps {
children: ReactNode;
}
// Inline minimal JoyUI wrapper to avoid importing Components.jsx
function MinimalJoyWrapper({ children }) {
const cache = React.useRef();
function MinimalJoyWrapper({ children }: MinimalJoyWrapperProps): React.ReactElement {
const cache = React.useRef<EmotionCache | null>(null);
if (!cache.current) {
cache.current = createCache({ key: "joy-sub" });
}
@@ -30,7 +34,12 @@ function MinimalJoyWrapper({ children }) {
);
}
export default function SubsiteRoot({ child, ...props }) {
interface SubsiteRootProps {
child: string;
[key: string]: unknown;
}
export default function SubsiteRoot({ child, ...props }: SubsiteRootProps): React.ReactElement {
if (typeof window !== 'undefined') {
window.toast = toast;
}

View File

@@ -1,7 +1,17 @@
import React from "react";
export default function BreadcrumbNav({ currentPage }) {
const pages = [
interface BreadcrumbNavProps {
currentPage: string;
}
interface PageLink {
key: string;
label: string;
href: string;
}
export default function BreadcrumbNav({ currentPage }: BreadcrumbNavProps): React.ReactElement {
const pages: PageLink[] = [
{ key: "request", label: "Request Media", href: "/TRip" },
{ key: "management", label: "Manage Requests", href: "/TRip/requests" },
];

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo } from "react";
import type { RefObject } from "react";
import type { Id } from "react-toastify";
import { toast } from "react-toastify";
import { Button } from "@mui/joy";
import { Accordion, AccordionTab } from "primereact/accordion";
@@ -8,50 +10,100 @@ import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL, ENVIRONMENT } from "@/config";
import "./RequestManagement.css";
interface Artist {
id: string | number;
name: string;
artist?: string;
}
interface Album {
id: string | number;
title: string;
album?: string;
release_date?: string;
cover_image?: string;
cover_small?: string;
tracks?: Track[];
}
interface FetchTracksSequentiallyFn {
(): Promise<void>;
lastCall?: number;
}
interface Track {
id: string | number;
title: string;
artist?: string;
duration?: number | string;
album_id?: string | number;
track_number?: number;
version?: string;
}
interface DiskSpaceInfo {
total?: number;
used?: number;
available?: number;
percent?: number;
usedPercent?: number;
availableFormatted?: string;
}
interface AudioProgress {
current: number;
duration: number;
}
interface SearchArtistsFn {
(e: { query: string }): void;
lastCall?: number;
}
export default function MediaRequestForm() {
const [type, setType] = useState("artist");
const [selectedArtist, setSelectedArtist] = useState(null);
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(null);
const [artistInput, setArtistInput] = useState("");
const [albumInput, setAlbumInput] = useState("");
const [trackInput, setTrackInput] = useState("");
const [quality, setQuality] = useState("FLAC"); // default FLAC
const [selectedItem, setSelectedItem] = useState(null);
const [albums, setAlbums] = useState([]);
const [tracksByAlbum, setTracksByAlbum] = useState({});
const [selectedTracks, setSelectedTracks] = useState({});
const [artistSuggestions, setArtistSuggestions] = useState([]);
const [selectedItem, setSelectedItem] = useState<Artist | Album | Track | null>(null);
const [albums, setAlbums] = useState<Album[]>([]);
const [tracksByAlbum, setTracksByAlbum] = useState<Record<string | number, Track[]>>({});
const [selectedTracks, setSelectedTracks] = useState<Record<string | number, string[] | null>>({});
const [artistSuggestions, setArtistSuggestions] = useState<Artist[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [loadingAlbumId, setLoadingAlbumId] = useState(null);
const [expandedAlbums, setExpandedAlbums] = useState([]);
const [loadingAlbumId, setLoadingAlbumId] = useState<string | number | null>(null);
const [expandedAlbums, setExpandedAlbums] = useState<number[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [currentTrackId, setCurrentTrackId] = useState(null);
const [currentTrackId, setCurrentTrackId] = useState<string | number | null>(null);
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
const [audioLoadingTrackId, setAudioLoadingTrackId] = useState(null);
const [playbackQueue, setPlaybackQueue] = useState([]);
const [queueIndex, setQueueIndex] = useState(null);
const [queueAlbumId, setQueueAlbumId] = useState(null);
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null);
const [shuffleAlbums, setShuffleAlbums] = useState({});
const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 });
const [diskSpace, setDiskSpace] = useState(null);
const [audioLoadingTrackId, setAudioLoadingTrackId] = useState<string | number | null>(null);
const [playbackQueue, setPlaybackQueue] = useState<Track[]>([]);
const [queueIndex, setQueueIndex] = useState<number | null>(null);
const [queueAlbumId, setQueueAlbumId] = useState<string | number | null>(null);
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState<string | number | null>(null);
const [shuffleAlbums, setShuffleAlbums] = useState<Record<string | number, boolean>>({});
const [audioProgress, setAudioProgress] = useState<AudioProgress>({ current: 0, duration: 0 });
const [diskSpace, setDiskSpace] = useState<DiskSpaceInfo | null>(null);
const debounceTimeout = useRef(null);
const autoCompleteRef = useRef(null);
const metadataFetchToastId = useRef(null);
const audioRef = useRef(null);
const audioSourcesRef = useRef({});
const pendingTrackFetchesRef = useRef({});
const playbackQueueRef = useRef([]);
const queueIndexRef = useRef(null);
const queueAlbumIdRef = useRef(null);
const albumHeaderRefs = useRef({});
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoCompleteRef = useRef<any>(null);
const metadataFetchToastId = useRef<Id | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioSourcesRef = useRef<Record<string | number, string>>({});
const pendingTrackFetchesRef = useRef<Record<string | number, Promise<string>>>({});
const playbackQueueRef = useRef<Track[]>([]);
const queueIndexRef = useRef<number | null>(null);
const queueAlbumIdRef = useRef<string | number | null>(null);
const albumHeaderRefs = useRef<Record<string | number, HTMLElement | null>>({});
const suppressHashRef = useRef(false);
const lastUrlRef = useRef("");
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
const sanitizeFilename = (text) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
const formatTime = (seconds) => {
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
const sanitizeFilename = (text: string) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
const formatTime = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60)
@@ -130,7 +182,7 @@ export default function MediaRequestForm() {
</svg>
);
const ShuffleIcon = ({ active }) => (
const ShuffleIcon = ({ active }: { active: boolean }) => (
<svg
viewBox="0 0 24 24"
role="presentation"
@@ -144,7 +196,7 @@ export default function MediaRequestForm() {
</svg>
);
const shuffleArray = (arr) => {
const shuffleArray = <T,>(arr: T[]): T[] => {
const clone = [...arr];
for (let i = clone.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -153,7 +205,7 @@ export default function MediaRequestForm() {
return clone;
};
const ensureAlbumExpanded = (albumIndex) => {
const ensureAlbumExpanded = (albumIndex: number) => {
if (typeof albumIndex !== "number") return;
let albumAdded = false;
setExpandedAlbums((prev) => {
@@ -188,7 +240,7 @@ export default function MediaRequestForm() {
setAudioProgress({ current: 0, duration: 0 });
};
const getTrackSource = async (trackId) => {
const getTrackSource = async (trackId: string | number): Promise<string> => {
if (audioSourcesRef.current[trackId]) {
return audioSourcesRef.current[trackId];
}
@@ -230,8 +282,12 @@ export default function MediaRequestForm() {
return pendingTrackFetchesRef.current[trackId];
};
const prefetchTrack = async (track) => {
if (!track || audioSourcesRef.current[track.id] || pendingTrackFetchesRef.current[track.id]) {
const prefetchTrack = async (track: Track | null) => {
if (!track || audioSourcesRef.current[track.id]) {
return;
}
// Check if already being fetched (returns a truthy promise)
if (track.id in pendingTrackFetchesRef.current) {
return;
}
try {
@@ -241,7 +297,7 @@ export default function MediaRequestForm() {
}
};
const playTrack = async (track, { fromQueue = false } = {}) => {
const playTrack = async (track: Track, { fromQueue = false } = {}) => {
const audio = audioRef.current;
if (!audio || !track) return;
@@ -272,7 +328,7 @@ export default function MediaRequestForm() {
};
// Fetch artist suggestions for autocomplete
const searchArtists = (e) => {
const searchArtists: SearchArtistsFn = (e) => {
const query = e.query.trim();
if (!query) {
setArtistSuggestions([]);
@@ -307,14 +363,14 @@ export default function MediaRequestForm() {
};
const truncate = (text, maxLen) =>
const truncate = (text: string, maxLen: number) =>
maxLen <= 3
? text.slice(0, maxLen)
: text.length <= maxLen
? text
: text.slice(0, maxLen - 3) + '...';
const selectArtist = (artist) => {
const selectArtist = (artist: Artist) => {
// artist may be a grouped item or an alternate object
const value = artist.artist || artist.name || "";
setSelectedArtist(artist);
@@ -334,7 +390,7 @@ export default function MediaRequestForm() {
// If panel still exists, hide it via style (safer than removing the node)
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
const panel = document.querySelector('.p-autocomplete-panel') as HTMLElement | null;
if (panel) {
panel.style.display = 'none';
panel.style.opacity = '0';
@@ -344,7 +400,7 @@ export default function MediaRequestForm() {
}, 10);
// blur the input element if present
const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input');
const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input') as HTMLInputElement | null;
if (inputEl && typeof inputEl.blur === 'function') inputEl.blur();
} catch (innerErr) {
// Ignore inner errors
@@ -357,11 +413,11 @@ export default function MediaRequestForm() {
const totalAlbums = albums.length;
const totalTracks = useMemo(() =>
Object.values(tracksByAlbum).reduce((sum, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
Object.values(tracksByAlbum).reduce((sum: number, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
[tracksByAlbum]
);
const artistItemTemplate = (artist) => {
const artistItemTemplate = (artist: Artist & { alternatives?: Artist[]; alternates?: Artist[] }) => {
if (!artist) return null;
const alts = artist.alternatives || artist.alternates || [];
@@ -401,13 +457,13 @@ export default function MediaRequestForm() {
// Handle autocomplete input changes (typing/selecting)
const handleArtistChange = (e) => {
const handleArtistChange = (e: { value: string | Artist }) => {
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);
setArtistInput(e.value.artist || e.value.name || "");
} else {
setArtistInput("");
setSelectedArtist(null);
@@ -446,7 +502,7 @@ export default function MediaRequestForm() {
return;
}
setSelectedItem(selectedArtist.artist);
setSelectedItem({ ...selectedArtist, name: selectedArtist.artist || selectedArtist.name || "" });
try {
const res = await authFetch(
@@ -483,7 +539,7 @@ export default function MediaRequestForm() {
setIsSearching(false);
return;
}
setSelectedItem(`${artistInput} - ${albumInput}`);
setSelectedItem({ id: 0, name: `${artistInput} - ${albumInput}` });
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
@@ -493,7 +549,7 @@ export default function MediaRequestForm() {
setIsSearching(false);
return;
}
setSelectedItem(`${artistInput} - ${trackInput}`);
setSelectedItem({ id: 0, name: `${artistInput} - ${trackInput}` });
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
@@ -501,7 +557,7 @@ export default function MediaRequestForm() {
setIsSearching(false);
};
const handleTrackPlayPause = async (track, albumId = null, albumIndex = null) => {
const handleTrackPlayPause = async (track: Track, albumId: string | number | null = null, albumIndex: number | null = null) => {
const audio = audioRef.current;
if (!audio) return;
@@ -609,9 +665,9 @@ export default function MediaRequestForm() {
const blob = await fileResponse.blob();
const url = URL.createObjectURL(blob);
const artistName = track.artist || selectedArtist?.artist || "Unknown Artist";
const artistName = track.artist || selectedArtist?.artist || selectedArtist?.name || "Unknown Artist";
const urlPath = new URL(data.stream_url).pathname;
const extension = urlPath.split(".").pop().split("?")[0] || "flac";
const extension = urlPath.split(".").pop()?.split("?")[0] || "flac";
const filename = `${sanitizeFilename(artistName)} - ${sanitizeFilename(track.title)}.${extension}`;
const link = document.createElement("a");
@@ -791,7 +847,7 @@ export default function MediaRequestForm() {
const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]);
if (albumsToFetch.length === 0) return;
const fetchTracksSequentially = async () => {
const fetchTracksSequentially: FetchTracksSequentiallyFn = async () => {
const minDelay = 650; // ms between API requests
setIsFetching(true);
@@ -829,22 +885,26 @@ export default function MediaRequestForm() {
}
// Update progress toast
toast.update(metadataFetchToastId.current, {
progress: (index + 1) / totalAlbums,
render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`,
});
if (metadataFetchToastId.current !== null) {
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,
});
if (metadataFetchToastId.current !== null) {
toast.update(metadataFetchToastId.current, {
render: "Metadata retrieved!",
type: "success",
progress: 1,
autoClose: 1500,
});
}
};
fetchTracksSequentially();
@@ -857,7 +917,7 @@ export default function MediaRequestForm() {
// Toggle individual track checkbox
const toggleTrack = (albumId, trackId) => {
const toggleTrack = (albumId: string | number, trackId: string | number) => {
setSelectedTracks((prev) => {
const current = new Set(prev[albumId] || []);
if (current.has(String(trackId))) current.delete(String(trackId));
@@ -867,11 +927,11 @@ export default function MediaRequestForm() {
};
// Toggle album checkbox (select/deselect all tracks in album)
const toggleAlbum = (albumId) => {
const toggleAlbum = (albumId: string | number) => {
const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || [];
setSelectedTracks((prev) => {
const current = prev[albumId] || [];
const allSelected = current.length === allTracks.length;
const allSelected = current?.length === allTracks.length;
return {
...prev,
[albumId]: allSelected ? [] : [...allTracks],
@@ -883,12 +943,12 @@ export default function MediaRequestForm() {
const attachScrollFix = () => {
setTimeout(() => {
const panel = document.querySelector(".p-autocomplete-panel");
const items = panel?.querySelector(".p-autocomplete-items");
const items = panel?.querySelector(".p-autocomplete-items") as HTMLElement | null;
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;
@@ -923,7 +983,7 @@ export default function MediaRequestForm() {
},
body: JSON.stringify({
track_ids: allSelectedIds,
target: selectedArtist.artist,
target: selectedArtist?.artist || selectedArtist?.name || "",
quality: quality,
}),
});
@@ -1018,7 +1078,7 @@ export default function MediaRequestForm() {
<BreadcrumbNav currentPage="request" />
{/* Disk Space Indicator - always visible */}
{diskSpace && (
{diskSpace && diskSpace.usedPercent !== undefined && (
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z" />
@@ -1047,7 +1107,7 @@ export default function MediaRequestForm() {
<div className="flex flex-col gap-4">
<label htmlFor="artistInput">Artist: </label>
<AutoComplete
id={artistInput}
id="artistInput"
ref={autoCompleteRef}
value={selectedArtist || artistInput}
suggestions={artistSuggestions}
@@ -1125,7 +1185,7 @@ export default function MediaRequestForm() {
multiple
className="mt-4"
activeIndex={expandedAlbums}
onTabChange={(e) => setExpandedAlbums(e.index)}
onTabChange={(e) => setExpandedAlbums(Array.isArray(e.index) ? e.index : [e.index])}
>
{albums.map(({ album, id, release_date }, albumIndex) => {
const allTracks = tracksByAlbum[id] || [];
@@ -1193,11 +1253,11 @@ export default function MediaRequestForm() {
<ShuffleIcon active={!!shuffleAlbums[id]} />
</button>
</div>
<span className="flex items-center" title={album}>
{truncate(album, 32)}
<span className="flex items-center" title={album || ''}>
{truncate(album || '', 32)}
{loadingAlbumId === id && <Spinner />}
</span>
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date || 'Unknown'})</small>
<span className="ml-0 w-full text-xs text-neutral-500 sm:ml-auto sm:w-auto">
{typeof tracksByAlbum[id] === 'undefined' ? (
loadingAlbumId === id ? 'Loading...' : '...'
@@ -1277,7 +1337,6 @@ export default function MediaRequestForm() {
<button
type="button"
onClick={() => handleTrackDownload(track)}
referrerPolicy="no-referrer"
className="text-neutral-500 hover:text-blue-600 underline whitespace-nowrap cursor-pointer"
aria-label={`Download ${track.title}`}
>

View File

@@ -11,22 +11,36 @@ import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL } from "@/config";
import "./RequestManagement.css";
interface RequestJob {
id: string | number;
target: string;
tracks: number;
quality: string;
status: string;
progress: number;
type?: string;
tarball_path?: string;
created_at?: string;
updated_at?: string;
[key: string]: unknown;
}
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
export default function RequestManagement() {
const [requests, setRequests] = useState([]);
const [filterType, setFilterType] = useState(null);
const [filterStatus, setFilterStatus] = useState(null);
const [filteredRequests, setFilteredRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [requests, setRequests] = useState<RequestJob[]>([]);
const [filterType, setFilterType] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string | null>(null);
const [filteredRequests, setFilteredRequests] = useState<RequestJob[]>([]);
const [selectedRequest, setSelectedRequest] = useState<RequestJob | null>(null);
const [isDialogVisible, setIsDialogVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const pollingRef = useRef(null);
const pollingDetailRef = useRef(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollingDetailRef = useRef<ReturnType<typeof setInterval> | null>(null);
const tarballUrl = (absPath, quality) => {
const tarballUrl = (absPath: string | undefined, quality: string) => {
if (!absPath) return null;
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
return `${TAR_BASE_URL}/${quality}/${filename}`;
@@ -37,7 +51,7 @@ export default function RequestManagement() {
if (showLoading) setIsLoading(true);
const res = await authFetch(`${API_URL}/trip/jobs/list`);
if (!res.ok) throw new Error("Failed to fetch jobs");
const data = await res.json();
const data = await res.json() as { jobs?: RequestJob[] };
setRequests(Array.isArray(data.jobs) ? data.jobs : []);
} catch (err) {
console.error(err);
@@ -51,11 +65,11 @@ export default function RequestManagement() {
}
};
const fetchJobDetail = async (jobId) => {
const fetchJobDetail = async (jobId: string | number): Promise<RequestJob | null> => {
try {
const res = await authFetch(`${API_URL}/trip/job/${jobId}`);
if (!res.ok) throw new Error("Failed to fetch job details");
return await res.json();
return await res.json() as RequestJob;
} catch (err) {
console.error(err);
if (!toast.isActive('fetch-job-fail-toast')) {
@@ -108,7 +122,7 @@ export default function RequestManagement() {
}, [filterType, filterStatus, requests]);
const getStatusColorClass = (status) => {
const getStatusColorClass = (status: string) => {
switch (status) {
case "Queued": return "bg-yellow-700 text-white";
case "Started": return "bg-blue-700 text-white";
@@ -119,7 +133,7 @@ export default function RequestManagement() {
}
};
const getQualityColorClass = (quality) => {
const getQualityColorClass = (quality: string) => {
switch (quality) {
case "FLAC": return "bg-green-700 text-white";
case "Lossy": return "bg-yellow-700 text-white";
@@ -128,25 +142,25 @@ export default function RequestManagement() {
};
const statusBodyTemplate = (rowData) => (
const statusBodyTemplate = (rowData: RequestJob) => (
<span className={`inline-flex items-center justify-center min-w-[90px] px-3 py-1 rounded-full font-semibold text-xs ${getStatusColorClass(rowData.status)}`}>
{rowData.status}
</span>
);
const qualityBodyTemplate = (rowData) => (
const qualityBodyTemplate = (rowData: RequestJob) => (
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
{rowData.quality}
</span>
);
const safeText = (val) => (val === 0 ? "0" : val || "—");
const textWithEllipsis = (val, width = "12rem") => (
const safeText = (val: unknown) => (val === 0 ? "0" : val || "—");
const textWithEllipsis = (val: string | undefined | null, width = "12rem") => (
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
);
const truncate = (text, maxLen) =>
const truncate = (text: string, maxLen: number) =>
maxLen <= 3
? text.slice(0, maxLen)
: text.length <= maxLen
@@ -154,9 +168,9 @@ export default function RequestManagement() {
: text.slice(0, maxLen - 3) + '...';
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
const basename = (p: string | undefined) => (typeof p === "string" ? p.split("/").pop() : "");
const formatProgress = (p) => {
const formatProgress = (p: unknown) => {
if (p === null || p === undefined || p === "") return "—";
const num = Number(p);
if (Number.isNaN(num)) return "—";
@@ -164,16 +178,16 @@ export default function RequestManagement() {
return `${pct}%`;
};
const computePct = (p) => {
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)));
};
const progressBarTemplate = (rowData) => {
const progressBarTemplate = (rowData: RequestJob) => {
const p = rowData.progress;
if (p === null || p === undefined || p === "") return "—";
if (p === null || p === undefined || p === 0) return "—";
const num = Number(p);
if (Number.isNaN(num)) return "—";
const pct = computePct(p);
@@ -192,7 +206,8 @@ export default function RequestManagement() {
<div
className={`rm-progress-fill ${getProgressColor()}`}
style={{
'--rm-progress': (pct / 100).toString(),
// CSS custom property for progress animation
['--rm-progress' as string]: (pct / 100).toString(),
borderTopRightRadius: pct === 100 ? '999px' : 0,
borderBottomRightRadius: pct === 100 ? '999px' : 0
}}
@@ -207,7 +222,7 @@ export default function RequestManagement() {
);
};
const confirmDelete = (requestId) => {
const confirmDelete = (requestId: string | number) => {
confirmDialog({
message: "Are you sure you want to delete this request?",
header: "Confirm Delete",
@@ -216,12 +231,12 @@ export default function RequestManagement() {
});
};
const deleteRequest = (requestId) => {
const deleteRequest = (requestId: string | number) => {
setRequests((prev) => prev.filter((r) => r.id !== requestId));
toast.success("Request deleted");
};
const actionBodyTemplate = (rowData) => (
const actionBodyTemplate = (rowData: RequestJob) => (
<Button
color="neutral"
variant="outlined"
@@ -237,8 +252,9 @@ export default function RequestManagement() {
</Button>
);
const handleRowClick = async (e) => {
const detail = await fetchJobDetail(e.data.id);
const handleRowClick = async (e: { data: unknown }) => {
const rowData = e.data as RequestJob;
const detail = await fetchJobDetail(rowData.id);
if (detail) { setSelectedRequest(detail); setIsDialogVisible(true); }
};
@@ -300,14 +316,14 @@ export default function RequestManagement() {
<Column
field="id"
header="ID"
body={(row) => (
<span title={row.id}>
{row.id.split("-").slice(-1)[0]}
body={(row: RequestJob) => (
<span title={String(row.id)}>
{String(row.id).split("-").slice(-1)[0]}
</span>
)}
/>
<Column field="target" header="Target" sortable body={(row) => textWithEllipsis(row.target, "100%")} />
<Column field="tracks" header="# Tracks" body={(row) => row.tracks} />
<Column field="target" header="Target" sortable body={(row: RequestJob) => textWithEllipsis(row.target, "100%")} />
<Column field="tracks" header="# Tracks" body={(row: RequestJob) => row.tracks} />
<Column field="status" header="Status" body={statusBodyTemplate} style={{ textAlign: "center" }} sortable />
<Column field="progress" header="Progress" body={progressBarTemplate} style={{ textAlign: "center" }} sortable />
<Column
@@ -324,12 +340,12 @@ export default function RequestManagement() {
Tarball
</span>
}
body={(row) => {
const url = tarballUrl(row.tarball, row.quality || "FLAC");
const encodedURL = encodeURI(url);
body={(row: RequestJob) => {
const url = tarballUrl(row.tarball_path, row.quality || "FLAC");
if (!url) return "—";
const encodedURL = encodeURI(url);
const fileName = url.split("/").pop();
const fileName = url.split("/").pop() || "";
return (
<a
@@ -365,7 +381,7 @@ export default function RequestManagement() {
{/* --- Metadata Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {selectedRequest.id}</p>}
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {String(selectedRequest.id)}</p>}
{selectedRequest.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
{selectedRequest.quality && (
@@ -396,7 +412,7 @@ export default function RequestManagement() {
<div
className={`rm-progress-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
style={{
'--rm-progress': (computePct(selectedRequest.progress) / 100).toString(),
['--rm-progress' as string]: (computePct(selectedRequest.progress) / 100).toString(),
borderTopRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0,
borderBottomRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0
}}
@@ -414,24 +430,24 @@ export default function RequestManagement() {
{/* --- Timestamps Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 gap-2">
{selectedRequest.enqueued_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p>}
{selectedRequest.started_at && <p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p>}
{selectedRequest.ended_at && <p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>}
{selectedRequest.created_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.created_at).toLocaleString()}</p>}
{(selectedRequest as RequestJob & { started_at?: string }).started_at && <p><strong>Started:</strong> {new Date((selectedRequest as RequestJob & { started_at: string }).started_at).toLocaleString()}</p>}
{(selectedRequest as RequestJob & { ended_at?: string }).ended_at && <p><strong>Ended:</strong> {new Date((selectedRequest as RequestJob & { ended_at: string }).ended_at).toLocaleString()}</p>}
</div>
{/* --- Tarball Card --- */}
{
selectedRequest.tarball && (
selectedRequest.tarball_path && (
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
<p>
<strong>Tarball:</strong>{" "}
<a
href={encodeURI(tarballUrl(selectedRequest.tarball, selectedRequest.quality))}
href={encodeURI(tarballUrl(selectedRequest.tarball_path, selectedRequest.quality) || "")}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{tarballUrl(selectedRequest.tarball, selectedRequest.quality).split("/").pop()}
{tarballUrl(selectedRequest.tarball_path, selectedRequest.quality)?.split("/").pop()}
</a>
</p>
</div>

View File

@@ -2,7 +2,17 @@ import React from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const CustomToastContainer = ({ theme = 'light', newestOnTop = false, closeOnClick = true }) => {
interface CustomToastContainerProps {
theme?: 'light' | 'dark' | string;
newestOnTop?: boolean;
closeOnClick?: boolean;
}
const CustomToastContainer: React.FC<CustomToastContainerProps> = ({
theme = 'light',
newestOnTop = false,
closeOnClick = true
}) => {
// Map data-theme values to react-toastify theme
const toastTheme = theme === 'dark' ? 'dark' : 'light';

View File

@@ -1,4 +1,44 @@
export const metaData = {
export interface IconConfig {
rel: string;
href: string;
}
export interface MetaData {
baseUrl: string;
title: string;
name: string;
owner: string;
ogImage: string;
description: string;
shareTitle: string;
shareDescription: string;
shareImageAlt: string;
favicon: string;
icons: IconConfig[];
}
export interface WhitelabelConfig {
title: string;
name: string;
brandColor: string;
siteTitle: string;
logoText: string;
shareTitle: string;
shareDescription: string;
ogImage: string;
shareImageAlt: string;
favicon: string;
baseUrl: string;
siteName: string;
}
export interface ProtectedRoute {
path: string;
roles?: string[];
exclude?: string[];
}
export const metaData: MetaData = {
baseUrl: "https://codey.lol/",
title: "CODEY STUFF",
name: "codey.lol",
@@ -17,18 +57,18 @@ export const metaData = {
],
};
export const API_URL = "https://api.codey.lol";
export const RADIO_API_URL = "https://radio-api.codey.lol";
export const API_URL: string = "https://api.codey.lol";
export const RADIO_API_URL: string = "https://radio-api.codey.lol";
export const socialLinks = {
export const socialLinks: Record<string, string> = {
};
export const MAJOR_VERSION = "0.5"
export const RELEASE_FLAG = null;
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
export const MAJOR_VERSION: string = "0.5"
export const RELEASE_FLAG: string | null = null;
export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod";
// Whitelabel overrides
export const WHITELABELS = {
export const WHITELABELS: Record<string, WhitelabelConfig> = {
'req.boatson.boats': {
title: 'Request Media',
name: 'REQ',
@@ -49,7 +89,7 @@ export const WHITELABELS = {
};
// Subsite mapping: host -> site path
export const SUBSITES = {
export const SUBSITES: Record<string, string> = {
'req.boatson.boats': '/subsites/req',
};
@@ -57,7 +97,7 @@ export const SUBSITES = {
// Routes listed here require authentication - middleware will redirect to /login if not authenticated
// Can be a string (just auth required) or object with roles array for role-based access
// Use 'exclude' array to exempt specific sub-paths from protection
export const PROTECTED_ROUTES = [
export const PROTECTED_ROUTES: ProtectedRoute[] = [
{ path: '/discord-logs', roles: ['discord'] },
{ path: '/api/discord', roles: ['discord'], exclude: ['/api/discord/cached-image'] },
{ path: '/TRip', roles: ['trip'] },
@@ -66,7 +106,7 @@ export const PROTECTED_ROUTES = [
];
// Routes that should skip auth check entirely (public routes)
export const PUBLIC_ROUTES = [
export const PUBLIC_ROUTES: string[] = [
'/',
'/login',
'/api/',

View File

@@ -1,21 +1,29 @@
// requireAuthHook.js
// requireAuthHook.ts
import { API_URL } from "@/config";
import type { AstroGlobal } from 'astro';
export interface AuthUser {
id?: string;
username?: string;
roles?: string[];
[key: string]: unknown;
}
// Short-term failure cache for auth timeouts/errors
let lastAuthFailureTime = 0;
const AUTH_FAILURE_CACHE_MS = 30000; // 30 seconds
let lastAuthFailureTime: number = 0;
const AUTH_FAILURE_CACHE_MS: number = 30000; // 30 seconds
// WeakMap to cache auth promises per Astro context (request)
const authCache = new WeakMap();
const authCache = new WeakMap<AstroGlobal, Promise<AuthUser | null>>();
export const requireAuthHook = async (Astro) => {
export const requireAuthHook = async (Astro: AstroGlobal): Promise<AuthUser | null> => {
// If we recently failed due to API timeout/unreachability, fail closed quickly
if (Date.now() - lastAuthFailureTime < AUTH_FAILURE_CACHE_MS) {
return null;
}
// Check if we already have a cached promise for this request
if (authCache.has(Astro)) {
return authCache.get(Astro);
return authCache.get(Astro) ?? null;
}
// Create a promise and cache it immediately to prevent race conditions
@@ -26,7 +34,7 @@ export const requireAuthHook = async (Astro) => {
return authPromise;
};
async function performAuth(Astro) {
async function performAuth(Astro: AstroGlobal): Promise<AuthUser | null> {
try {
const cookieHeader = Astro.request.headers.get("cookie") ?? "";
// Add timeout to avoid hanging SSR render
@@ -83,7 +91,7 @@ async function performAuth(Astro) {
}
// Get all Set-Cookie headers (getSetCookie returns an array)
let setCookies = [];
let setCookies: string[] = [];
if (typeof refreshRes.headers.getSetCookie === 'function') {
setCookies = refreshRes.headers.getSetCookie();
} else {

View File

@@ -1,15 +0,0 @@
import { useState, useEffect } from "react";
export function useHtmlThemeAttr() {
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute("data-theme") || "light"
);
useEffect(() => {
const handler = (e) => setTheme(e.detail);
document.addEventListener("set-theme", handler);
return () => document.removeEventListener("set-theme", handler);
}, []);
return theme;
}

View File

@@ -0,0 +1,15 @@
import { useState, useEffect } from "react";
export function useHtmlThemeAttr(): string {
const [theme, setTheme] = useState<string>(() =>
document.documentElement.getAttribute("data-theme") || "light"
);
useEffect(() => {
const handler = (e: CustomEvent<string>) => setTheme(e.detail);
document.addEventListener("set-theme", handler as EventListener);
return () => document.removeEventListener("set-theme", handler as EventListener);
}, []);
return theme;
}

View File

@@ -1,8 +1,8 @@
import { useEffect } from "react";
export function usePrimeReactThemeSwitcher(theme) {
export function usePrimeReactThemeSwitcher(theme: string | null): void {
useEffect(() => {
const themeLink = document.getElementById("primereact-theme");
const themeLink = document.getElementById("primereact-theme") as HTMLLinkElement | null;
if (!themeLink) return;
const newTheme =

View File

@@ -1,27 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
const WhitelabelLayout = ({ children, header, footer, customStyles }) => {
return (
<div style={customStyles} className="whitelabel-layout">
{header && <header className="whitelabel-header">{header}</header>}
<main className="whitelabel-main">{children}</main>
{footer && <footer className="whitelabel-footer">{footer}</footer>}
</div>
);
};
WhitelabelLayout.propTypes = {
children: PropTypes.node.isRequired,
header: PropTypes.node,
footer: PropTypes.node,
customStyles: PropTypes.object,
};
WhitelabelLayout.defaultProps = {
header: null,
footer: null,
customStyles: {},
};
export default WhitelabelLayout;

View File

@@ -0,0 +1,25 @@
import React, { ReactNode, CSSProperties } from 'react';
interface WhitelabelLayoutProps {
children: ReactNode;
header?: ReactNode;
footer?: ReactNode;
customStyles?: CSSProperties;
}
const WhitelabelLayout: React.FC<WhitelabelLayoutProps> = ({
children,
header = null,
footer = null,
customStyles = {}
}) => {
return (
<div style={customStyles} className="whitelabel-layout">
{header && <header className="whitelabel-header">{header}</header>}
<main className="whitelabel-main">{children}</main>
{footer && <footer className="whitelabel-footer">{footer}</footer>}
</div>
);
};
export default WhitelabelLayout;

View File

@@ -1,16 +1,30 @@
import { defineMiddleware } from 'astro:middleware';
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES } from './config.js';
import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js';
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, type ProtectedRoute } from './config.ts';
import { getSubsiteByHost, getSubsiteFromSignal, type SubsiteInfo } from './utils/subsites.ts';
export interface AuthUser {
id?: string;
username?: string;
roles?: string[];
[key: string]: unknown;
}
export interface AuthResult {
authenticated: boolean;
user: AuthUser | null;
setCookies: string[];
cookies?: string[] | null;
}
// 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') {
if (typeof globalThis.Headers !== 'undefined' && typeof (globalThis.Headers.prototype as any).getSetCookie !== 'function') {
try {
Object.defineProperty(globalThis.Headers.prototype, 'getSetCookie', {
value: function () {
const cookies = [];
value: function (this: Headers): string[] {
const cookies: string[] = [];
for (const [name, val] of this.entries()) {
if (name && name.toLowerCase() === 'set-cookie') cookies.push(val);
}
@@ -26,43 +40,43 @@ if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.proto
}
}
const API_URL = "https://api.codey.lol";
const AUTH_TIMEOUT_MS = 3000; // 3 second timeout for auth requests
const API_URL: string = "https://api.codey.lol";
const AUTH_TIMEOUT_MS: number = 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
let refreshPromise: Promise<AuthResult> | null = null;
let lastRefreshResult: AuthResult | null = null;
let lastRefreshTime: number = 0;
const REFRESH_RESULT_TTL: number = 5000; // Cache successful refresh result for 5 seconds
// Auth check function (mirrors requireAuthHook logic but for middleware)
async function checkAuth(request) {
async function checkAuth(request: Request): Promise<AuthResult> {
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;
let res: Response;
try {
res = await fetch(`${API_URL}/auth/id`, {
headers: { Cookie: cookieHeader },
credentials: "include",
signal: controller.signal,
});
} catch (err) {
} catch (err: unknown) {
clearTimeout(timeout);
console.error("[middleware] auth/id failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
return { authenticated: false, user: null, cookies: null };
console.error("[middleware] auth/id failed or timed out", (err as Error)?.name === 'AbortError' ? 'timeout' : err);
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
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 };
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
// Check if we have a recent successful refresh result we can reuse
@@ -91,10 +105,10 @@ async function checkAuth(request) {
credentials: "include",
signal: controller.signal,
});
} catch (err) {
} catch (err: unknown) {
clearTimeout(timeout);
console.error("[middleware] auth/refresh failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
return { authenticated: false, user: null, cookies: null };
console.error("[middleware] auth/refresh failed or timed out", (err as Error)?.name === 'AbortError' ? 'timeout' : err);
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
clearTimeout(timeout);
@@ -106,13 +120,13 @@ async function checkAuth(request) {
errorDetail = ` - ${errorBody}`;
} catch {}
console.error(`[middleware] Token refresh failed ${refreshRes.status}${errorDetail}`);
return { authenticated: false, user: null, cookies: null };
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
console.log(`[middleware] Token refresh succeeded`);
// Get refreshed cookies
let setCookies = [];
let setCookies: string[] = [];
if (typeof refreshRes.headers.getSetCookie === 'function') {
setCookies = refreshRes.headers.getSetCookie();
} else {
@@ -124,7 +138,7 @@ async function checkAuth(request) {
if (setCookies.length === 0) {
console.error("[middleware] No set-cookie headers in refresh response");
return { authenticated: false, user: null, cookies: null };
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
// Build new cookie header for retry
@@ -140,25 +154,25 @@ async function checkAuth(request) {
credentials: "include",
signal: controller.signal,
});
} catch (err) {
} catch (err: unknown) {
clearTimeout(timeout);
console.error("[middleware] auth/id retry failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
return { authenticated: false, user: null, cookies: null };
console.error("[middleware] auth/id retry failed or timed out", (err as Error)?.name === 'AbortError' ? 'timeout' : err);
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
clearTimeout(timeout);
if (!retryRes.ok) {
console.error(`[middleware] auth/id retry failed with status ${retryRes.status}`);
return { authenticated: false, user: null, cookies: null };
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
const user = await retryRes.json();
return { authenticated: true, user, cookies: setCookies };
return { authenticated: true, user, cookies: setCookies, setCookies };
})();
// Clear the promise when done and cache the result
refreshPromise.then(result => {
if (result.authenticated) {
if (result && result.authenticated) {
lastRefreshResult = result;
lastRefreshTime = Date.now();
}
@@ -167,23 +181,23 @@ async function checkAuth(request) {
refreshPromise = null;
});
return refreshPromise;
return refreshPromise!;
}
if (!res.ok) {
return { authenticated: false, user: null, cookies: null };
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
const user = await res.json();
return { authenticated: true, user, cookies: null };
return { authenticated: true, user, cookies: null, setCookies: [] };
} catch (err) {
console.error("[middleware] Auth check error:", err);
return { authenticated: false, user: null, cookies: null };
return { authenticated: false, user: null, cookies: null, setCookies: [] };
}
}
// Check if a path matches any protected route and return the config
function getProtectedRouteConfig(pathname) {
function getProtectedRouteConfig(pathname: string): ProtectedRoute | null {
// Normalize pathname for comparison (lowercase)
const normalizedPath = pathname.toLowerCase();
@@ -201,14 +215,14 @@ function getProtectedRouteConfig(pathname) {
return null; // Excluded, not protected
}
}
return typeof route === 'string' ? { path: route, roles: null } : route;
return typeof route === 'string' ? { path: route, roles: undefined } : route;
}
}
return null;
}
// Check if a path is explicitly public (but NOT if it matches a protected route)
function isPublicRoute(pathname) {
function isPublicRoute(pathname: string): boolean {
// If the path matches a protected route, it's NOT public
if (getProtectedRouteConfig(pathname)) {
return false;

View File

@@ -6,7 +6,7 @@ import Root from "@/components/AppLayout.jsx";
// Middleware redirects to /login if not authenticated
const user = Astro.locals.user as any;
---
<Base>
<Base title="TRip" description="TRip Media Request Form">
<section class="page-section trip-section" transition:animate="none">
<Root child="qs2.MediaRequestForm" client:only="react" />
</section>

View File

@@ -6,7 +6,7 @@ import Root from "@/components/AppLayout.jsx";
// Middleware redirects to /login if not authenticated
const user = Astro.locals.user as any;
---
<Base>
<Base title="TRip Requests" description="TRip Requests / Status">
<section class="page-section trip-section" transition:animate="none">
<Root child="qs2.RequestManagement" client:only="react" transition:persist />
</section>

View File

@@ -5,12 +5,13 @@
* Security: Uses HMAC signatures to prevent enumeration of image IDs.
* Images can only be accessed with a valid signature generated server-side.
*/
import sql from '../../../utils/db.js';
import sql from '../../../utils/db.ts';
import crypto from 'crypto';
import {
checkRateLimit,
recordRequest,
} from '../../../utils/rateLimit.js';
} from '../../../utils/rateLimit.ts';
import type { APIContext } from 'astro';
// Secret for signing image IDs - prevents enumeration attacks
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
@@ -20,10 +21,10 @@ if (!IMAGE_CACHE_SECRET) {
/**
* Generate HMAC signature for an image ID
* @param {string|number} imageId - The image ID to sign
* @returns {string} - The hex signature
* @param imageId - The image ID to sign
* @returns The hex signature
*/
export function signImageId(imageId) {
export function signImageId(imageId: string | number): string {
if (!IMAGE_CACHE_SECRET) {
throw new Error('IMAGE_CACHE_SECRET not configured');
}
@@ -34,11 +35,11 @@ export function signImageId(imageId) {
/**
* Verify HMAC signature for an image ID
* @param {string|number} imageId - The image ID
* @param {string} signature - The signature to verify
* @returns {boolean} - Whether signature is valid
* @param imageId - The image ID
* @param signature - The signature to verify
* @returns Whether signature is valid
*/
function verifyImageSignature(imageId, signature) {
function verifyImageSignature(imageId: string | number, signature: string | null): boolean {
if (!IMAGE_CACHE_SECRET || !signature) return false;
const expected = signImageId(imageId);
// Timing-safe comparison
@@ -49,7 +50,7 @@ function verifyImageSignature(imageId, signature) {
}
}
export async function GET({ request }) {
export async function GET({ request }: APIContext): Promise<Response> {
// Rate limit check - higher limit for images but still protected
const rateCheck = checkRateLimit(request, {
limit: 100,

View File

@@ -2,26 +2,27 @@
* Serve cached videos stored on disk (or by source_url) for Discord attachments/embeds
* Security: uses HMAC signature on id to prevent enumeration and requires id-based lookups to include a valid signature.
*/
import sql from '../../../utils/db.js';
import sql from '../../../utils/db.ts';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { Readable } from 'stream';
import { checkRateLimit, recordRequest } from '../../../utils/rateLimit.js';
import { checkRateLimit, recordRequest } from '../../../utils/rateLimit.ts';
import type { APIContext } from 'astro';
const VIDEO_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET; // share same secret for simplicity
if (!VIDEO_CACHE_SECRET) {
console.error('WARNING: IMAGE_CACHE_SECRET not set, video signing may be unavailable');
}
export function signVideoId(videoId) {
export function signVideoId(videoId: string | number): string {
if (!VIDEO_CACHE_SECRET) throw new Error('VIDEO_CACHE_SECRET not configured');
const hmac = crypto.createHmac('sha256', VIDEO_CACHE_SECRET);
hmac.update(String(videoId));
return hmac.digest('hex').substring(0, 16);
}
function verifySignature(id, signature) {
function verifySignature(id: string | number, signature: string | null): boolean {
if (!VIDEO_CACHE_SECRET || !signature) return false;
const expected = signVideoId(id);
try {
@@ -31,8 +32,16 @@ function verifySignature(id, signature) {
}
}
interface StreamResult {
status: number;
stream?: ReadableStream;
total?: number;
start?: number;
end?: number;
}
// Helper to stream a file with range support
function streamFile(filePath, rangeHeader) {
function streamFile(filePath: string, rangeHeader: string | null): StreamResult {
// Ensure file exists
if (!fs.existsSync(filePath)) return { status: 404 };
const stat = fs.statSync(filePath);
@@ -40,7 +49,7 @@ function streamFile(filePath, rangeHeader) {
if (!rangeHeader) {
const nodeStream = fs.createReadStream(filePath);
const stream = Readable.toWeb(nodeStream);
const stream = Readable.toWeb(nodeStream) as ReadableStream;
return { status: 200, stream, total, start: 0, end: total - 1 };
}
@@ -52,11 +61,11 @@ function streamFile(filePath, rangeHeader) {
if (isNaN(start) || isNaN(end) || start > end || start >= total) return { status: 416 };
const nodeStream = fs.createReadStream(filePath, { start, end });
const stream = Readable.toWeb(nodeStream);
const stream = Readable.toWeb(nodeStream) as ReadableStream;
return { status: 206, stream, total, start, end };
}
export async function GET({ request }) {
export async function GET({ request }: APIContext): Promise<Response> {
const rateCheck = checkRateLimit(request, { limit: 50, windowMs: 1000, burstLimit: 200, burstWindowMs: 10_000 });
if (!rateCheck.allowed) return new Response('Rate limit exceeded', { status: 429, headers: { 'Retry-After': '1' } });
recordRequest(request, 1000);

View File

@@ -1,15 +1,30 @@
/**
* API endpoint to fetch Discord channels from database
*/
import sql from '../../../utils/db.js';
import { requireApiAuth, createApiResponse } from '../../../utils/apiAuth.js';
import sql from '../../../utils/db.ts';
import { requireApiAuth, createApiResponse } from '../../../utils/apiAuth.ts';
import {
checkRateLimit,
recordRequest,
} from '../../../utils/rateLimit.js';
import { signImageId } from './cached-image.js';
} from '../../../utils/rateLimit.ts';
import { signImageId } from './cached-image.ts';
import type { APIContext } from 'astro';
export async function GET({ request }) {
interface ChannelRow {
channel_id: string;
name: string;
type: number;
position: number;
parent_id: string | null;
topic: string | null;
guild_id: string;
guild_name: string | null;
guild_icon: string | null;
guild_cached_icon_id: string | null;
message_count: number;
}
export async function GET({ request }: APIContext): Promise<Response> {
// Rate limit check
const rateCheck = checkRateLimit(request, {
limit: 20,
@@ -34,7 +49,7 @@ export async function GET({ request }) {
if (authError) return authError;
// Helper to validate Discord snowflake IDs (17-20 digit strings)
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
const isValidSnowflake = (id: string | null): boolean => !id || /^\d{17,20}$/.test(id);
try {
const url = new URL(request.url);

View File

@@ -2,13 +2,13 @@
* API endpoint to fetch Discord guild members with roles
* Optionally filters by channel visibility using permission overwrites
*/
import sql from '../../../utils/db.js';
import { requireApiAuth } from '../../../utils/apiAuth.js';
import sql from '../../../utils/db.ts';
import { requireApiAuth } from '../../../utils/apiAuth.ts';
import {
checkRateLimit,
recordRequest,
} from '../../../utils/rateLimit.js';
import { signImageId } from './cached-image.js';
} from '../../../utils/rateLimit.ts';
import { signImageId } from './cached-image.ts';
// Discord permission flags
const VIEW_CHANNEL = 0x400n; // 1024

View File

@@ -2,16 +2,18 @@
* API endpoint to fetch Discord messages from database
* Includes user info, attachments, embeds, and reactions
*/
import sql from '../../../utils/db.js';
import { requireApiAuth } from '../../../utils/apiAuth.js';
import sql from '../../../utils/db.ts';
import { requireApiAuth } from '../../../utils/apiAuth.ts';
import {
checkRateLimit,
recordRequest,
} from '../../../utils/rateLimit.js';
import { signImageId } from './cached-image.js';
import { signVideoId } from './cached-video.js';
} from '../../../utils/rateLimit.ts';
import { signImageId } from './cached-image.ts';
import { signVideoId } from './cached-video.ts';
import crypto from 'crypto';
import type { APIContext } from 'astro';
const IMAGE_PROXY_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
if (!IMAGE_PROXY_SECRET) {
console.error('WARNING: IMAGE_PROXY_SECRET not set, image signing will fail');
@@ -39,7 +41,7 @@ const TRUSTED_DOMAINS = new Set([
'user-images.githubusercontent.com',
]);
function isTrustedDomain(url) {
function isTrustedDomain(url: string): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase();
return TRUSTED_DOMAINS.has(hostname) ||
@@ -49,7 +51,7 @@ function isTrustedDomain(url) {
}
}
function generateSignature(url) {
function generateSignature(url: string): string {
return crypto
.createHmac('sha256', IMAGE_PROXY_SECRET)
.update(url)
@@ -63,7 +65,7 @@ function generateSignature(url) {
* @param {string} baseUrl - Base URL for constructing cached image URLs
* @returns {string|null} The URL to use
*/
function getCachedOrProxyUrl(cachedImageId, originalUrl, baseUrl) {
function getCachedOrProxyUrl(cachedImageId: number | null, originalUrl: string | null, baseUrl: string): string | null {
// Prefer cached image if available
if (cachedImageId) {
const sig = signImageId(cachedImageId);
@@ -82,7 +84,7 @@ function getCachedOrProxyUrl(cachedImageId, originalUrl, baseUrl) {
return `${baseUrl}/api/image-proxy?url=${encodedUrl}&sig=${signature}`;
}
function getSafeImageUrl(originalUrl, baseUrl) {
function getSafeImageUrl(originalUrl: string | null, baseUrl: string): string | null {
if (!originalUrl) return null;
if (isTrustedDomain(originalUrl)) {
@@ -94,7 +96,7 @@ function getSafeImageUrl(originalUrl, baseUrl) {
return `${baseUrl}/api/image-proxy?url=${encodedUrl}&sig=${signature}`;
}
export async function GET({ request }) {
export async function GET({ request }: APIContext) {
// Rate limit check
const rateCheck = checkRateLimit(request, {
limit: 30,
@@ -119,7 +121,7 @@ export async function GET({ request }) {
if (authError) return authError;
// Helper to create responses with optional Set-Cookie header
const createResponse = (data, status = 200) => {
const createResponse = (data: unknown, status = 200) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (setCookieHeader) {
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
@@ -131,7 +133,7 @@ export async function GET({ request }) {
};
// Helper to validate Discord snowflake IDs (17-20 digit strings)
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
const isValidSnowflake = (id: string | null) => !id || /^\d{17,20}$/.test(id);
try {
const url = new URL(request.url);
@@ -462,7 +464,7 @@ export async function GET({ request }) {
const authorIds = [...new Set(messages.map(m => m.author_id).filter(Boolean))];
// Fetch cached avatar IDs for all authors
const userAvatarCache = {};
const userAvatarCache: Record<string, { cachedAvatarId: number | null }> = {};
if (authorIds.length > 0) {
const avatarInfo = await sql`
SELECT
@@ -472,7 +474,7 @@ export async function GET({ request }) {
FROM users u
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
WHERE u.user_id = ANY(${authorIds})
`;
` as unknown as Array<{ user_id: string; cached_avatar_id: number | null; cached_guild_avatar_id: number | null }>;
for (const info of avatarInfo) {
userAvatarCache[info.user_id.toString()] = {
cachedAvatarId: info.cached_guild_avatar_id || info.cached_avatar_id,
@@ -493,7 +495,7 @@ export async function GET({ request }) {
}
// Fetch mentioned users with their display names and colors
let mentionedUsers = [];
let mentionedUsers: { user_id: string; username: string; display_name: string; color: number | null }[] = [];
if (mentionedUserIds.size > 0) {
const mentionedIds = Array.from(mentionedUserIds);
mentionedUsers = await sql`
@@ -513,7 +515,7 @@ export async function GET({ request }) {
FROM users u
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
WHERE u.user_id = ANY(${mentionedIds})
`;
` as unknown as { user_id: string; username: string; display_name: string; color: number | null }[];
}
// Build users map for mentions
@@ -566,19 +568,19 @@ export async function GET({ request }) {
`;
// Fetch embed fields for all embeds
const embedIds = embeds.map(e => e.embed_id);
let embedFields = [];
const embedIds = (embeds as unknown as Array<{ embed_id: number }>).map(e => e.embed_id);
let embedFields: { embed_id: number; name: string; value: string; inline: boolean; position: number }[] = [];
if (embedIds.length > 0) {
embedFields = await sql`
SELECT embed_id, name, value, inline, position
FROM embed_fields
WHERE embed_id = ANY(${embedIds})
ORDER BY position ASC
`;
` as unknown as { embed_id: number; name: string; value: string; inline: boolean; position: number }[];
}
// Index embed fields by embed_id
const fieldsByEmbed = {};
const fieldsByEmbed: Record<number, Array<{ name: string; value: string; inline: boolean }>> = {};
for (const field of embedFields) {
if (!fieldsByEmbed[field.embed_id]) {
fieldsByEmbed[field.embed_id] = [];
@@ -669,7 +671,7 @@ export async function GET({ request }) {
}
// Fetch emoji cache info for all referenced emojis
let emojiCacheMap = {};
let emojiCacheMap: Record<string, { url: string; animated: boolean }> = {};
const emojiIdArray = Array.from(allEmojiIds);
if (emojiIdArray.length > 0) {
const emojiCacheRows = await sql`
@@ -677,7 +679,7 @@ export async function GET({ request }) {
FROM emojis
WHERE emoji_id = ANY(${emojiIdArray}::bigint[])
AND cached_image_id IS NOT NULL
`;
` as unknown as Array<{ emoji_id: string; emoji_animated: boolean; cached_image_id: number }>;
for (const row of emojiCacheRows) {
const sig = signImageId(row.cached_image_id);
emojiCacheMap[String(row.emoji_id)] = {
@@ -688,8 +690,8 @@ export async function GET({ request }) {
}
// Fetch poll votes with user info
const pollMessageIds = polls.map(p => p.message_id);
let pollVotes = [];
const pollMessageIds = (polls as unknown as Array<{ message_id: string }>).map(p => p.message_id);
let pollVotes: Array<{ message_id: string; answer_id: string; user_id: string; username: string; display_name: string; avatar_url: string | null }> = [];
if (pollMessageIds.length > 0) {
pollVotes = await sql`
SELECT
@@ -705,7 +707,7 @@ export async function GET({ request }) {
WHERE pv.message_id = ANY(${pollMessageIds})
AND pv.is_removed = FALSE
ORDER BY pv.voted_at ASC
`;
` as unknown as Array<{ message_id: string; answer_id: string; user_id: string; username: string; display_name: string; avatar_url: string | null }>;
}
// Fetch referenced messages for replies
@@ -713,7 +715,7 @@ 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_avatar: string | null; author_display_name: string; author_guild_avatar: string | null }> = [];
if (referencedIds.length > 0) {
referencedMessages = await sql`
SELECT
@@ -729,23 +731,23 @@ export async function GET({ request }) {
LEFT JOIN channels c ON m.channel_id = c.channel_id
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
WHERE m.message_id = ANY(${referencedIds})
`;
` as Array<{ message_id: string; content: string; author_id: string; author_username: string; author_avatar: string | null; author_display_name: string; author_guild_avatar: string | null }>;
}
// Build a map of video cache entries for any attachment/embed video URLs
const candidateVideoUrls = [];
for (const att of attachments) {
const candidateVideoUrls: (string | null)[] = [];
for (const att of attachments as unknown as Array<{ content_type: string | null; url: string | null; proxy_url: string | null }>) {
if (att.content_type?.startsWith('video/')) {
candidateVideoUrls.push(att.url || att.proxy_url);
}
}
for (const emb of embeds) {
for (const emb of embeds as unknown as Array<{ video_url: string | null }>) {
if (emb.video_url) candidateVideoUrls.push(emb.video_url);
}
// Query video_cache for any matching source_url values
// Helper: normalize video source URL by stripping query/hash (Discord adds signatures)
const normalizeVideoSrc = (url) => {
const normalizeVideoSrc = (url: string) => {
if (!url) return url;
try {
const u = new URL(url);
@@ -755,10 +757,10 @@ export async function GET({ request }) {
}
};
let videoCacheRows = [];
let videoCacheRows: Array<{ video_id: number; source_url: string; file_path: string; content_type: string; is_youtube: boolean; youtube_id: string | null }> = [];
if (candidateVideoUrls.length > 0) {
// dedupe and normalize (strip query params since Discord adds signatures)
const uniqueUrls = Array.from(new Set(candidateVideoUrls.filter(Boolean).map(normalizeVideoSrc)));
const uniqueUrls = Array.from(new Set(candidateVideoUrls.filter((u): u is string => Boolean(u)).map(normalizeVideoSrc)));
if (uniqueUrls.length > 0) {
// Query all video cache entries that start with any of our normalized URLs
// Since source_url may have query params, we use LIKE prefix matching
@@ -954,14 +956,14 @@ export async function GET({ request }) {
}
// Index poll votes by message_id and answer_id
const pollVotesByAnswer = {};
const pollVotesByAnswer: Record<string, Array<{ id: string; username: string; displayName: string; avatar: string | null }>> = {};
for (const vote of pollVotes) {
const key = `${vote.message_id}-${vote.answer_id}`;
if (!pollVotesByAnswer[key]) {
pollVotesByAnswer[key] = [];
}
// Build avatar URL
let avatarUrl = null;
let avatarUrl: string | null = null;
if (vote.avatar_url) {
if (vote.avatar_url.startsWith('http')) {
avatarUrl = vote.avatar_url;

View File

@@ -1,13 +1,13 @@
/**
* API endpoint to fetch users who reacted with a specific emoji on a message
*/
import sql from '../../../utils/db.js';
import { requireApiAuth } from '../../../utils/apiAuth.js';
import sql from '../../../utils/db.ts';
import { requireApiAuth } from '../../../utils/apiAuth.ts';
import {
checkRateLimit,
recordRequest,
} from '../../../utils/rateLimit.js';
import { signImageId } from './cached-image.js';
} from '../../../utils/rateLimit.ts';
import { signImageId } from './cached-image.ts';
export async function GET({ request }) {
// Rate limit check

View File

@@ -2,15 +2,15 @@
* API endpoint to search Discord messages
* Searches message content and embed content
*/
import sql from '../../../utils/db.js';
import { requireApiAuth } from '../../../utils/apiAuth.js';
import sql from '../../../utils/db.ts';
import { requireApiAuth } from '../../../utils/apiAuth.ts';
import {
checkRateLimit,
recordRequest,
getCookieId,
} from '../../../utils/rateLimit.js';
import { signVideoId } from './cached-video.js';
import { signImageId } from './cached-image.js';
} from '../../../utils/rateLimit.ts';
import { signVideoId } from './cached-video.ts';
import { signImageId } from './cached-image.ts';
/**
* Escapes special characters in a string for safe use in SQL LIKE/ILIKE queries.

View File

@@ -1,10 +1,21 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { requireApiAuth } from '../../utils/apiAuth.js';
import { requireApiAuth, type User } from '../../utils/apiAuth.ts';
import type { APIContext } from 'astro';
const execAsync = promisify(exec);
export async function GET({ request }) {
interface DiskSpaceResponse {
total: number;
used: number;
available: number;
usedPercent: number;
totalFormatted: string;
usedFormatted: string;
availableFormatted: string;
}
export async function GET({ request }: APIContext): Promise<Response> {
// Check authentication
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
if (authError) return authError;
@@ -61,7 +72,7 @@ export async function GET({ request }) {
}
}
function formatBytes(bytes) {
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];

View File

@@ -10,7 +10,7 @@ import {
getCookieId,
generateNonce,
createNonceCookie,
} from '../../utils/rateLimit.js';
} from '../../utils/rateLimit.ts';
// Secret key for signing URLs - MUST be set in production
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
@@ -35,7 +35,7 @@ const PRIVATE_IP_PATTERNS = [
/^\[?fd00:/i, // IPv6 private
];
function isPrivateUrl(urlString) {
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
const hostname = url.hostname;
@@ -63,7 +63,7 @@ const ALLOWED_CONTENT_TYPES = [
];
// Image extensions for fallback content-type detection
const IMAGE_EXTENSIONS = {
const IMAGE_EXTENSIONS: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
@@ -79,7 +79,7 @@ const IMAGE_EXTENSIONS = {
/**
* Get content type from URL extension
*/
function getContentTypeFromUrl(url) {
function getContentTypeFromUrl(url: string): string | null {
try {
const pathname = new URL(url).pathname.toLowerCase();
for (const [ext, type] of Object.entries(IMAGE_EXTENSIONS)) {
@@ -94,7 +94,7 @@ function getContentTypeFromUrl(url) {
/**
* Generate HMAC signature for a URL
*/
export async function signImageUrl(imageUrl) {
export async function signImageUrl(imageUrl: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',

View File

@@ -10,9 +10,21 @@ import {
getCookieId,
generateNonce,
createNonceCookie,
} from '../../utils/rateLimit.js';
import { signImageUrl } from './image-proxy.js';
} from '../../utils/rateLimit.ts';
import { signImageUrl } from './image-proxy.ts';
import { parseHTML } from 'linkedom';
import type { APIContext } from 'astro';
interface LinkPreviewMeta {
url: string;
title: string | null;
description: string | null;
image: string | null;
siteName: string | null;
type: string | null;
video: string | null;
themeColor: string | null;
}
// Trusted domains that can be loaded client-side
const TRUSTED_DOMAINS = new Set([
@@ -35,7 +47,7 @@ const TRUSTED_DOMAINS = new Set([
'picsum.photos', 'images.unsplash.com',
]);
function isTrustedDomain(url) {
function isTrustedDomain(url: string): boolean {
try {
const parsed = new URL(url);
return TRUSTED_DOMAINS.has(parsed.hostname);
@@ -44,15 +56,15 @@ function isTrustedDomain(url) {
}
}
async function getSafeImageUrl(imageUrl) {
async function getSafeImageUrl(imageUrl: string | null): Promise<string | null> {
if (!imageUrl) return null;
if (isTrustedDomain(imageUrl)) return imageUrl;
const signature = await signImageUrl(imageUrl);
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
}
function parseMetaTags(html, url) {
const meta = {
function parseMetaTags(html: string, url: string): LinkPreviewMeta {
const meta: LinkPreviewMeta = {
url,
title: null,
description: null,

View File

@@ -1,4 +1,4 @@
import { getSubsiteByHost } from '../../utils/subsites.js';
import { getSubsiteByHost } from '../../utils/subsites.ts';
import {
checkRateLimit,
recordRequest,
@@ -6,9 +6,29 @@ import {
generateNonce,
createNonceCookie,
getClientIp,
} from '../../utils/rateLimit.js';
} from '../../utils/rateLimit.ts';
import type { APIContext } from 'astro';
export async function GET({ request }) {
interface TMDBResult {
media_type: string;
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
overview?: string;
poster_path?: string;
}
interface SearchSuggestion {
label: string;
value: string;
year?: string;
mediaType: string;
overview?: string;
poster_path?: string;
}
export async function GET({ request }: APIContext): Promise<Response> {
const host = request.headers.get('host');
const subsite = getSubsiteByHost(host);

View File

@@ -1,4 +1,4 @@
import { getSubsiteByHost } from '../../utils/subsites.js';
import { getSubsiteByHost } from '../../utils/subsites.ts';
import {
checkRateLimit,
recordRequest as recordRateLimitRequest,
@@ -6,12 +6,39 @@ import {
generateNonce,
createNonceCookie,
getClientIp,
} from '../../utils/rateLimit.js';
import { validateCsrfToken, generateCsrfToken } from '../../utils/csrf.js';
} from '../../utils/rateLimit.ts';
import { validateCsrfToken, generateCsrfToken } from '../../utils/csrf.ts';
import type { APIContext } from 'astro';
export async function POST({ request }) {
interface SubmitRequestBody {
csrfToken?: string;
title?: string;
year?: string;
type?: string;
requester?: string;
[key: string]: unknown;
}
interface TMDBResult {
id: number;
media_type: string;
title?: string;
name?: string;
overview?: string;
poster_path?: string;
}
interface TMDBSearchResponse {
results: TMDBResult[];
}
interface ExtendedRequest extends Request {
bodyData?: SubmitRequestBody;
}
export async function POST({ request }: APIContext): Promise<Response> {
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 });
}
@@ -25,7 +52,7 @@ export async function POST({ request }) {
// Validate CSRF token before rate limiting (to avoid consuming rate limit on invalid requests)
let csrfToken;
try {
const body = await request.json();
const body = await request.json() as SubmitRequestBody;
csrfToken = body.csrfToken;
if (!validateCsrfToken(csrfToken, cookieId)) {
@@ -42,8 +69,8 @@ export async function POST({ request }) {
return response;
}
// Re-parse body for later use (since we already consumed the stream)
request.bodyData = body;
// Store parsed body for later use
(request as ExtendedRequest).bodyData = body;
} catch (error) {
const response = new Response(JSON.stringify({
error: 'Invalid request format'
@@ -99,7 +126,8 @@ export async function POST({ request }) {
try {
// Use pre-parsed body data
const { title, year, type, requester } = request.bodyData;
const extendedRequest = request as ExtendedRequest;
const { title, year, type, requester } = extendedRequest.bodyData || {};
// Input validation
if (!title || typeof title !== 'string' || !title.trim()) {
@@ -124,7 +152,7 @@ export async function POST({ request }) {
return response;
}
if (!['movie', 'tv'].includes(type)) {
if (!type || !['movie', 'tv'].includes(type)) {
const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
@@ -160,15 +188,15 @@ export async function POST({ request }) {
// Fetch synopsis and IMDb ID from TMDB
let synopsis = '';
let imdbId = '';
let matchingItem = null;
let matchingItem: TMDBResult | null = null;
try {
const searchResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${import.meta.env.TMDB_API_KEY}&query=${encodeURIComponent(title)}`);
const searchResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${import.meta.env.TMDB_API_KEY}&query=${encodeURIComponent(title!)}`);
if (searchResponse.ok) {
const searchData = await searchResponse.json();
const searchData = await searchResponse.json() as TMDBSearchResponse;
matchingItem = searchData.results.find(item =>
item.media_type === type &&
(item.title || item.name) === title
);
) || null;
if (matchingItem) {
synopsis = matchingItem.overview || '';
@@ -176,8 +204,8 @@ export async function POST({ request }) {
const detailEndpoint = type === 'movie' ? `movie/${matchingItem.id}` : `tv/${matchingItem.id}`;
const detailResponse = await fetch(`https://api.themoviedb.org/3/${detailEndpoint}?api_key=${import.meta.env.TMDB_API_KEY}`);
if (detailResponse.ok) {
const detailData = await detailResponse.json();
imdbId = detailData.imdb_id;
const detailData = await detailResponse.json() as { imdb_id?: string };
imdbId = detailData.imdb_id || '';
}
}
}
@@ -238,13 +266,22 @@ export async function POST({ request }) {
});
}
const embed = {
interface DiscordEmbed {
title: string;
color: number;
fields: Array<{ name: string; value: string; inline: boolean }>;
timestamp: string;
footer: { text: string };
image?: { url: string };
}
const embed: DiscordEmbed = {
title: type === 'tv' ? "📺 New TV Show Request" : "🎥 New Movie Request",
color: type === 'tv' ? 0x4ecdc4 : 0xff6b6b,
fields: fields,
timestamp: new Date().toISOString(),
footer: {
text: subsite.host || 'req.boatson.boats'
text: subsite?.host || 'req.boatson.boats'
}
};

View File

@@ -14,7 +14,7 @@ import { WHITELABELS } from "../config";
const whitelabel = WHITELABELS[host] ?? (detected ? WHITELABELS[detected.host] : null);
---
<Base>
<Base title="Lyric Search">
{whitelabel ? (
<section class="page-section">
<Root child="ReqForm" client:only="react" />

View File

@@ -1,18 +1,31 @@
// Short-term failure cache for API auth timeouts
let lastAuthFailureTime = 0;
const AUTH_FAILURE_CACHE_MS = 30000; // 30 seconds
let lastAuthFailureTime: number = 0;
const AUTH_FAILURE_CACHE_MS: number = 30000; // 30 seconds
/**
* API route authentication helper
* Validates user session for protected API endpoints
*/
import { API_URL } from '@/config';
export interface User {
id?: string;
username?: string;
roles?: string[];
[key: string]: unknown;
}
export interface AuthResult {
user: User | null;
error: Response | null;
setCookieHeader: string | string[] | null;
}
/**
* Check if the request has a valid authentication session
* @param {Request} request - The incoming request
* @returns {Promise<{user: object|null, error: Response|null, setCookieHeader: string|null}>}
* @param request - The incoming request
* @returns Promise with user object, error response, or set-cookie header
*/
export async function requireApiAuth(request) {
export async function requireApiAuth(request: Request): Promise<AuthResult> {
// If we recently failed due to API timeout, immediately fail closed
if (Date.now() - lastAuthFailureTime < AUTH_FAILURE_CACHE_MS) {
return {
@@ -179,11 +192,15 @@ export async function requireApiAuth(request) {
/**
* Helper to create a response with optional Set-Cookie header forwarding
* @param {any} data - Response data
* @param {number} status - HTTP status code
* @param {string|null} setCookieHeader - Set-Cookie header from auth refresh
* @param data - Response data
* @param status - HTTP status code
* @param setCookieHeader - Set-Cookie header from auth refresh
*/
export function createApiResponse(data, status = 200, setCookieHeader = null) {
export function createApiResponse(
data: unknown,
status: number = 200,
setCookieHeader: string | string[] | null = null
): Response {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (setCookieHeader) {
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];

View File

@@ -1,12 +1,20 @@
import { API_URL } from "@/config";
// Track in-flight refresh to avoid duplicate requests
let refreshPromise = null;
let lastRefreshTime = 0;
const REFRESH_COOLDOWN = 2000; // 2 second cooldown between refreshes
let refreshPromise: Promise<boolean> | null = null;
let lastRefreshTime: number = 0;
const REFRESH_COOLDOWN: number = 2000; // 2 second cooldown between refreshes
export interface AuthFetchOptions extends RequestInit {
[key: string]: unknown;
}
// Auth fetch wrapper
export const authFetch = async (url, options = {}, retry = true) => {
export const authFetch = async (
url: string,
options: AuthFetchOptions = {},
retry: boolean = true
): Promise<Response> => {
const res = await fetch(url, {
...options,
credentials: "include", // cookie goes automatically
@@ -31,7 +39,7 @@ export const authFetch = async (url, options = {}, retry = true) => {
};
// Centralized refresh function that handles deduplication properly
async function doRefresh() {
async function doRefresh(): Promise<boolean> {
const now = Date.now();
// If a refresh just succeeded recently, assume we're good
@@ -69,7 +77,7 @@ async function doRefresh() {
}
// Refresh token function (HttpOnly cookie flow)
export async function refreshAccessToken(cookieHeader) {
export async function refreshAccessToken(cookieHeader: string | null): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
@@ -95,7 +103,7 @@ export async function refreshAccessToken(cookieHeader) {
* Makes a lightweight auth check against our own API and refreshes if needed.
* Returns true if auth is valid, false if user needs to log in.
*/
export async function ensureAuth() {
export async function ensureAuth(): Promise<boolean> {
try {
// Try a lightweight request to our own API that requires auth
// Using HEAD or a simple endpoint to minimize overhead
@@ -132,7 +140,7 @@ export async function ensureAuth() {
}
}
export function handleLogout() {
export function handleLogout(): void {
document.cookie.split(";").forEach((cookie) => {
const name = cookie.split("=")[0].trim();
document.cookie = `${name}=; Max-Age=0; path=/;`;

View File

@@ -7,8 +7,13 @@ import crypto from 'crypto';
const CSRF_TOKEN_LENGTH = 32;
const CSRF_TOKEN_EXPIRY = 3600000; // 1 hour in milliseconds
interface TokenData {
sessionId: string;
expiresAt: number;
}
// In-memory token store (for production, use Redis or database)
const tokenStore = new Map();
const tokenStore: Map<string, TokenData> = new Map();
// Cleanup expired tokens periodically
setInterval(() => {
@@ -22,10 +27,10 @@ setInterval(() => {
/**
* Generate a new CSRF token
* @param {string} sessionId - Unique identifier for the user session (e.g., cookie ID)
* @returns {string} The generated CSRF token
* @param sessionId - Unique identifier for the user session (e.g., cookie ID)
* @returns The generated CSRF token
*/
export function generateCsrfToken(sessionId) {
export function generateCsrfToken(sessionId: string): string {
const token = crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('hex');
const expiresAt = Date.now() + CSRF_TOKEN_EXPIRY;
@@ -39,11 +44,11 @@ export function generateCsrfToken(sessionId) {
/**
* Validate a CSRF token
* @param {string} token - The token to validate
* @param {string} sessionId - The session ID to validate against
* @returns {boolean} True if token is valid, false otherwise
* @param token - The token to validate
* @param sessionId - The session ID to validate against
* @returns True if token is valid, false otherwise
*/
export function validateCsrfToken(token, sessionId) {
export function validateCsrfToken(token: string | null | undefined, sessionId: string | null | undefined): boolean {
if (!token || !sessionId) {
return false;
}
@@ -74,6 +79,6 @@ export function validateCsrfToken(token, sessionId) {
/**
* Get token store size (for monitoring)
*/
export function getTokenStoreSize() {
export function getTokenStoreSize(): number {
return tokenStore.size;
}

View File

@@ -2,9 +2,10 @@
* PostgreSQL database connection for Discord logs
*/
import postgres from 'postgres';
import type { Sql } from 'postgres';
// Database connection configuration
const 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',

View File

@@ -19,16 +19,28 @@ if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) {
);
}
interface JwtKeyFile {
keys: Record<string, string>;
}
export interface JwtPayload {
sub?: string;
exp?: number;
iat?: number;
roles?: string[];
[key: string]: unknown;
}
// Load and parse keys JSON once at startup
let keyFileData;
let keyFileData: JwtKeyFile;
try {
keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
} catch (err) {
console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, err.message);
console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, (err as Error).message);
throw new Error('JWT keys file not found or invalid. Set JWT_KEYS_PATH environment variable.');
}
export function verifyToken(token) {
export function verifyToken(token: string | null | undefined): JwtPayload | null {
if (!token) {
return null;
}
@@ -47,11 +59,11 @@ export function verifyToken(token) {
}
// Verify using the correct key and HS256 algo
const payload = jwt.verify(token, key, { algorithms: ['HS256'] });
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
return payload;
} catch (error) {
console.error('JWT verification failed:', error.message);
console.error('JWT verification failed:', (error as Error).message);
return null;
}
}

View File

@@ -3,12 +3,31 @@
* Implements sliding window with burst protection and automatic cleanup.
*/
export interface RateLimitEntry {
count: number;
resetTime: number;
}
export interface RateLimitOptions {
limit?: number;
windowMs?: number;
burstLimit?: number;
burstWindowMs?: number;
}
export interface RateLimitResult {
allowed: boolean;
ip: string;
cookieId: string | null;
isFlooding: boolean;
}
// Separate maps for IP and cookie tracking
const ipRateLimitMap = new Map();
const cookieRateLimitMap = new Map();
const ipRateLimitMap: Map<string, RateLimitEntry> = new Map();
const cookieRateLimitMap: Map<string, RateLimitEntry> = new Map();
// Global flood protection - track overall request volume per IP
const floodProtectionMap = new Map();
const floodProtectionMap: Map<string, RateLimitEntry> = new Map();
// Cleanup old entries every 60 seconds to prevent memory leaks
const CLEANUP_INTERVAL = 60_000;
@@ -33,7 +52,7 @@ const CLOUDFLARE_INDICATOR = typeof import.meta.env.CF_PAGES !== 'undefined' ||
* Check if request is from a trusted proxy.
* In production behind Vercel/Cloudflare, proxy headers are trustworthy.
*/
function isTrustedProxy(request) {
function isTrustedProxy(request: Request): boolean {
// If running on Vercel or Cloudflare, trust their headers
if (VERCEL_INDICATOR || CLOUDFLARE_INDICATOR) return true;
@@ -48,7 +67,7 @@ function isTrustedProxy(request) {
return false;
}
function cleanupStaleEntries() {
function cleanupStaleEntries(): void {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
@@ -68,7 +87,7 @@ function cleanupStaleEntries() {
* Extract client IP from request headers with proxy support.
* Only trusts proxy headers when behind a known/configured proxy.
*/
export function getClientIp(request) {
export function getClientIp(request: Request): string {
const headers = request.headers;
// Only trust proxy headers if we're behind a trusted proxy
@@ -104,7 +123,7 @@ export function getClientIp(request) {
/**
* Basic IP validation to prevent header injection attacks.
*/
function isValidIp(ip) {
function isValidIp(ip: string | null | undefined): boolean {
if (!ip || typeof ip !== 'string') return false;
// Basic sanity check - no weird characters, reasonable length
if (ip.length > 45) return false; // Max IPv6 length
@@ -115,14 +134,14 @@ function isValidIp(ip) {
/**
* Normalize IP for consistent keying (lowercase, trim).
*/
function normalizeIp(ip) {
function normalizeIp(ip: string): string {
return ip.toLowerCase().trim();
}
/**
* Extract nonce cookie from request.
*/
export function getCookieId(request) {
export function getCookieId(request: Request): string | null {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) return null;
@@ -138,7 +157,7 @@ export function getCookieId(request) {
/**
* Check if an identifier is rate limited.
*/
function isLimited(map, key, limit, windowMs) {
function isLimited(map: Map<string, RateLimitEntry>, key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const entry = map.get(key);
@@ -152,7 +171,7 @@ function isLimited(map, key, limit, windowMs) {
/**
* Record a request for rate limiting.
*/
function recordHit(map, key, windowMs) {
function recordHit(map: Map<string, RateLimitEntry>, key: string, windowMs: number): void {
const now = Date.now();
const entry = map.get(key);
@@ -166,7 +185,7 @@ function recordHit(map, key, windowMs) {
/**
* Get current count for an identifier.
*/
function getCount(map, key) {
function getCount(map: Map<string, RateLimitEntry>, key: string): number {
const now = Date.now();
const entry = map.get(key);
if (!entry || now > entry.resetTime) return 0;
@@ -177,7 +196,7 @@ function getCount(map, key) {
* Check flood protection - aggressive rate limit for potential abuse.
* This triggers when an IP sends too many requests in a short burst.
*/
function checkFloodProtection(ip, burstLimit = 30, burstWindowMs = 10_000) {
function checkFloodProtection(ip: string, burstLimit: number = 30, burstWindowMs: number = 10_000): boolean {
if (ip === 'unknown') return false; // Can't flood-protect unknown IPs effectively
const now = Date.now();
@@ -195,15 +214,11 @@ function checkFloodProtection(ip, burstLimit = 30, burstWindowMs = 10_000) {
/**
* Main rate limit check combining IP and cookie tracking.
*
* @param {Request} request - The incoming request
* @param {Object} options - Rate limit configuration
* @param {number} options.limit - Max requests per window
* @param {number} options.windowMs - Window duration in ms
* @param {number} options.burstLimit - Flood protection burst limit
* @param {number} options.burstWindowMs - Flood protection window
* @returns {{ allowed: boolean, ip: string, cookieId: string|null, isFlooding: boolean }}
* @param request - The incoming request
* @param options - Rate limit configuration
* @returns Object with allowed status, ip, cookieId, and flooding flag
*/
export function checkRateLimit(request, options = {}) {
export function checkRateLimit(request: Request, options: RateLimitOptions = {}): RateLimitResult {
const {
limit = 5,
windowMs = 1000,
@@ -239,7 +254,7 @@ export function checkRateLimit(request, options = {}) {
* Record a successful request for rate limiting tracking.
* Call this after the request is processed.
*/
export function recordRequest(request, windowMs = 1000) {
export function recordRequest(request: Request, windowMs: number = 1000): void {
const ip = getClientIp(request);
const cookieId = getCookieId(request);
@@ -254,14 +269,14 @@ export function recordRequest(request, windowMs = 1000) {
/**
* Generate a new nonce cookie value.
*/
export function generateNonce() {
export function generateNonce(): string {
return crypto.randomUUID();
}
/**
* Create Set-Cookie header value for nonce.
*/
export function createNonceCookie(nonce) {
export function createNonceCookie(nonce: string): string {
return `nonce=${nonce}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=31536000`;
}

View File

@@ -1,14 +1,20 @@
import { SUBSITES, WHITELABELS } from '../config.js';
import { SUBSITES, WHITELABELS, WhitelabelConfig } from '../config.ts';
export interface SubsiteInfo {
host: string;
path: string;
short: string;
}
// Returns normalized host (no port)
function normalizeHost(host = '') {
function normalizeHost(host: string = ''): string {
if (!host) return '';
return host.split(':')[0].toLowerCase();
}
const HOSTS = Object.keys(SUBSITES || {});
const HOSTS: string[] = Object.keys(SUBSITES || {});
export function getSubsiteByHost(rawHost = '') {
export function getSubsiteByHost(rawHost: string = ''): SubsiteInfo | null {
const host = normalizeHost(rawHost || '');
if (!host) return null;
if (SUBSITES[host]) return { host, path: SUBSITES[host], short: host.split('.')[0] };
@@ -19,7 +25,7 @@ export function getSubsiteByHost(rawHost = '') {
return null;
}
export function getSubsiteFromSignal(signal = '') {
export function getSubsiteFromSignal(signal: string = ''): SubsiteInfo | null {
if (!signal) return null;
// signal can be 'req' or 'req.boatson.boats'
const val = signal.split(':')[0].split('?')[0];
@@ -32,7 +38,7 @@ export function getSubsiteFromSignal(signal = '') {
return null;
}
export function getSubsiteByPath(path = '') {
export function getSubsiteByPath(path: string = ''): SubsiteInfo | null {
if (!path) return null;
// check if path starts with one of the SUBSITES values
const candidate = Object.entries(SUBSITES || {}).find(([, p]) => path.startsWith(p));
@@ -41,14 +47,14 @@ export function getSubsiteByPath(path = '') {
return { host: hostKey, path: p, short: hostKey.split('.')[0] };
}
export function isSubsiteHost(rawHost = '', shortName = '') {
export function isSubsiteHost(rawHost: string = '', shortName: string = ''): boolean {
const h = getSubsiteByHost(rawHost);
if (!h) return false;
if (!shortName) return true;
return h.short === shortName || h.host === shortName;
}
export function getWhitelabelForHost(rawHost = '') {
export function getWhitelabelForHost(rawHost: string = ''): WhitelabelConfig | null {
const info = getSubsiteByHost(rawHost);
if (!info) return null;
return WHITELABELS[info.host] ?? null;