feat(api): implement rate limiting and SSRF protection across endpoints
- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse. - Introduced SSRF protection in `image-proxy` to block requests to private IP ranges. - Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction. - Refactored authentication checks in various pages to utilize middleware for cleaner code. - Improved JWT key loading with error handling and security warnings for production. - Updated `authFetch` utility to handle token refresh more efficiently with deduplication. - Enhanced rate limiting utility to trust proxy headers from known sources. - Numerous layout / design changes
This commit is contained in:
@@ -8,37 +8,41 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { toast } from 'react-toastify';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import Box from '@mui/joy/Box';
|
||||
import Button from "@mui/joy/Button";
|
||||
import IconButton from "@mui/joy/IconButton";
|
||||
import Checkbox from "@mui/joy/Checkbox";
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
|
||||
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
// Sanitize HTML from external sources to prevent XSS
|
||||
const sanitizeHtml = (html) => {
|
||||
if (!html) return '';
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['br', 'p', 'b', 'i', 'em', 'strong', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['class'],
|
||||
});
|
||||
};
|
||||
|
||||
export default function LyricSearch() {
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="lyric-search">
|
||||
<h2 className="title">
|
||||
<span>Lyric Search</span>
|
||||
</h2>
|
||||
<div className="card-text my-4">
|
||||
<label htmlFor="lyric-search-input">Search:</label>
|
||||
<LyricSearchInputField
|
||||
id="lyric-search-input"
|
||||
placeholder="Artist - Song"
|
||||
setShowLyrics={setShowLyrics}
|
||||
/>
|
||||
<div id="spinner" className="hidden">
|
||||
<CircularProgress variant="plain" color="primary" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-8 text-neutral-900 dark:text-white tracking-tight">
|
||||
Lyric Search
|
||||
</h1>
|
||||
<LyricSearchInputField
|
||||
id="lyric-search-input"
|
||||
placeholder="Artist - Song"
|
||||
setShowLyrics={setShowLyrics}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +57,10 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const [lyricsResult, setLyricsResult] = useState(null);
|
||||
const [textSize, setTextSize] = useState("normal");
|
||||
const [inputStatus, setInputStatus] = useState("hint");
|
||||
const [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration }
|
||||
const [youtubeLoading, setYoutubeLoading] = useState(false);
|
||||
const [highlightedVerse, setHighlightedVerse] = useState(null);
|
||||
const [isLyricsVisible, setIsLyricsVisible] = useState(false);
|
||||
const searchToastRef = useRef(null);
|
||||
const autoCompleteRef = useRef(null);
|
||||
const autoCompleteInputRef = useRef(null);
|
||||
@@ -122,6 +130,39 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
setSuggestions(json);
|
||||
}, []);
|
||||
|
||||
// Fetch YouTube video for the song
|
||||
const fetchYouTubeVideo = useCallback(async (artist, song) => {
|
||||
setYoutubeLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/yt/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ t: `${artist} - ${song}` }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setYoutubeVideo(null);
|
||||
setYoutubeLoading(false);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data?.video_id) {
|
||||
setYoutubeVideo({
|
||||
video_id: data.video_id,
|
||||
title: data.extras?.title || `${artist} - ${song}`,
|
||||
channel: data.extras?.channel || '',
|
||||
duration: data.extras?.duration || '',
|
||||
thumbnail: data.extras?.thumbnails?.[0] || null,
|
||||
});
|
||||
} else {
|
||||
setYoutubeVideo(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('YouTube search failed:', err);
|
||||
setYoutubeVideo(null);
|
||||
}
|
||||
setYoutubeLoading(false);
|
||||
}, []);
|
||||
|
||||
// Toggle exclusion state for checkboxes
|
||||
const toggleExclusion = (source) => {
|
||||
const lower = source.toLowerCase();
|
||||
@@ -194,10 +235,34 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
evaluateSearchValue(value);
|
||||
}, [value, evaluateSearchValue]);
|
||||
|
||||
const handleSearch = async (searchValue = value) => {
|
||||
if (autoCompleteRef.current) {
|
||||
autoCompleteRef.current.hide();
|
||||
// Robustly hide autocomplete panel
|
||||
const hideAutocompletePanel = useCallback(() => {
|
||||
// Method 1: Use PrimeReact API
|
||||
if (autoCompleteRef.current?.hide) {
|
||||
try {
|
||||
autoCompleteRef.current.hide();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Clear suggestions to ensure panel hides
|
||||
setSuggestions([]);
|
||||
|
||||
// Method 3: Force hide via DOM after a tick (fallback)
|
||||
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';
|
||||
}
|
||||
}, 10);
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (searchValue = value) => {
|
||||
hideAutocompletePanel();
|
||||
autoCompleteInputRef.current?.blur(); // blur early so the suggestion panel closes immediately
|
||||
|
||||
const evaluation = evaluateSearchValue(searchValue);
|
||||
@@ -210,6 +275,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const { artist, song } = evaluation;
|
||||
setIsLoading(true);
|
||||
setLyricsResult(null);
|
||||
setYoutubeVideo(null); // Reset YouTube video
|
||||
setShowLyrics(false);
|
||||
|
||||
const toastId = "lyrics-searching-toast";
|
||||
@@ -251,12 +317,21 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
setTextSize("normal");
|
||||
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
||||
setHighlightedVerse(null);
|
||||
setIsLyricsVisible(false);
|
||||
// Trigger fade-in animation
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setIsLyricsVisible(true));
|
||||
});
|
||||
setShowLyrics(true);
|
||||
|
||||
// Update URL hash with search parameters
|
||||
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
|
||||
window.history.pushState(null, '', hash);
|
||||
|
||||
// Search for YouTube video (don't block on this)
|
||||
fetchYouTubeVideo(data.artist, data.song);
|
||||
|
||||
dismissSearchToast();
|
||||
toast.success(`Found! (Took ${duration}s)`, {
|
||||
autoClose: 2500,
|
||||
@@ -270,6 +345,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
hideAutocompletePanel();
|
||||
autoCompleteInputRef.current?.blur();
|
||||
searchButtonRef.current?.blur();
|
||||
searchToastRef.current = null;
|
||||
@@ -279,6 +355,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
hideAutocompletePanel();
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
@@ -348,8 +425,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
onShow={handlePanelShow}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
style={{ width: '100%', maxWidth: '900px' }}
|
||||
inputStyle={{ width: '100%' }}
|
||||
style={{ width: '100%' }}
|
||||
className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`}
|
||||
aria-invalid={inputStatus === "error"}
|
||||
aria-label={`Lyric search input. ${statusTitle}`}
|
||||
@@ -370,30 +446,31 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
{statusTitle}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleSearch()}
|
||||
className="btn"
|
||||
ref={searchButtonRef}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
Exclude:<br />
|
||||
<div id="exclude-checkboxes">
|
||||
<div className="flex items-center gap-4 mt-5">
|
||||
<Button
|
||||
onClick={() => handleSearch()}
|
||||
className="search-btn"
|
||||
ref={searchButtonRef}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700" aria-hidden="true"></div>
|
||||
<div className="exclude-sources">
|
||||
<span className="exclude-label">Exclude:</span>
|
||||
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib-Cache" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-3">
|
||||
<div className="mt-6 flex justify-center">
|
||||
<CircularProgress variant="plain" color="primary" size="md" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lyricsResult && (
|
||||
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
||||
<div className={`lyrics-card lyrics-card-${theme} mt-6 p-5 rounded-xl shadow-lg lyrics-card-animate ${isLyricsVisible ? 'lyrics-card-visible' : ''}`}>
|
||||
<div className="lyrics-toolbar">
|
||||
<div className="lyrics-title">
|
||||
{lyricsResult.artist} - {lyricsResult.song}
|
||||
@@ -439,10 +516,40 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
>
|
||||
<LinkIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{youtubeLoading && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
aria-label="Loading YouTube video..."
|
||||
title="Finding video..."
|
||||
className="lyrics-action-button"
|
||||
disabled
|
||||
sx={{ opacity: 0.3 }}
|
||||
>
|
||||
<PlayCircleOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{!youtubeLoading && youtubeVideo && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
color="neutral"
|
||||
aria-label="Watch on YouTube"
|
||||
title={`Watch: ${youtubeVideo.title}`}
|
||||
className="lyrics-action-button"
|
||||
component="a"
|
||||
href={`https://www.youtube.com/watch?v=${youtubeVideo.video_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<PlayCircleOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(lyricsResult.lyrics) }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -460,10 +567,11 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
||||
}));
|
||||
|
||||
const verifyExclusions = () => {
|
||||
const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]");
|
||||
const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
|
||||
const checkboxes = document.querySelectorAll(".exclude-chip");
|
||||
const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
|
||||
|
||||
if (checkedCount === 3) {
|
||||
// Reset all by triggering clicks
|
||||
checkboxes.forEach(cb => cb.click());
|
||||
if (!toast.isActive("lyrics-exclusion-reset-toast")) {
|
||||
toast.error("All sources were excluded; exclusions have been reset.",
|
||||
@@ -473,27 +581,27 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const newChecked = e.target.checked;
|
||||
const handleClick = () => {
|
||||
const newChecked = !checked;
|
||||
setChecked(newChecked);
|
||||
if (props.onToggle) {
|
||||
const source = props.label; // Use label as source identifier
|
||||
const source = props.label;
|
||||
props.onToggle(source);
|
||||
}
|
||||
verifyExclusions();
|
||||
setTimeout(verifyExclusions, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Checkbox
|
||||
id={props.id}
|
||||
key={props.label}
|
||||
checked={checked}
|
||||
label={props.label}
|
||||
style={{ color: "inherit" }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id={props.id}
|
||||
className={`exclude-chip ${checked ? 'exclude-chip--active' : ''}`}
|
||||
data-checked={checked}
|
||||
onClick={handleClick}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user