begin js(x) to ts(x)
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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',
|
||||
@@ -146,6 +417,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(/<@&(\d+)>/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] }));
|
||||
}, []);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}>
|
||||
@@ -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 {
|
||||
19
src/components/RadioBanner.tsx
Normal file
19
src/components/RadioBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`, {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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}`}
|
||||
>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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/',
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
15
src/hooks/useHtmlThemeAttr.tsx
Normal file
15
src/hooks/useHtmlThemeAttr.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 =
|
||||
@@ -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;
|
||||
25
src/layouts/WhitelabelLayout.tsx
Normal file
25
src/layouts/WhitelabelLayout.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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'];
|
||||
@@ -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',
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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];
|
||||
@@ -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=/;`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user