misc
This commit is contained in:
@@ -5,6 +5,15 @@
|
||||
window.toggleTheme = () => {
|
||||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
||||
|
||||
// Toggle the dark class for Tailwind
|
||||
if (newTheme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
// Dispatch event for astro-themes and other listeners
|
||||
document.dispatchEvent(new CustomEvent("set-theme", { detail: newTheme }));
|
||||
};
|
||||
|
||||
@@ -80,5 +89,7 @@
|
||||
ready();
|
||||
}
|
||||
|
||||
// Support both original and obfuscated event names
|
||||
document.addEventListener("astro:page-load", initMobileMenu);
|
||||
document.addEventListener("c:ready", initMobileMenu);
|
||||
})();
|
||||
|
||||
95
public/scripts/theme-init.js
Normal file
95
public/scripts/theme-init.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Theme initialization script - must run before page renders
|
||||
* This prevents flash of wrong theme colors
|
||||
*
|
||||
* Replaces astro-themes functionality with external file to reduce HTML size
|
||||
*/
|
||||
(function() {
|
||||
var d = document.documentElement;
|
||||
var stored = localStorage.getItem('theme');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var isDark = stored === 'dark' || (!stored && prefersDark);
|
||||
var theme = isDark ? 'dark' : 'light';
|
||||
|
||||
// Set theme immediately
|
||||
d.setAttribute('data-theme', theme);
|
||||
d.style.colorScheme = theme;
|
||||
if (isDark) {
|
||||
d.classList.add('dark');
|
||||
} else {
|
||||
d.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Mark ready when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
d.classList.add('ready');
|
||||
});
|
||||
} else {
|
||||
d.classList.add('ready');
|
||||
}
|
||||
|
||||
// Handle theme changes from other tabs
|
||||
window.addEventListener('storage', function(e) {
|
||||
if (e.key === 'theme') {
|
||||
var newTheme = e.newValue || (prefersDark ? 'dark' : 'light');
|
||||
d.setAttribute('data-theme', newTheme);
|
||||
d.style.colorScheme = newTheme;
|
||||
if (newTheme === 'dark') {
|
||||
d.classList.add('dark');
|
||||
} else {
|
||||
d.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle system preference changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
var newTheme = this.matches ? 'dark' : 'light';
|
||||
d.setAttribute('data-theme', newTheme);
|
||||
d.style.colorScheme = newTheme;
|
||||
if (newTheme === 'dark') {
|
||||
d.classList.add('dark');
|
||||
} else {
|
||||
d.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle set-theme custom event (for toggle button)
|
||||
document.addEventListener('set-theme', function(e) {
|
||||
var newTheme = e.detail;
|
||||
if (newTheme) {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
} else {
|
||||
localStorage.removeItem('theme');
|
||||
newTheme = prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
d.setAttribute('data-theme', newTheme);
|
||||
d.style.colorScheme = newTheme;
|
||||
if (newTheme === 'dark') {
|
||||
d.classList.add('dark');
|
||||
} else {
|
||||
d.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
|
||||
// Re-apply theme after client-side navigation
|
||||
// Support both original and obfuscated event names
|
||||
var swapHandler = function() {
|
||||
var s = localStorage.getItem('theme');
|
||||
var pDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var t = s || (pDark ? 'dark' : 'light');
|
||||
d.setAttribute('data-theme', t);
|
||||
d.style.colorScheme = t;
|
||||
if (t === 'dark') {
|
||||
d.classList.add('dark');
|
||||
} else {
|
||||
d.classList.remove('dark');
|
||||
}
|
||||
d.classList.add('ready');
|
||||
};
|
||||
document.addEventListener('astro:after-swap', swapHandler);
|
||||
document.addEventListener('c:swap', swapHandler);
|
||||
})();
|
||||
@@ -272,6 +272,38 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Mobile: wrap channel name and topic */
|
||||
@media (max-width: 768px) {
|
||||
.discord-header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discord-header-info {
|
||||
overflow: hidden;
|
||||
max-width: calc(100% - 60px);
|
||||
}
|
||||
|
||||
.discord-channel-name {
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.discord-topic-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.discord-channel-topic {
|
||||
flex-basis: 100%;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] .discord-channel-name {
|
||||
color: #f2f3f5;
|
||||
}
|
||||
@@ -1417,6 +1449,57 @@ a.discord-embed-title:hover {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* YouTube thumbnail click-to-play */
|
||||
.discord-embed-video-thumbnail-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.discord-embed-video-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.discord-embed-video-thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.discord-embed-video-thumbnail-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.discord-embed-video-play-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 68px;
|
||||
height: 48px;
|
||||
transition: transform 0.2s ease;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.discord-embed-video-thumbnail:hover .discord-embed-video-play-overlay {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
.discord-embed-video-play-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Reactions */
|
||||
.discord-reactions {
|
||||
display: flex;
|
||||
@@ -1470,6 +1553,17 @@ a.discord-embed-title:hover {
|
||||
color: #80848e;
|
||||
position: relative;
|
||||
margin-left: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.discord-reply-context:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .discord-reply-context:hover {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.discord-reply-context::before {
|
||||
@@ -1716,24 +1810,65 @@ a.discord-embed-title:hover {
|
||||
margin: 0.25rem 0;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
max-width: 100%;
|
||||
overscroll-behavior: contain;
|
||||
/* prefer modern developer-friendly monospace fonts, avoid ligatures for ASCII art */
|
||||
font-family: ui-monospace, "JetBrains Mono", "Fira Code", "Roboto Mono", "Consolas", "Monaco", "Courier New", monospace;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
font-size: 0.92rem; /* readable size for ASCII art */
|
||||
line-height: 1.04; /* tighter line height to preserve vertical alignment */
|
||||
white-space: pre;
|
||||
line-height: 1.125;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
tab-size: 4;
|
||||
-moz-tab-size: 4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for code blocks */
|
||||
.discord-code-block::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.discord-code-block::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.discord-code-block::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.discord-code-block::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.discord-code-block::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-theme="light"] .discord-code-block::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .discord-code-block::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="light"] .discord-code-block::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.discord-code-block code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #b5bac1;
|
||||
white-space: pre; /* keep exact spacing */
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
@@ -2351,6 +2486,7 @@ a.discord-embed-title:hover {
|
||||
}
|
||||
|
||||
.discord-copy-link-btn,
|
||||
.discord-jump-first-btn,
|
||||
.discord-member-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2365,17 +2501,24 @@ a.discord-embed-title:hover {
|
||||
}
|
||||
|
||||
[data-theme="dark"] .discord-copy-link-btn,
|
||||
[data-theme="dark"] .discord-jump-first-btn,
|
||||
[data-theme="dark"] .discord-member-toggle-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #b5bac1;
|
||||
}
|
||||
|
||||
.discord-copy-link-btn:hover,
|
||||
.discord-jump-first-btn:hover,
|
||||
.discord-member-toggle-btn:hover {
|
||||
background: rgba(88, 101, 242, 0.15);
|
||||
color: #5865f2;
|
||||
}
|
||||
|
||||
.discord-jump-first-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.discord-copy-link-btn.copied {
|
||||
background: rgba(35, 165, 90, 0.2);
|
||||
color: #23a55a;
|
||||
|
||||
@@ -465,6 +465,16 @@ Custom
|
||||
font-size: 1.1rem;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.lyrics-title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-actions {
|
||||
@@ -647,6 +657,91 @@ Custom
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
PrimeReact Dialog + DataTable dark theme fixes (global)
|
||||
These ensure dialogs rendered via portal (class .p-dialog) pick up
|
||||
site dark theme when the document has [data-theme="dark"].
|
||||
=================================================== */
|
||||
|
||||
/* Paginator specifics used by the player queue (global variant) */
|
||||
.queue-paginator .p-paginator-current {
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .queue-paginator .p-paginator-current {
|
||||
color: rgb(212 212 212) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.queue-paginator .p-paginator-bottom {
|
||||
padding: 16px !important;
|
||||
border-top: 1px solid rgb(229 229 229) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .queue-paginator .p-paginator-bottom {
|
||||
border-top-color: rgb(82 82 82) !important;
|
||||
}
|
||||
|
||||
/* DataTable + Dialog - make table dark in any portal dialog */
|
||||
|
||||
/* Improved global dark mode for PrimeReact DataTable in dialogs (matches RequestManagement look) */
|
||||
[data-theme="dark"] .p-dialog .p-datatable {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-datatable-thead > tr > th {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-datatable-tbody > tr {
|
||||
background-color: #1a1a1a !important;
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-datatable-tbody > tr:nth-child(odd) {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-datatable-tbody > tr:hover {
|
||||
background-color: #333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-datatable .p-datatable-header,
|
||||
[data-theme="dark"] .p-dialog .p-datatable .p-datatable-footer {
|
||||
background: #1f1f1f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Paginator Dark Mode for dialogs */
|
||||
[data-theme="dark"] .p-dialog .p-paginator {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-last {
|
||||
color: #e5e7eb !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-paginator-prev:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
[data-theme="dark"] .p-dialog .p-paginator .p-highlight {
|
||||
background-color: #6b7280 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
.trip-checkbox {
|
||||
appearance: none;
|
||||
width: 2.1rem;
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function Root({ child, user = undefined, ...props }) {
|
||||
)}
|
||||
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
|
||||
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
|
||||
{child == "ReqForm" && <ReqForm client:only="react" />}
|
||||
{child == "ReqForm" && <ReqForm {...props} client:only="react" />}
|
||||
{child == "Lighting" && <Lighting key={window.location.pathname + Math.random()} client:only="react" />}
|
||||
</JoyUIRootIsland>
|
||||
</PrimeReactProvider>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
|
||||
import "@styles/player.css";
|
||||
import "@/components/TRip/RequestManagement.css";
|
||||
import { metaData } from "../config";
|
||||
import Play from "@mui/icons-material/PlayArrow";
|
||||
import Pause from "@mui/icons-material/Pause";
|
||||
import "@styles/player.css";
|
||||
import { Dialog } from "primereact/dialog";
|
||||
import { AutoComplete } from "primereact/autocomplete";
|
||||
import { DataTable } from "primereact/datatable";
|
||||
@@ -14,30 +14,7 @@ import { API_URL } from "@/config";
|
||||
import { authFetch } from "@/utils/authFetch";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
|
||||
import "@/components/TRip/RequestManagement.css";
|
||||
|
||||
// Custom styles for paginator
|
||||
const paginatorStyles = `
|
||||
.queue-paginator .p-paginator-current {
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.dark .queue-paginator .p-paginator-current {
|
||||
color: rgb(212 212 212) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.queue-paginator .p-paginator-bottom {
|
||||
padding: 16px !important;
|
||||
border-top: 1px solid rgb(229 229 229) !important;
|
||||
}
|
||||
|
||||
.dark .queue-paginator .p-paginator-bottom {
|
||||
border-top-color: rgb(82 82 82) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const STATIONS = {
|
||||
main: { label: "Main" },
|
||||
@@ -49,16 +26,7 @@ const STATIONS = {
|
||||
|
||||
|
||||
export default function Player({ user }) {
|
||||
// Inject custom paginator styles
|
||||
useEffect(() => {
|
||||
const styleId = 'queue-paginator-styles';
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = paginatorStyles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}, []);
|
||||
// Global CSS now contains the paginator / dialog datatable dark rules.
|
||||
|
||||
const [isQueueVisible, setQueueVisible] = useState(false);
|
||||
// Mouse wheel scroll fix for queue modal
|
||||
@@ -749,7 +717,7 @@ export default function Player({ user }) {
|
||||
|
||||
<div className="music-time flex justify-between items-center mt-4">
|
||||
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
|
||||
<p className="music-time__last text-sm">-{formatTime(trackDuration - elapsedTime)}</p>
|
||||
<p className="music-time__last text-sm">- {formatTime(trackDuration - elapsedTime)}</p>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
||||
@@ -870,7 +838,8 @@ export default function Player({ user }) {
|
||||
style={{ width: "80vw", maxWidth: "1200px", height: "auto", maxHeight: "90vh" }}
|
||||
footer={queueFooter}
|
||||
onHide={() => setQueueVisible(false)}
|
||||
className={theme === "dark" ? "dark-theme" : "light-theme"}
|
||||
// Use the same dark class used by other dialog styles (CSS escapes the colon)
|
||||
className={theme === "dark" ? "dark:bg-neutral-900" : "light-theme"}
|
||||
dismissableMask={true}
|
||||
>
|
||||
<div style={{ maxHeight: "calc(90vh - 100px)", overflow: "visible" }}>
|
||||
|
||||
@@ -3,6 +3,13 @@ import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { authFetch } from '@/utils/authFetch';
|
||||
|
||||
// ============================================================================
|
||||
// Discord Message Type Constants
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
const MESSAGE_TYPE_DEFAULT = 0;
|
||||
const MESSAGE_TYPE_REPLY = 19;
|
||||
const MESSAGE_TYPE_CHAT_INPUT_COMMAND = 20;
|
||||
const MESSAGE_TYPE_CONTEXT_MENU_COMMAND = 23;
|
||||
const MESSAGE_TYPE_POLL_RESULT = 46;
|
||||
|
||||
// Image modal context for child components to trigger modal
|
||||
const ImageModalContext = createContext(null);
|
||||
@@ -413,26 +420,28 @@ function resolveArchivedUser(archivedUsername, usersMap, members) {
|
||||
* @param {Map} options.channelMap - Map of channel IDs to channel objects
|
||||
* @param {Object} options.usersMap - Map of user IDs to user objects { displayName, username, color }
|
||||
* @param {Object} options.rolesMap - Map of role IDs to role objects { name, color }
|
||||
* @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 = {}) {
|
||||
if (!text) return '';
|
||||
|
||||
// Normalize HTML entities that sometimes make it into messages/embed fields
|
||||
// We decode before we escape so strings like "A & B" become "A & B"
|
||||
// and avoid double-encoding when we later run an escape pass.
|
||||
try {
|
||||
// Normalize HTML entities that sometimes make it into messages/embed fields
|
||||
// We decode before we escape so strings like "A & B" become "A & B"
|
||||
// and avoid double-encoding when we later run an escape pass.
|
||||
|
||||
const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), onChannelClick } = options;
|
||||
const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), emojiCache = {}, onChannelClick } = options;
|
||||
|
||||
// Normalize entities then escape HTML to avoid XSS while ensuring
|
||||
// already-encoded entities don't become double-encoded in the UI.
|
||||
const normalized = decodeHtmlEntities(text);
|
||||
// Normalize entities then escape HTML to avoid XSS while ensuring
|
||||
// already-encoded entities don't become double-encoded in the UI.
|
||||
const normalized = decodeHtmlEntities(text);
|
||||
|
||||
// Escape HTML first
|
||||
let parsed = normalized
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
// Escape HTML first
|
||||
let parsed = normalized
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
|
||||
// Must be processed first to prevent other formatting inside code
|
||||
@@ -593,22 +602,39 @@ function parseDiscordMarkdown(text, options = {}) {
|
||||
return `<span class="discord-mention">#${channelName}</span>`;
|
||||
});
|
||||
|
||||
// Role mentions (<@&123456789>)
|
||||
// Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
|
||||
parsed = parsed.replace(/<@&(\d+)>/g, (_, roleId) => {
|
||||
const role = rolesMap?.get?.(roleId) || rolesMap?.[roleId];
|
||||
const roleName = role?.name || 'role';
|
||||
const roleColor = role?.color || null;
|
||||
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
|
||||
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
|
||||
try {
|
||||
let role = 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)];
|
||||
}
|
||||
}
|
||||
const roleName = role?.name || 'role';
|
||||
const roleColor = role?.color || null;
|
||||
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
|
||||
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
|
||||
} catch (err) {
|
||||
// Defensive: log for telemetry/debug and return safe fallback
|
||||
try { console.error('parseDiscordMarkdown: role mention parse failed', { roleId, err, rolesMapType: rolesMap && typeof rolesMap }); } catch (e) { /* ignore logging errors */ }
|
||||
return `<span class="discord-mention discord-role-mention">@role</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Slash command mentions (</command:123456789>)
|
||||
parsed = parsed.replace(/<\/([^:]+):(\d+)>/g, '<span class="discord-slash-command">/$1</span>');
|
||||
|
||||
// Custom emoji (<:name:123456789> or <a:name:123456789>)
|
||||
// Use cached emoji URL if available, otherwise fall back to Discord CDN
|
||||
parsed = parsed.replace(/<(a)?:(\w+):(\d+)>/g, (_, animated, name, id) => {
|
||||
const ext = animated ? 'gif' : 'png';
|
||||
return `<img class="discord-emoji" src="https://cdn.discordapp.com/emojis/${id}.${ext}" alt=":${name}:" title=":${name}:">`;
|
||||
const cached = emojiCache[id];
|
||||
const url = cached?.url || `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`;
|
||||
return `<img class="discord-emoji" src="${url}" alt=":${name}:" title=":${name}:">`;
|
||||
});
|
||||
|
||||
// Unicode emoji (keep as-is, they render natively)
|
||||
@@ -632,11 +658,20 @@ function parseDiscordMarkdown(text, options = {}) {
|
||||
// Newlines
|
||||
parsed = parsed.replace(/\n/g, '<br>');
|
||||
|
||||
// Unescape Discord markdown escape sequences (\_ \* \~ \` \| \\)
|
||||
// Unescape Discord markdown escape sequences (\\_ \\* \\~ \\` \\| \\\\)
|
||||
// Must be done after all markdown processing
|
||||
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
|
||||
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ }
|
||||
// Fallback: return a safely-escaped version of the input to avoid crashing the UI
|
||||
const safe = String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,27 +778,42 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
|
||||
return null; // Don't show anything for failed previews
|
||||
}
|
||||
|
||||
// YouTube embed - trusted, use iframe
|
||||
// YouTube embed - use click-to-play thumbnail
|
||||
if (preview.type === 'youtube' && preview.videoId) {
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${preview.videoId}/maxresdefault.jpg`;
|
||||
const watchUrl = `https://www.youtube.com/watch?v=${preview.videoId}`;
|
||||
|
||||
return (
|
||||
<div className="discord-embed discord-embed-video" style={{ borderColor: preview.themeColor || '#FF0000' }}>
|
||||
<div className="discord-embed-content">
|
||||
<div className="discord-embed-provider">YouTube</div>
|
||||
{preview.title && (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="discord-embed-title">
|
||||
<a href={watchUrl} target="_blank" rel="noopener noreferrer" className="discord-embed-title">
|
||||
{decodeHtmlEntities(preview.title)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="discord-embed-video-container">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${preview.videoId}`}
|
||||
title={decodeHtmlEntities(preview.title) || 'YouTube video'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="discord-embed-iframe"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="discord-embed-video-thumbnail-link"
|
||||
title="Watch on YouTube"
|
||||
>
|
||||
<div className="discord-embed-video-container discord-embed-video-thumbnail">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={decodeHtmlEntities(preview.title) || 'YouTube video'}
|
||||
className="discord-embed-video-thumbnail-img"
|
||||
/>
|
||||
<div className="discord-embed-video-play-overlay">
|
||||
<svg viewBox="0 0 68 48" className="discord-embed-video-play-icon">
|
||||
<path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path>
|
||||
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -866,7 +916,7 @@ const Attachment = memo(function Attachment({ attachment }) {
|
||||
const openImageModal = useContext(ImageModalContext);
|
||||
|
||||
const isImage = content_type?.startsWith('image/') || IMAGE_EXTENSIONS.test(filename || url);
|
||||
const isVideo = content_type?.startsWith('video/') || VIDEO_EXTENSIONS.test(filename || url);
|
||||
const isVideo = content_type?.startsWith('video/') || VIDEO_EXTENSIONS.test(filename || url) || (url && url.includes('/api/discord/cached-video'));
|
||||
const isAudio = content_type?.startsWith('audio/') || AUDIO_EXTENSIONS.test(filename || url);
|
||||
|
||||
if (isImage) {
|
||||
@@ -958,6 +1008,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
onPreviewLoad,
|
||||
channelMap,
|
||||
usersMap,
|
||||
emojiCache,
|
||||
members,
|
||||
onChannelSelect,
|
||||
channelName,
|
||||
@@ -1019,6 +1070,9 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
}
|
||||
});
|
||||
|
||||
// Exclude URLs that match any attachment's originalUrl
|
||||
const attachmentOriginalUrls = new Set(attachments?.map(a => a.originalUrl).filter(Boolean));
|
||||
|
||||
return urls.filter(url => {
|
||||
// Skip if exact URL match
|
||||
if (embedUrls.has(url)) return false;
|
||||
@@ -1027,18 +1081,23 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
const ytId = getYouTubeId(url);
|
||||
if (ytId && embedYouTubeIds.has(ytId)) return false;
|
||||
|
||||
// Skip if URL matches any attachment originalUrl
|
||||
if (attachmentOriginalUrls.has(url)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [urls, embeds]);
|
||||
}, [urls, embeds, attachments]);
|
||||
|
||||
// Build rolesMap from members data for role mention parsing
|
||||
// Build rolesMap from members data for role mention parsing (defensive)
|
||||
const rolesMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
members?.roles?.forEach(role => map.set(role.id, role));
|
||||
if (Array.isArray(members?.roles)) {
|
||||
members.roles.forEach(role => map.set(role?.id, role));
|
||||
}
|
||||
return map;
|
||||
}, [members?.roles]);
|
||||
|
||||
const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap }), [displayContent, channelMap, usersMap, rolesMap]);
|
||||
const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap, emojiCache }), [displayContent, channelMap, usersMap, rolesMap, emojiCache]);
|
||||
|
||||
// Handle channel link clicks
|
||||
const handleContentClick = useCallback((e) => {
|
||||
@@ -1069,9 +1128,15 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
// System messages (join, boost, etc.)
|
||||
// Type 0 = default, 19 = reply, 20 = chat input command, 23 = context menu command
|
||||
// Types 20 and 23 are app/bot command messages and should render normally
|
||||
if (type && type !== 0 && type !== 19 && type !== 20 && type !== 23) {
|
||||
if (
|
||||
type &&
|
||||
type !== MESSAGE_TYPE_DEFAULT &&
|
||||
type !== MESSAGE_TYPE_REPLY &&
|
||||
type !== MESSAGE_TYPE_CHAT_INPUT_COMMAND &&
|
||||
type !== MESSAGE_TYPE_CONTEXT_MENU_COMMAND
|
||||
) {
|
||||
// Special handling for poll result system messages (type 46)
|
||||
if (type === 46) {
|
||||
if (type === MESSAGE_TYPE_POLL_RESULT) {
|
||||
// Find the poll_result embed
|
||||
const pollResultEmbed = embeds?.find(e => e.type === 'poll_result');
|
||||
let pollFields = {};
|
||||
@@ -1163,7 +1228,15 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<>
|
||||
{/* Reply context */}
|
||||
{referenced_message && (
|
||||
<div className="discord-reply-context">
|
||||
<div
|
||||
className="discord-reply-context"
|
||||
onClick={() => {
|
||||
if (referenced_message.id && onJumpToMessage) {
|
||||
onJumpToMessage(referenced_message.id);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: referenced_message.id ? 'pointer' : 'default' }}
|
||||
>
|
||||
<img
|
||||
src={referenced_message.author?.avatar
|
||||
? (referenced_message.author.avatar.startsWith('http')
|
||||
@@ -1182,11 +1255,12 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: referenced_message.content
|
||||
? parseDiscordMarkdown(
|
||||
referenced_message.content.length > 100
|
||||
(referenced_message.content.length > 100
|
||||
? referenced_message.content.slice(0, 100) + '...'
|
||||
: referenced_message.content,
|
||||
{ channelMap, usersMap, rolesMap }
|
||||
)
|
||||
: referenced_message.content
|
||||
).replace(/\n/g, ' '),
|
||||
{ channelMap, usersMap, rolesMap, emojiCache }
|
||||
).replace(/<br\s*\/?>/gi, ' ')
|
||||
: 'Click to see attachment'
|
||||
}}
|
||||
/>
|
||||
@@ -1303,7 +1377,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
{poll.question.emoji && (
|
||||
poll.question.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${poll.question.emoji.id}.${poll.question.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={emojiCache[poll.question.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${poll.question.emoji.id}.${poll.question.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={poll.question.emoji.name}
|
||||
className="discord-poll-emoji"
|
||||
/>
|
||||
@@ -1331,7 +1405,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
{answer.emoji && (
|
||||
answer.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={emojiCache[answer.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={answer.emoji.name}
|
||||
className="discord-poll-answer-emoji"
|
||||
/>
|
||||
@@ -1422,7 +1496,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<div
|
||||
className="discord-embed-description"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: parseDiscordMarkdown(embed.description, { channelMap, usersMap, rolesMap })
|
||||
__html: parseDiscordMarkdown(embed.description, { channelMap, usersMap, rolesMap, emojiCache })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1441,7 +1515,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<div
|
||||
className="discord-embed-field-value"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: parseDiscordMarkdown(field.value, { channelMap, usersMap, rolesMap })
|
||||
__html: parseDiscordMarkdown(field.value, { channelMap, usersMap, rolesMap, emojiCache })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1463,15 +1537,40 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<img src={embed.image.url} alt="" className="discord-embed-image" />
|
||||
)}
|
||||
{embed.video?.url && (
|
||||
// Check if it's a YouTube embed URL - use iframe instead of video
|
||||
// Check if it's a YouTube URL - use click-to-play thumbnail
|
||||
embed.video.url.includes('youtube.com/embed/') || embed.video.url.includes('youtu.be') ? (
|
||||
<iframe
|
||||
src={embed.video.url}
|
||||
title={embed.title || 'YouTube video'}
|
||||
className="discord-embed-video-iframe"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
(() => {
|
||||
// Extract video ID from embed URL
|
||||
const videoId = embed.video.url.includes('youtube.com/embed/')
|
||||
? embed.video.url.split('/embed/')[1]?.split('?')[0]
|
||||
: embed.video.url.split('/').pop()?.split('?')[0];
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="discord-embed-video-thumbnail-link"
|
||||
title="Watch on YouTube"
|
||||
>
|
||||
<div className="discord-embed-video-thumbnail">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={embed.title || 'YouTube video'}
|
||||
className="discord-embed-video-thumbnail-img"
|
||||
/>
|
||||
<div className="discord-embed-video-play-overlay">
|
||||
<svg viewBox="0 0 68 48" className="discord-embed-video-play-icon">
|
||||
<path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path>
|
||||
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<video src={embed.video.url} controls className="discord-embed-video-player" />
|
||||
)
|
||||
@@ -1518,7 +1617,7 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
>
|
||||
{reaction.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${reaction.emoji.id}.${reaction.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={reaction.emoji.url || `https://cdn.discordapp.com/emojis/${reaction.emoji.id}.${reaction.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={reaction.emoji.name}
|
||||
className="discord-reaction-emoji"
|
||||
/>
|
||||
@@ -1551,6 +1650,7 @@ export default function DiscordLogs() {
|
||||
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 [loadingMembers, setLoadingMembers] = useState(false);
|
||||
const [memberListExpanded, setMemberListExpanded] = useState(false); // Default to collapsed
|
||||
@@ -1559,6 +1659,8 @@ export default function DiscordLogs() {
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
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 [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState(null); // Server-side search results
|
||||
@@ -1722,6 +1824,46 @@ export default function DiscordLogs() {
|
||||
setContextMenu(null);
|
||||
}, [contextMenu]);
|
||||
|
||||
// Helper to scroll to a message element with correction for layout shifts
|
||||
const scrollToMessageElement = useCallback((element, highlightDuration = 2000) => {
|
||||
if (!element) return;
|
||||
|
||||
// First scroll immediately to get close
|
||||
element.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
|
||||
// Add highlight effect
|
||||
element.classList.add('discord-message-highlight');
|
||||
setTimeout(() => element.classList.remove('discord-message-highlight'), highlightDuration);
|
||||
|
||||
// After a delay for images to start loading, scroll again to correct for layout shifts
|
||||
setTimeout(() => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
// Helper to wait for an element to exist in DOM then scroll to it
|
||||
const waitForElementAndScroll = useCallback((messageId, highlightDuration = 2000, maxAttempts = 20) => {
|
||||
let attempts = 0;
|
||||
|
||||
const tryScroll = () => {
|
||||
const element = document.getElementById(`message-${messageId}`);
|
||||
if (element) {
|
||||
scrollToMessageElement(element, highlightDuration);
|
||||
return true;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
requestAnimationFrame(tryScroll);
|
||||
return false;
|
||||
}
|
||||
console.warn(`Could not find message-${messageId} after ${maxAttempts} attempts`);
|
||||
return false;
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryScroll);
|
||||
}, [scrollToMessageElement]);
|
||||
|
||||
// Jump to a specific message (used from search results and poll result view)
|
||||
const jumpToMessage = useCallback((messageId) => {
|
||||
if (!messageId) return;
|
||||
@@ -1732,10 +1874,7 @@ export default function DiscordLogs() {
|
||||
// Message is already loaded, just scroll to it
|
||||
const element = document.getElementById(`message-${messageId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Add a brief highlight effect
|
||||
element.classList.add('discord-message-highlight');
|
||||
setTimeout(() => element.classList.remove('discord-message-highlight'), 2000);
|
||||
scrollToMessageElement(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1749,7 +1888,7 @@ export default function DiscordLogs() {
|
||||
// Trigger a re-fetch by incrementing the counter
|
||||
setRefetchCounter(c => c + 1);
|
||||
setLoadingMessages(true);
|
||||
}, [messages]);
|
||||
}, [messages, scrollToMessageElement]);
|
||||
|
||||
// Handle channel context menu (right-click)
|
||||
const handleChannelContextMenu = useCallback((e, channel) => {
|
||||
@@ -1856,10 +1995,12 @@ export default function DiscordLogs() {
|
||||
return map;
|
||||
}, [channels]);
|
||||
|
||||
// Create roles lookup map for role mentions
|
||||
// Create roles lookup map for role mentions (defensive)
|
||||
const rolesMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
members?.roles?.forEach(role => map.set(role.id, role));
|
||||
if (Array.isArray(members?.roles)) {
|
||||
members.roles.forEach(role => map.set(role?.id, role));
|
||||
}
|
||||
return map;
|
||||
}, [members?.roles]);
|
||||
|
||||
@@ -2073,7 +2214,9 @@ export default function DiscordLogs() {
|
||||
async function fetchMessages() {
|
||||
setMessages([]);
|
||||
setUsersMap({});
|
||||
setEmojiCache({});
|
||||
setHasMoreMessages(true);
|
||||
setHasNewerMessages(false); // Loading latest messages
|
||||
|
||||
// Capture target message ID from ref (for deep-linking)
|
||||
const targetMessageId = pendingTargetMessageRef.current;
|
||||
@@ -2089,9 +2232,10 @@ export default function DiscordLogs() {
|
||||
if (!response.ok) throw new Error('Failed to fetch messages');
|
||||
const data = await response.json();
|
||||
|
||||
// Handle new response format { messages, users }
|
||||
// Handle new response format { messages, users, emojiCache }
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
// If we were looking for a specific message but got no results,
|
||||
// fall back to loading the latest messages
|
||||
@@ -2103,6 +2247,7 @@ export default function DiscordLogs() {
|
||||
const fallbackData = await fallbackResponse.json();
|
||||
const fallbackMessages = fallbackData.messages || fallbackData;
|
||||
const fallbackUsers = fallbackData.users || {};
|
||||
const fallbackEmojis = fallbackData.emojiCache || {};
|
||||
const normalizedFallback = fallbackMessages.map(msg => ({
|
||||
...msg,
|
||||
referenced_message: msg.referencedMessage || msg.referenced_message,
|
||||
@@ -2113,6 +2258,7 @@ export default function DiscordLogs() {
|
||||
}));
|
||||
setMessages(normalizedFallback.reverse());
|
||||
setUsersMap(fallbackUsers);
|
||||
setEmojiCache(fallbackEmojis);
|
||||
setHasMoreMessages(fallbackMessages.length === 50);
|
||||
scrollToBottomRef.current = true;
|
||||
lastPollTimeRef.current = new Date().toISOString();
|
||||
@@ -2138,6 +2284,7 @@ export default function DiscordLogs() {
|
||||
|
||||
setMessages(orderedMessages);
|
||||
setUsersMap(usersData);
|
||||
setEmojiCache(emojiCacheData);
|
||||
setHasMoreMessages(messagesData.length === 50);
|
||||
|
||||
// Reset poll time for edit detection
|
||||
@@ -2172,28 +2319,28 @@ export default function DiscordLogs() {
|
||||
// Handle target message (deep-linking)
|
||||
const targetMessageId = pendingTargetMessageRef.current;
|
||||
if (targetMessageId) {
|
||||
// Use requestAnimationFrame to ensure DOM is fully painted
|
||||
requestAnimationFrame(() => {
|
||||
const targetElement = document.getElementById(`message-${targetMessageId}`);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Highlight the message with pulse + fade animation
|
||||
targetElement.classList.add('discord-message-highlight');
|
||||
setTimeout(() => {
|
||||
targetElement.classList.remove('discord-message-highlight');
|
||||
}, 5000); // 3 pulses (1.5s) + 3s fade
|
||||
}
|
||||
pendingTargetMessageRef.current = null;
|
||||
});
|
||||
pendingTargetMessageRef.current = null;
|
||||
waitForElementAndScroll(targetMessageId, 5000); // 3 pulses (1.5s) + 3s fade
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle scroll to bottom on initial channel load
|
||||
if (scrollToBottomRef.current) {
|
||||
scrollToBottomRef.current = false;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
// Wait for content to render, then scroll to bottom
|
||||
const scrollToBottom = () => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
};
|
||||
|
||||
// Scroll immediately
|
||||
scrollToBottom();
|
||||
|
||||
// Then scroll again after a delay to correct for any layout shifts
|
||||
setTimeout(scrollToBottom, 300);
|
||||
setTimeout(scrollToBottom, 600);
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, waitForElementAndScroll]);
|
||||
|
||||
// Load more messages (pagination)
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
@@ -2209,9 +2356,10 @@ export default function DiscordLogs() {
|
||||
if (!response.ok) throw new Error('Failed to fetch more messages');
|
||||
const data = await response.json();
|
||||
|
||||
// Handle new response format { messages, users }
|
||||
// Handle new response format { messages, users, emojiCache }
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
const normalizedMessages = messagesData.map(msg => ({
|
||||
...msg,
|
||||
@@ -2226,6 +2374,8 @@ export default function DiscordLogs() {
|
||||
setMessages(prev => [...normalizedMessages.reverse(), ...prev]);
|
||||
// Merge new users into existing usersMap
|
||||
setUsersMap(prev => ({ ...prev, ...usersData }));
|
||||
// Merge new emojis into existing emojiCache
|
||||
setEmojiCache(prev => ({ ...prev, ...emojiCacheData }));
|
||||
setHasMoreMessages(messagesData.length === 50);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more messages:', err);
|
||||
@@ -2234,7 +2384,96 @@ export default function DiscordLogs() {
|
||||
}
|
||||
}, [loadingMore, hasMoreMessages, messages, selectedChannel]);
|
||||
|
||||
// Infinite scroll: load more when scrolling near the top
|
||||
// Jump to first message in channel
|
||||
const jumpToFirstMessage = useCallback(async () => {
|
||||
if (!selectedChannel || loadingMessages) return;
|
||||
|
||||
setLoadingMessages(true);
|
||||
setMessages([]);
|
||||
|
||||
try {
|
||||
// Fetch oldest messages using oldest=true parameter
|
||||
const response = await authFetch(
|
||||
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&oldest=true`
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch first messages');
|
||||
const data = await response.json();
|
||||
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
const normalizedMessages = messagesData.map(msg => ({
|
||||
...msg,
|
||||
referenced_message: msg.referencedMessage || msg.referenced_message,
|
||||
attachments: (msg.attachments || []).map(att => ({
|
||||
...att,
|
||||
content_type: att.contentType || att.content_type,
|
||||
})),
|
||||
}));
|
||||
|
||||
setMessages(normalizedMessages);
|
||||
setUsersMap(usersData);
|
||||
setEmojiCache(emojiCacheData);
|
||||
setHasMoreMessages(messagesData.length === 50);
|
||||
setHasNewerMessages(true); // We're viewing oldest, so there are newer messages
|
||||
|
||||
// Scroll to top after messages load
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to jump to first message:', err);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, [selectedChannel, loadingMessages]);
|
||||
|
||||
// Load newer messages (when viewing historical/oldest messages)
|
||||
const loadNewerMessages = useCallback(async () => {
|
||||
if (loadingNewer || !hasNewerMessages || messages.length === 0) return;
|
||||
|
||||
setLoadingNewer(true);
|
||||
try {
|
||||
// Get the newest message ID currently loaded
|
||||
const newestMessage = messages[messages.length - 1];
|
||||
const response = await authFetch(
|
||||
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}`
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch newer messages');
|
||||
const data = await response.json();
|
||||
|
||||
const messagesData = data.messages || data;
|
||||
const usersData = data.users || {};
|
||||
const emojiCacheData = data.emojiCache || {};
|
||||
|
||||
const normalizedMessages = messagesData.map(msg => ({
|
||||
...msg,
|
||||
referenced_message: msg.referencedMessage || msg.referenced_message,
|
||||
attachments: (msg.attachments || []).map(att => ({
|
||||
...att,
|
||||
content_type: att.contentType || att.content_type,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Append newer messages
|
||||
setMessages(prev => [...prev, ...normalizedMessages]);
|
||||
setUsersMap(prev => ({ ...prev, ...usersData }));
|
||||
setEmojiCache(prev => ({ ...prev, ...emojiCacheData }));
|
||||
// If we got less than 50, we've reached the end (no more newer messages)
|
||||
if (messagesData.length < 50) {
|
||||
setHasNewerMessages(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load newer messages:', err);
|
||||
} finally {
|
||||
setLoadingNewer(false);
|
||||
}
|
||||
}, [loadingNewer, hasNewerMessages, messages, selectedChannel]);
|
||||
|
||||
// Infinite scroll: load more when scrolling near the top or bottom
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
@@ -2243,7 +2482,7 @@ export default function DiscordLogs() {
|
||||
// Don't load more when viewing search results
|
||||
if (searchQuery.trim().length >= 2 && searchResults !== null) return;
|
||||
|
||||
// Load more when within 200px of the top
|
||||
// Load older messages when within 200px of the top
|
||||
if (container.scrollTop < 200 && hasMoreMessages && !loadingMore) {
|
||||
// Save scroll position before loading
|
||||
const scrollHeightBefore = container.scrollHeight;
|
||||
@@ -2255,17 +2494,25 @@ export default function DiscordLogs() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load newer messages when within 200px of the bottom (when viewing historical)
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (distanceFromBottom < 200 && hasNewerMessages && !loadingNewer) {
|
||||
loadNewerMessages();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [hasMoreMessages, loadingMore, loadMoreMessages, searchQuery, searchResults]);
|
||||
}, [hasMoreMessages, loadingMore, loadMoreMessages, hasNewerMessages, loadingNewer, loadNewerMessages, searchQuery, searchResults]);
|
||||
|
||||
// Poll for new messages and edits every 5 seconds
|
||||
useEffect(() => {
|
||||
if (!selectedChannel || loadingMessages || messages.length === 0) return;
|
||||
// Don't poll when viewing search results - it would add messages that aren't in search
|
||||
if (searchQuery.trim().length >= 2 && searchResults !== null) return;
|
||||
// Don't poll when viewing historical messages (jumped to first)
|
||||
if (hasNewerMessages) return;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
@@ -2281,11 +2528,13 @@ export default function DiscordLogs() {
|
||||
|
||||
let newMessages = [];
|
||||
let newUsersData = {};
|
||||
let newEmojiCacheData = {};
|
||||
|
||||
if (newMsgsResponse.ok) {
|
||||
const data = await newMsgsResponse.json();
|
||||
const messagesData = data.messages || data;
|
||||
newUsersData = data.users || {};
|
||||
newEmojiCacheData = data.emojiCache || {};
|
||||
|
||||
if (messagesData.length > 0) {
|
||||
newMessages = messagesData.map(msg => ({
|
||||
@@ -2309,7 +2558,9 @@ export default function DiscordLogs() {
|
||||
const editedData = await editedResponse.json();
|
||||
const editedMessagesData = editedData.messages || editedData;
|
||||
const editedUsersData = editedData.users || {};
|
||||
const editedEmojiCacheData = editedData.emojiCache || {};
|
||||
newUsersData = { ...newUsersData, ...editedUsersData };
|
||||
newEmojiCacheData = { ...newEmojiCacheData, ...editedEmojiCacheData };
|
||||
|
||||
editedMessages = editedMessagesData.map(msg => ({
|
||||
...msg,
|
||||
@@ -2358,6 +2609,7 @@ export default function DiscordLogs() {
|
||||
});
|
||||
|
||||
setUsersMap(prev => ({ ...prev, ...newUsersData }));
|
||||
setEmojiCache(prev => ({ ...prev, ...newEmojiCacheData }));
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom if user was already near bottom and there are new messages
|
||||
@@ -2372,7 +2624,7 @@ export default function DiscordLogs() {
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [selectedChannel, loadingMessages, messages, searchQuery, searchResults]); // Poll for channel/guild updates every 5 seconds
|
||||
}, [selectedChannel, loadingMessages, messages, searchQuery, searchResults, hasNewerMessages]); // Poll for channel/guild updates every 5 seconds
|
||||
useEffect(() => {
|
||||
if (loading) return; // Don't poll during initial load
|
||||
|
||||
@@ -2677,7 +2929,7 @@ export default function DiscordLogs() {
|
||||
onClick={() => setTopicExpanded(!topicExpanded)}
|
||||
title={topicExpanded ? 'Click to collapse' : 'Click to expand'}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap })
|
||||
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap, emojiCache })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -2703,6 +2955,16 @@ export default function DiscordLogs() {
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="discord-jump-first-btn"
|
||||
onClick={jumpToFirstMessage}
|
||||
title="Jump to first message"
|
||||
disabled={loadingMessages}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`discord-member-toggle-btn ${memberListExpanded ? 'active' : ''}`}
|
||||
onClick={() => setMemberListExpanded(!memberListExpanded)}
|
||||
@@ -2908,6 +3170,7 @@ export default function DiscordLogs() {
|
||||
onPreviewLoad={handlePreviewLoad}
|
||||
channelMap={channelMap}
|
||||
usersMap={usersMap}
|
||||
emojiCache={emojiCache}
|
||||
members={members}
|
||||
onChannelSelect={handleChannelSelect}
|
||||
channelName={selectedChannel?.name}
|
||||
@@ -3016,7 +3279,7 @@ export default function DiscordLogs() {
|
||||
<div className="discord-reaction-popup-header">
|
||||
{reactionPopup.emoji.id ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/emojis/${reactionPopup.emoji.id}.${reactionPopup.emoji.animated ? 'gif' : 'png'}`}
|
||||
src={reactionPopup.emoji.url || `https://cdn.discordapp.com/emojis/${reactionPopup.emoji.id}.${reactionPopup.emoji.animated ? 'gif' : 'png'}`}
|
||||
alt={reactionPopup.emoji.name}
|
||||
className="discord-reaction-popup-emoji"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ function clearCookie(name) {
|
||||
document.cookie = `${name}=; Max-Age=0; path=/;`;
|
||||
}
|
||||
|
||||
export default function LoginPage({ loggedIn = false }) {
|
||||
export default function LoginPage({ loggedIn = false, accessDenied = false, requiredRoles = [] }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -80,7 +80,11 @@ export default function LoginPage({ loggedIn = false }) {
|
||||
toast.success("Login successful!", {
|
||||
toastId: "login-success-toast",
|
||||
});
|
||||
const returnTo = "/";
|
||||
// Check for returnUrl in query params
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const returnUrl = urlParams.get('returnUrl');
|
||||
// Validate returnUrl is a relative path (security: prevent open redirect)
|
||||
const returnTo = (returnUrl && returnUrl.startsWith('/')) ? returnUrl : '/';
|
||||
window.location.href = returnTo;
|
||||
} else {
|
||||
toast.error("Login failed: no access token received", {
|
||||
@@ -98,18 +102,32 @@ export default function LoginPage({ loggedIn = false }) {
|
||||
}
|
||||
|
||||
if (loggedIn) {
|
||||
const rolesList = Array.isArray(requiredRoles) ? requiredRoles : (requiredRoles ? requiredRoles.split(',') : []);
|
||||
return (
|
||||
<div className="flex items-center justify-center px-4 py-16">
|
||||
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 py-8 text-center">
|
||||
<img className="logo-auth mx-auto mb-4" src="/images/zim.png" alt="Logo" />
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">You're already logged in</h2>
|
||||
<p className="text-sm text-gray-800 dark:text-gray-300 mb-4">But you do not have permission to access this resource.
|
||||
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8 text-center">
|
||||
<img className="logo-auth mx-auto mb-5" src="/images/zim.png" alt="Logo" />
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-3 tracking-tight">Access Denied</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
You don't have permission to access this resource.
|
||||
</p>
|
||||
<p className="text-xs italic text-gray-800 dark:text-gray-300 mb-4">
|
||||
If you feel you have received this message in error, scream at codey.
|
||||
{rolesList.length > 0 && (
|
||||
<div className="mb-5 p-3 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{rolesList.length > 1 ? 's' : ''}:</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{rolesList.map((role, i) => (
|
||||
<span key={i} className="px-2.5 py-1 text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded-full">
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs italic text-neutral-400 dark:text-neutral-500 mb-5">
|
||||
If you believe this is an error, scream at codey.
|
||||
</p>
|
||||
<Button
|
||||
className="btn"
|
||||
className="w-full py-2.5 px-6 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 transition-colors shadow-sm"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onClick={() => (window.location.href = "/")}
|
||||
@@ -122,16 +140,23 @@ export default function LoginPage({ loggedIn = false }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-center bg-gray-50 dark:bg-[#121212] px-4 pt-20 py-10">
|
||||
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 pb-6">
|
||||
<h2 className="flex flex-col items-center text-3xl font-semibold text-gray-900 dark:text-white mb-8 font-sans">
|
||||
<img className="logo-auth mb-4" src="/images/zim.png" alt="Logo" />
|
||||
Log In
|
||||
</h2>
|
||||
<div className="flex items-center justify-center px-4 py-16">
|
||||
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8">
|
||||
<div className="text-center mb-8">
|
||||
<img className="logo-auth mx-auto mb-4" src="/images/zim.png" alt="Logo" />
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight">
|
||||
Log In
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
Sign in to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6 relative" onSubmit={handleSubmit} noValidate>
|
||||
{/* Username */}
|
||||
<div className="relative">
|
||||
<form className="space-y-5" onSubmit={handleSubmit} noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
@@ -141,20 +166,15 @@ export default function LoginPage({ loggedIn = false }) {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 text-neutral-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
|
||||
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
|
||||
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="relative">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
@@ -166,26 +186,20 @@ export default function LoginPage({ loggedIn = false }) {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 text-neutral-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
|
||||
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
|
||||
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full py-3 bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 text-white rounded-lg font-semibold shadow-md transition-colors ${loading ? "opacity-60 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
{loading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full py-3 px-6 bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-500/30 text-white rounded-xl font-semibold shadow-sm transition-all ${loading ? "opacity-60 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{loading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,10 +34,26 @@ export default function LyricSearch() {
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="lyric-search">
|
||||
<h1 className="text-3xl font-bold mb-8 text-neutral-900 dark:text-white tracking-tight">
|
||||
Lyric Search
|
||||
</h1>
|
||||
<div className="lyric-search w-full">
|
||||
{/* Hero section */}
|
||||
<div className="mt-8 mb-12 text-center flex flex-col items-center">
|
||||
<div className="relative w-32 h-32 flex items-center justify-center mb-4">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at 50% 50%, rgba(168,85,247,0.25) 0%, rgba(168,85,247,0.15) 30%, rgba(236,72,153,0.08) 60%, transparent 80%)',
|
||||
}}
|
||||
></div>
|
||||
<span className="relative text-6xl" style={{ marginTop: '-4px' }}>🎤</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-3 text-neutral-900 dark:text-white tracking-tight">
|
||||
Lyric Search
|
||||
</h1>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-base max-w-sm leading-relaxed">
|
||||
Search millions of songs instantly.<br />
|
||||
<span className="text-neutral-400 dark:text-neutral-500 text-sm">Powered by Genius, LRCLib & more</span>
|
||||
</p>
|
||||
</div>
|
||||
<LyricSearchInputField
|
||||
id="lyric-search-input"
|
||||
placeholder="Artist - Song"
|
||||
@@ -268,7 +284,9 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const evaluation = evaluateSearchValue(searchValue);
|
||||
if (!evaluation?.valid) {
|
||||
const message = statusLabels[evaluation?.status || inputStatus] || "Please use Artist - Song";
|
||||
toast.error(message);
|
||||
if (!toast.isActive("lyrics-validation-error-toast")) {
|
||||
toast.error(message, { toastId: "lyrics-validation-error-toast" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -335,13 +353,14 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
dismissSearchToast();
|
||||
toast.success(`Found! (Took ${duration}s)`, {
|
||||
autoClose: 2500,
|
||||
toastId: `lyrics-success-${Date.now()}`,
|
||||
toastId: "lyrics-success-toast",
|
||||
});
|
||||
} catch (error) {
|
||||
dismissSearchToast();
|
||||
toast.error(error.message, {
|
||||
icon: () => "😕",
|
||||
autoClose: 5000,
|
||||
toastId: "lyrics-error-toast",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -412,7 +431,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
}, [statusTitle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div className="lyric-search-input-wrapper">
|
||||
<AutoComplete
|
||||
id={id}
|
||||
@@ -446,7 +465,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
{statusTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-5">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 mt-5 mb-8">
|
||||
<Button
|
||||
onClick={() => handleSearch()}
|
||||
className="search-btn"
|
||||
@@ -454,12 +473,12 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700" aria-hidden="true"></div>
|
||||
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700 hidden sm:block" 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" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
|
||||
<span className="exclude-label hidden sm:inline">Exclude:</span>
|
||||
<UICheckbox id="excl-Genius" label="Genius" value="Genius" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib" value="LRCLib-Cache" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-Cache" label="Cache" value="Cache" onToggle={toggleExclusion} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -585,7 +604,7 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
||||
const newChecked = !checked;
|
||||
setChecked(newChecked);
|
||||
if (props.onToggle) {
|
||||
const source = props.label;
|
||||
const source = props.value || props.label;
|
||||
props.onToggle(source);
|
||||
}
|
||||
setTimeout(verifyExclusions, 0);
|
||||
|
||||
@@ -3,18 +3,24 @@ import { API_URL } from "../config";
|
||||
|
||||
export default function RandomMsg() {
|
||||
const [randomMsg, setRandomMsg] = useState("");
|
||||
const [responseTime, setResponseTime] = useState(null);
|
||||
const [showResponseTime, setShowResponseTime] = useState(false);
|
||||
|
||||
const getRandomMsg = async () => {
|
||||
try {
|
||||
const start = performance.now();
|
||||
const response = await fetch(`${API_URL}/randmsg`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
const end = performance.now();
|
||||
setResponseTime(Math.round(end - start));
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?>/gi, "\n"));
|
||||
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?\>/gi, "\n"));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch random message:", err);
|
||||
setResponseTime(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,11 +28,43 @@ export default function RandomMsg() {
|
||||
|
||||
return (
|
||||
<div className="random-msg-container">
|
||||
<div className="random-msg">
|
||||
<div className="random-msg" style={{ position: "relative", display: "inline-block" }}>
|
||||
{randomMsg && (
|
||||
<small>
|
||||
<i>{randomMsg}</i>
|
||||
</small>
|
||||
<>
|
||||
<small
|
||||
style={{ cursor: responseTime !== null ? "pointer" : "default" }}
|
||||
onClick={() => {
|
||||
if (responseTime !== null) setShowResponseTime((v) => !v);
|
||||
}}
|
||||
tabIndex={0}
|
||||
onBlur={() => setShowResponseTime(false)}
|
||||
>
|
||||
<i>{randomMsg}</i>
|
||||
</small>
|
||||
{showResponseTime && responseTime !== null && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "100%",
|
||||
transform: "translateX(-50%)",
|
||||
marginTop: 4,
|
||||
background: "#222",
|
||||
color: "#fff",
|
||||
fontSize: "0.75em",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 6,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 10,
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
API response: {responseTime} ms
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{randomMsg && (
|
||||
|
||||
64
src/components/SubsiteAppLayout.jsx
Normal file
64
src/components/SubsiteAppLayout.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* SubsiteAppLayout - Lightweight app layout for subsites
|
||||
*
|
||||
* 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 { ToastContainer, toast } from 'react-toastify';
|
||||
import { CssVarsProvider } from "@mui/joy";
|
||||
import { CacheProvider } from "@emotion/react";
|
||||
import createCache from "@emotion/cache";
|
||||
import { PrimeReactProvider } from "primereact/api";
|
||||
|
||||
// Import only minimal CSS - no theme CSS, no primeicons
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import 'primereact/resources/primereact.min.css';
|
||||
|
||||
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
|
||||
|
||||
// Inline minimal JoyUI wrapper to avoid importing Components.jsx
|
||||
function MinimalJoyWrapper({ children }) {
|
||||
const cache = React.useRef();
|
||||
if (!cache.current) {
|
||||
cache.current = createCache({ key: "joy-sub" });
|
||||
}
|
||||
return (
|
||||
<CacheProvider value={cache.current}>
|
||||
<CssVarsProvider>{children}</CssVarsProvider>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubsiteRoot({ child, ...props }) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.toast = toast;
|
||||
}
|
||||
const theme = typeof document !== 'undefined'
|
||||
? document.documentElement.getAttribute("data-theme")
|
||||
: 'light';
|
||||
|
||||
return (
|
||||
<PrimeReactProvider>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={true}
|
||||
closeOnClick={true}
|
||||
rtl={false}
|
||||
pauseOnFocusLoss={false}
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
/>
|
||||
<MinimalJoyWrapper>
|
||||
{child === "ReqForm" && (
|
||||
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
|
||||
<ReqForm {...props} />
|
||||
</Suspense>
|
||||
)}
|
||||
</MinimalJoyWrapper>
|
||||
</PrimeReactProvider>
|
||||
);
|
||||
}
|
||||
@@ -14,16 +14,17 @@ export default function BreadcrumbNav({ currentPage }) {
|
||||
<React.Fragment key={key}>
|
||||
<a
|
||||
href={href}
|
||||
data-astro-reload
|
||||
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
|
||||
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
|
||||
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
|
||||
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
{i < pages.length - 1 && (
|
||||
<span className="text-neutral-400 dark:text-neutral-600" aria-hidden="true">/</span>
|
||||
<span className="text-neutral-400 dark:text-neutral-600 py-1.5 flex items-center" aria-hidden="true">/</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -240,8 +240,10 @@ export default function MediaRequestForm() {
|
||||
audio.src = sourceUrl;
|
||||
setAudioProgress({ current: 0, duration: 0 });
|
||||
setCurrentTrackId(track.id);
|
||||
setIsAudioPlaying(true);
|
||||
await audio.play();
|
||||
} catch (error) {
|
||||
setIsAudioPlaying(false);
|
||||
console.error(error);
|
||||
toast.error("Failed to play track.");
|
||||
if (!fromQueue) {
|
||||
@@ -492,13 +494,16 @@ export default function MediaRequestForm() {
|
||||
|
||||
if (currentTrackId === track.id) {
|
||||
if (audio.paused) {
|
||||
setIsAudioPlaying(true);
|
||||
try {
|
||||
await audio.play();
|
||||
} catch (error) {
|
||||
setIsAudioPlaying(false);
|
||||
console.error(error);
|
||||
toast.error("Unable to resume playback.");
|
||||
}
|
||||
} else {
|
||||
setIsAudioPlaying(false);
|
||||
audio.pause();
|
||||
}
|
||||
return;
|
||||
@@ -547,13 +552,16 @@ export default function MediaRequestForm() {
|
||||
|
||||
if (queueAlbumId === albumId && playbackQueue.length > 0) {
|
||||
if (audio.paused) {
|
||||
setIsAudioPlaying(true);
|
||||
try {
|
||||
await audio.play();
|
||||
} catch (error) {
|
||||
setIsAudioPlaying(false);
|
||||
console.error(error);
|
||||
toast.error("Unable to resume album playback.");
|
||||
}
|
||||
} else {
|
||||
setIsAudioPlaying(false);
|
||||
audio.pause();
|
||||
}
|
||||
return;
|
||||
@@ -1182,7 +1190,21 @@ export default function MediaRequestForm() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleTrackPlayPause(track, id, albumIndex);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
try {
|
||||
if (e?.pointerType === "touch" || e.type === "touchstart") {
|
||||
e.preventDefault();
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
style={{ touchAction: "manipulation" }}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||
@@ -1213,6 +1235,7 @@ 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}`}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
/* Table and Dark Overrides */
|
||||
.trip-management-container {
|
||||
width: 100%;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
||||
}
|
||||
|
||||
/* Improve DataTable font across headers, cells, paginator, and body */
|
||||
.trip-management-container .p-datatable,
|
||||
.trip-management-container .p-datatable th,
|
||||
.trip-management-container .p-datatable td,
|
||||
.trip-management-container .p-paginator,
|
||||
.trip-management-container .p-datatable .p-datatable-header,
|
||||
.trip-management-container .p-datatable .p-datatable-footer{
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 0.95rem !important;
|
||||
line-height: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* Keep monospace for any code or ident columns */
|
||||
.trip-management-container .p-datatable td.code,
|
||||
.trip-management-container .p-datatable td.mono {
|
||||
font-family: ui-monospace, "JetBrains Mono", "Fira Code", "Roboto Mono", "Consolas", "Monaco", "Courier New", monospace !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.trip-management-container .table-wrapper {
|
||||
@@ -37,7 +58,9 @@
|
||||
/* Column widths - distribute across table */
|
||||
.trip-management-container .p-datatable-thead > tr > th,
|
||||
.trip-management-container .p-datatable-tbody > tr > td {
|
||||
/* Default: auto distribute */
|
||||
/* Default column distribution: uniform padding and vertical alignment */
|
||||
padding: 0.65rem 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ID column - narrow */
|
||||
@@ -252,34 +275,142 @@
|
||||
}
|
||||
|
||||
/* Progress Bar Styles */
|
||||
.progress-bar-container {
|
||||
.rm-progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
flex: 1;
|
||||
|
||||
.rm-progress-track {
|
||||
position: relative;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
height: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px; /* rounded track so fill edge is hidden when smaller */
|
||||
overflow: hidden; /* must clip when scaled */
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.progress-bar-track-lg {
|
||||
.rm-progress-track-lg {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
.rm-progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
width: 100% !important; /* full width; we scale via transform to avoid subpixel gaps */
|
||||
transform-origin: left center;
|
||||
transform: scaleX(var(--rm-progress, 0)); /* use custom property (0-1 range) */
|
||||
border-top-left-radius: 999px;
|
||||
border-bottom-left-radius: 999px;
|
||||
transition: transform 0.24s cubic-bezier(0.4,0,0.2,1), border-radius 0.24s;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
right: 0;
|
||||
min-width: 0;
|
||||
will-change: transform;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
/* Fix for native audio progress bar (range input) */
|
||||
.audio-progress-range {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.audio-progress-range::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audio-progress-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
box-shadow: 0 0 2px rgba(0,0,0,0.2);
|
||||
margin-top: -3px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.audio-progress-range::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.audio-progress-range::-ms-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.audio-progress-range::-ms-fill-lower {
|
||||
background: #2563eb;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audio-progress-range::-ms-fill-upper {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audio-progress-range::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
.audio-progress-range:focus {
|
||||
outline: none;
|
||||
}
|
||||
.audio-progress-range::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(to right, #2563eb 0%, #2563eb var(--progress, 0%), rgba(128,128,128,0.2) var(--progress, 0%), rgba(128,128,128,0.2) 100%);
|
||||
}
|
||||
.audio-progress-range::-moz-range-progress {
|
||||
background-color: #2563eb;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audio-progress-range::-moz-range-track {
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audio-progress-range::-ms-fill-lower {
|
||||
background-color: #2563eb;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audio-progress-range::-ms-fill-upper {
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audio-progress-range:focus::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(to right, #2563eb 0%, #2563eb var(--progress, 0%), rgba(128,128,128,0.2) var(--progress, 0%), rgba(128,128,128,0.2) 100%);
|
||||
}
|
||||
.audio-progress-range::-ms-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rm-progress-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
|
||||
/* Ensure progress styles apply when rendered within a PrimeReact Dialog (portal) */
|
||||
.p-dialog .rm-progress-container{display:flex;align-items:center;width:100%}
|
||||
.p-dialog .rm-progress-track{position:relative;flex:1 1 0%;min-width:0;height:6px;background-color:#80808033;border-radius:999px;overflow:hidden;margin:0!important;padding:0!important}
|
||||
.p-dialog .rm-progress-track-lg{height:10px}
|
||||
.p-dialog .rm-progress-fill{position:absolute;left:0;top:0;height:100%;width:100%!important;transform-origin:left center;transform:scaleX(var(--rm-progress, 0));border-top-left-radius:999px;border-bottom-left-radius:999px;transition:transform .24s cubic-bezier(.4,0,.2,1),border-radius .24s;margin:0!important;padding:0!important;right:0;min-width:0;will-change:transform;box-sizing:border-box}
|
||||
.p-dialog .rm-progress-text{font-size:.75rem;font-weight:600;min-width:2.5rem;text-align:right}
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -339,17 +470,17 @@
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
.trip-management-container .rm-progress-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
.trip-management-container .rm-progress-track {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
.trip-management-container .rm-progress-text {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -440,3 +571,147 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Mobile Card Layout for Request Table
|
||||
======================================== */
|
||||
@media (max-width: 768px) {
|
||||
/* Hide table header on mobile */
|
||||
.trip-management-container .p-datatable-thead {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Convert table to block layout */
|
||||
.trip-management-container .p-datatable-table {
|
||||
display: block !important;
|
||||
table-layout: auto !important;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-tbody {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Each row becomes a card */
|
||||
.trip-management-container .p-datatable-tbody > tr {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
padding: 1rem !important;
|
||||
margin-bottom: 0.75rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
border: 1px solid rgba(128, 128, 128, 0.3) !important;
|
||||
gap: 0.5rem 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Reset column widths */
|
||||
.trip-management-container .p-datatable-tbody > tr > td {
|
||||
width: auto !important;
|
||||
padding: 0.25rem 0 !important;
|
||||
border: none !important;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 48%;
|
||||
min-width: 48%;
|
||||
}
|
||||
|
||||
/* Add labels before each cell */
|
||||
.trip-management-container .p-datatable-tbody > tr > td::before {
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: #9ca3af;
|
||||
min-width: 60px;
|
||||
line-height: 1.1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Target takes full width */
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(2) {
|
||||
width: 100% !important;
|
||||
order: -1;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding-bottom: 0.5rem !important;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.2) !important;
|
||||
margin-bottom: 0.25rem;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(2)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ID - small, muted */
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(1) {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(1)::before {
|
||||
content: "ID";
|
||||
}
|
||||
|
||||
/* Tracks */
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(3)::before {
|
||||
content: "Tracks";
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(4)::before {
|
||||
content: "Status";
|
||||
}
|
||||
|
||||
/* Progress - full width */
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(5) {
|
||||
width: 100% !important;
|
||||
order: 10;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(5)::before {
|
||||
content: "Progress";
|
||||
}
|
||||
|
||||
/* Quality */
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(6)::before {
|
||||
content: "Quality";
|
||||
}
|
||||
|
||||
/* Tarball - full width */
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(7) {
|
||||
width: 100% !important;
|
||||
order: 11;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.trip-management-container .p-datatable-tbody > tr > td:nth-child(7)::before {
|
||||
content: "Download";
|
||||
}
|
||||
|
||||
/* Progress bar adjustments for mobile */
|
||||
.trip-management-container .rm-progress-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Skeleton adjustments for mobile */
|
||||
.table-skeleton .skeleton-row {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.table-skeleton .skeleton-cell {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.table-skeleton .skeleton-bar {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.table-skeleton .skeleton-cell:first-child .skeleton-bar {
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,12 +163,19 @@ export default function RequestManagement() {
|
||||
return `${pct}%`;
|
||||
};
|
||||
|
||||
const computePct = (p) => {
|
||||
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 p = rowData.progress;
|
||||
if (p === null || p === undefined || p === "") return "—";
|
||||
const num = Number(p);
|
||||
if (Number.isNaN(num)) return "—";
|
||||
const pct = Math.min(100, Math.max(0, num > 1 ? Math.round(num) : num * 100));
|
||||
const pct = computePct(p);
|
||||
|
||||
const getProgressColor = () => {
|
||||
if (rowData.status === "Failed") return "bg-red-500";
|
||||
@@ -179,14 +186,22 @@ export default function RequestManagement() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar-track">
|
||||
<div className="rm-progress-container">
|
||||
<div className="rm-progress-track" style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
className={`progress-bar-fill ${getProgressColor()}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
className={`rm-progress-fill ${getProgressColor()}`}
|
||||
style={{
|
||||
'--rm-progress': (pct / 100).toString(),
|
||||
borderTopRightRadius: pct === 100 ? '999px' : 0,
|
||||
borderBottomRightRadius: pct === 100 ? '999px' : 0
|
||||
}}
|
||||
data-pct={pct}
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-bar-text">{pct}%</span>
|
||||
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -348,7 +363,7 @@ export default function RequestManagement() {
|
||||
<div className="space-y-4 text-sm">
|
||||
|
||||
{/* --- Metadata Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
|
||||
<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.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
|
||||
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
|
||||
@@ -363,7 +378,7 @@ export default function RequestManagement() {
|
||||
</div>
|
||||
|
||||
{/* --- Status / Progress Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{selectedRequest.status && (
|
||||
<p>
|
||||
<strong>Status:</strong>{" "}
|
||||
@@ -375,14 +390,22 @@ export default function RequestManagement() {
|
||||
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
|
||||
<div className="col-span-2">
|
||||
<strong>Progress:</strong>
|
||||
<div className="progress-bar-container mt-2">
|
||||
<div className="progress-bar-track progress-bar-track-lg">
|
||||
<div className="rm-progress-container mt-2">
|
||||
<div className="rm-progress-track rm-progress-track-lg">
|
||||
<div
|
||||
className={`progress-bar-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}%` }}
|
||||
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(),
|
||||
borderTopRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0,
|
||||
borderBottomRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0
|
||||
}}
|
||||
data-pct={computePct(selectedRequest.progress)}
|
||||
aria-valuenow={Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</div>
|
||||
<span className="progress-bar-text">{formatProgress(selectedRequest.progress)}</span>
|
||||
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useState, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@mui/joy";
|
||||
// Dropdown not used in this form; removed to avoid unused-import warnings
|
||||
import { AutoComplete } from "primereact/autocomplete/autocomplete.esm.js";
|
||||
import { InputText } from "primereact/inputtext/inputtext.esm.js";
|
||||
import { AutoComplete } from "primereact/autocomplete";
|
||||
import { InputText } from "primereact/inputtext";
|
||||
|
||||
export default function ReqForm() {
|
||||
const [type, setType] = useState("");
|
||||
@@ -16,6 +16,15 @@ export default function ReqForm() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [posterLoading, setPosterLoading] = useState(true);
|
||||
const [submittedRequest, setSubmittedRequest] = useState(null); // Track successful submission
|
||||
const [csrfToken, setCsrfToken] = useState(null);
|
||||
|
||||
// Get CSRF token from window global on mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window._t) {
|
||||
setCsrfToken(window._t);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (title !== selectedTitle) {
|
||||
@@ -54,6 +63,11 @@ export default function ReqForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!csrfToken) {
|
||||
toast.error("Security token not loaded. Please refresh the page.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
@@ -61,20 +75,34 @@ export default function ReqForm() {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title, year, type, requester }),
|
||||
body: JSON.stringify({ title, year, type, requester, csrfToken }),
|
||||
});
|
||||
const responseData = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Submission failed');
|
||||
const errorMessage = responseData.error || 'Submission failed';
|
||||
toast.error(errorMessage);
|
||||
|
||||
// If CSRF token error, require page reload
|
||||
if (response.status === 403) {
|
||||
toast.error('Please refresh the page and try again.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.success("Request submitted successfully!");
|
||||
// Reset form
|
||||
setType("");
|
||||
setTitle("");
|
||||
setYear("");
|
||||
setRequester("");
|
||||
setSelectedOverview("");
|
||||
setSelectedTitle("");
|
||||
setSelectedItem(null);
|
||||
|
||||
// Save the new CSRF token from the response for future submissions
|
||||
if (responseData.csrfToken) {
|
||||
setCsrfToken(responseData.csrfToken);
|
||||
}
|
||||
|
||||
// Store submitted request info for success view
|
||||
setSubmittedRequest({
|
||||
title,
|
||||
year,
|
||||
type,
|
||||
requester,
|
||||
poster_path: selectedItem?.poster_path,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
toast.error("Failed to submit request. Please try again.");
|
||||
@@ -83,6 +111,19 @@ export default function ReqForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setType("");
|
||||
setTitle("");
|
||||
setYear("");
|
||||
setRequester("");
|
||||
setSelectedOverview("");
|
||||
setSelectedTitle("");
|
||||
setSelectedItem(null);
|
||||
setSubmittedRequest(null);
|
||||
setPosterLoading(true);
|
||||
// Token was already refreshed from the submit response
|
||||
};
|
||||
|
||||
const attachScrollFix = () => {
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector(".p-autocomplete-panel");
|
||||
@@ -116,6 +157,63 @@ export default function ReqForm() {
|
||||
|
||||
const selectedTypeLabel = formatMediaType(selectedItem?.mediaType || type);
|
||||
|
||||
// Success view after submission
|
||||
if (submittedRequest) {
|
||||
const typeLabel = formatMediaType(submittedRequest.type);
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
||||
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#141414] rounded-2xl shadow-lg shadow-neutral-900/5 dark:shadow-black/20 border border-neutral-200/60 dark:border-neutral-800/60 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2 tracking-tight font-['IBM_Plex_Sans',sans-serif]">
|
||||
Request Submitted!
|
||||
</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
|
||||
Your request has been received and is pending review.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{submittedRequest.poster_path && (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w92${submittedRequest.poster_path}`}
|
||||
alt="Poster"
|
||||
className="w-16 h-auto rounded-lg border border-neutral-200 dark:border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-semibold text-neutral-900 dark:text-white">
|
||||
{submittedRequest.title}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{submittedRequest.year && `${submittedRequest.year} · `}
|
||||
{typeLabel || 'Media'}
|
||||
</p>
|
||||
{submittedRequest.requester && (
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
Requested by: {submittedRequest.requester}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={resetForm}
|
||||
className="w-full py-3 px-6 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 transition-colors shadow-sm"
|
||||
>
|
||||
Submit Another Request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
||||
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#141414] rounded-2xl shadow-lg shadow-neutral-900/5 dark:shadow-black/20 border border-neutral-200/60 dark:border-neutral-800/60">
|
||||
@@ -138,7 +236,12 @@ export default function ReqForm() {
|
||||
suggestions={suggestions}
|
||||
completeMethod={searchTitles}
|
||||
minLength={2}
|
||||
onChange={(e) => setTitle(typeof e.value === 'string' ? e.value : e.value.label)}
|
||||
delay={300}
|
||||
onChange={(e) => {
|
||||
// Handle both string input and object selection
|
||||
const val = e.target?.value ?? e.value;
|
||||
setTitle(typeof val === 'string' ? val : val?.label || '');
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
setType(e.value.mediaType === 'tv' ? 'tv' : 'movie');
|
||||
setTitle(e.value.label);
|
||||
@@ -153,6 +256,7 @@ export default function ReqForm() {
|
||||
inputClassName="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||
panelClassName="rounded-xl overflow-hidden"
|
||||
field="label"
|
||||
autoComplete="off"
|
||||
onShow={attachScrollFix}
|
||||
itemTemplate={(item) => (
|
||||
<div className="p-2 rounded">
|
||||
|
||||
@@ -56,11 +56,12 @@ export const SUBSITES = {
|
||||
// Protected routes configuration
|
||||
// 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 = [
|
||||
'/radio',
|
||||
{ path: '/lighting', roles: ['lighting'] },
|
||||
{ path: '/discord-logs', roles: ['discord'] },
|
||||
'/memes',
|
||||
{ path: '/api/discord', roles: ['discord'], exclude: ['/api/discord/cached-image'] },
|
||||
'/TRip',
|
||||
'/TRip/requests',
|
||||
];
|
||||
|
||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@@ -13,5 +13,7 @@ declare namespace App {
|
||||
whitelabel?: string | null;
|
||||
isSubsite?: boolean;
|
||||
refreshedCookies?: string[];
|
||||
accessDenied?: boolean | null;
|
||||
requiredRoles?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
// requireAuthHook.js
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
// Short-term failure cache for auth timeouts/errors
|
||||
let lastAuthFailureTime = 0;
|
||||
const AUTH_FAILURE_CACHE_MS = 30000; // 30 seconds
|
||||
|
||||
// WeakMap to cache auth promises per Astro context (request)
|
||||
const authCache = new WeakMap();
|
||||
|
||||
export const requireAuthHook = async (Astro) => {
|
||||
// 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);
|
||||
@@ -21,17 +29,43 @@ export const requireAuthHook = async (Astro) => {
|
||||
async function performAuth(Astro) {
|
||||
try {
|
||||
const cookieHeader = Astro.request.headers.get("cookie") ?? "";
|
||||
let res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
// Add timeout to avoid hanging SSR render
|
||||
let controller = new AbortController();
|
||||
let timeout = setTimeout(() => controller.abort(), 3000);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
lastAuthFailureTime = Date.now();
|
||||
console.error("[SSR] auth/id failed or timed out", err);
|
||||
return null;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.status === 401) {
|
||||
// Try refresh with timeout
|
||||
controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), 3000);
|
||||
let refreshRes;
|
||||
try {
|
||||
refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
lastAuthFailureTime = Date.now();
|
||||
console.error("[SSR] auth/refresh failed or timed out", err);
|
||||
return null;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
console.error("Token refresh failed", refreshRes.status);
|
||||
@@ -62,10 +96,21 @@ async function performAuth(Astro) {
|
||||
// Build new cookie header for the retry request
|
||||
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
lastAuthFailureTime = Date.now();
|
||||
console.error("[SSR] auth/id retry failed or timed out", err);
|
||||
return null;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -6,7 +6,7 @@ interface Props {
|
||||
hideFooter?: boolean;
|
||||
}
|
||||
|
||||
import Themes from "astro-themes";
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
import BaseHead from "../components/BaseHead.astro";
|
||||
import { metaData } from "../config";
|
||||
@@ -67,11 +67,16 @@ if (import.meta.env.DEV) {
|
||||
<html lang="en" class="scrollbar-hide lenis lenis-smooth" data-subsite={isSubsite ? 'true' : 'false'}>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="googlebot"
|
||||
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
|
||||
/>
|
||||
<Themes />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
<style is:inline>
|
||||
html { visibility: hidden; }
|
||||
html.ready { visibility: visible; }
|
||||
html { background: #fff; }
|
||||
html.dark { background: #121212; }
|
||||
</style>
|
||||
<script src="/scripts/theme-init.js" is:inline></script>
|
||||
<ClientRouter />
|
||||
<BaseHead title={whitelabel?.siteTitle ?? title} description={description} image={image ?? metaData.ogImage} isWhitelabel={!!whitelabel} whitelabel={whitelabel} subsite={detectedSubsite} />
|
||||
<script>
|
||||
import "@scripts/lenisSmoothScroll.js";
|
||||
@@ -83,13 +88,13 @@ if (import.meta.env.DEV) {
|
||||
scrollbar-hide"
|
||||
style={`--brand-color: ${whitelabel?.brandColor ?? '#111827'}`}>
|
||||
|
||||
<!-- Nav is outside main to allow full-width -->
|
||||
<div class="w-full">
|
||||
<!-- Nav is outside main to allow full-width, sticky wrapper -->
|
||||
<div class="w-full sticky top-0 z-50">
|
||||
{whitelabel ? <SubNav whitelabel={whitelabel} subsite={detectedSubsite} /> : <Nav />}
|
||||
</div>
|
||||
|
||||
<main
|
||||
class="page-enter flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-8">
|
||||
class="flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-8">
|
||||
<noscript>
|
||||
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||
@@ -99,9 +104,6 @@ if (import.meta.env.DEV) {
|
||||
{!hideFooter && <Footer />}
|
||||
</main>
|
||||
<style>
|
||||
/* Minimal page transition to replace deprecated ViewTransitions */
|
||||
.page-enter { opacity: 0; transform: translateY(6px); transition: opacity 220ms ease, transform 240ms ease; }
|
||||
html.page-ready .page-enter { opacity: 1; transform: none; }
|
||||
/* CSS rules for the page scrollbar */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -112,20 +114,5 @@ if (import.meta.env.DEV) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Mark the page ready so CSS transitions run (replaces ViewTransitions).
|
||||
// We don't rely on any Astro-specific API here — just a small client-side toggle.
|
||||
(function () {
|
||||
try {
|
||||
// Add page-ready on the next animation frame so the transition always runs.
|
||||
if (typeof window !== 'undefined') {
|
||||
requestAnimationFrame(() => document.documentElement.classList.add('page-ready'));
|
||||
}
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -46,7 +46,7 @@ const currentPath = Astro.url.pathname;
|
||||
|
||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||
|
||||
<nav class="w-full px-4 sm:px-6 py-3 sticky top-0 z-50 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||
<nav class="w-full px-4 sm:px-6 py-3 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||
<!-- Logo/Brand -->
|
||||
@@ -62,6 +62,7 @@ const currentPath = Astro.url.pathname;
|
||||
{visibleNavItems.map((item) => {
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const isTRipLink = item.href?.startsWith("/TRip");
|
||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedCurrent = normalize(currentPath);
|
||||
const normalizedHref = normalize(item.href);
|
||||
@@ -82,6 +83,7 @@ const currentPath = Astro.url.pathname;
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
data-astro-reload={isTRipLink ? true : undefined}
|
||||
>
|
||||
{item.label}
|
||||
{item.icon === "external" && (
|
||||
@@ -166,6 +168,7 @@ const currentPath = Astro.url.pathname;
|
||||
{visibleNavItems.map((item) => {
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const isTRipLink = item.href?.startsWith("/TRip");
|
||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedCurrent = normalize(currentPath);
|
||||
const normalizedHref = normalize(item.href);
|
||||
@@ -179,16 +182,17 @@ const currentPath = Astro.url.pathname;
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
class={isActive
|
||||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white"
|
||||
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
}
|
||||
style={isActive ? `background: #111827` : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
data-astro-reload={isTRipLink ? true : undefined}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span style="color:inherit;">{item.label}</span>
|
||||
{item.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
|
||||
107
src/layouts/SubsiteBase.astro
Normal file
107
src/layouts/SubsiteBase.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* SubsiteBase.astro - Lightweight base layout for subsites
|
||||
*
|
||||
* This layout excludes heavy CSS/JS from the main site to reduce payload.
|
||||
* It does NOT import AppLayout or its dependencies.
|
||||
*/
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
hideFooter?: boolean;
|
||||
}
|
||||
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
import BaseHead from "../components/BaseHead.astro";
|
||||
import { metaData } from "../config";
|
||||
import Footer from "../components/Footer.astro";
|
||||
|
||||
// Minimal font imports for subsites
|
||||
import "@fontsource/ibm-plex-sans/500.css";
|
||||
import "@fontsource/geist-sans/400.css";
|
||||
import "@styles/global.css";
|
||||
|
||||
const { title, description, image, hideFooter = false } = Astro.props;
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
|
||||
import { getSubsiteByHost, getSubsiteFromSignal, getSubsiteByPath } from '../utils/subsites.js';
|
||||
import { WHITELABELS } from "../config";
|
||||
|
||||
const detectedSubsite = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null;
|
||||
const isReq = detectedSubsite?.short === 'req';
|
||||
|
||||
// Accept forced whitelabel via query param, headers or request locals
|
||||
const forcedParam = Astro.url.searchParams.get('whitelabel') || Astro.request?.headers?.get('x-whitelabel') || (Astro.request as any)?.locals?.whitelabel || null;
|
||||
|
||||
let whitelabel: any = null;
|
||||
if (forcedParam) {
|
||||
const forced = getSubsiteFromSignal(forcedParam);
|
||||
if (forced) whitelabel = WHITELABELS[forced.host] ?? null;
|
||||
}
|
||||
|
||||
if (!whitelabel) {
|
||||
whitelabel = WHITELABELS[host] ?? (isReq ? WHITELABELS[detectedSubsite.host] : null);
|
||||
}
|
||||
---
|
||||
|
||||
<html lang="en" class="scrollbar-hide" data-subsite="true">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
<style is:inline>
|
||||
html { visibility: hidden; }
|
||||
html.ready { visibility: visible; }
|
||||
html { background: #fff; }
|
||||
html.dark { background: #121212; }
|
||||
</style>
|
||||
<script src="/scripts/theme-init.js" is:inline></script>
|
||||
<ClientRouter />
|
||||
<BaseHead title={whitelabel?.siteTitle ?? title} description={description} image={image ?? metaData.ogImage} isWhitelabel={!!whitelabel} whitelabel={whitelabel} subsite={detectedSubsite} />
|
||||
</head>
|
||||
<body
|
||||
class="antialiased flex flex-col items-center mx-auto min-h-screen scrollbar-hide"
|
||||
style={`--brand-color: ${whitelabel?.brandColor ?? '#111827'}`}>
|
||||
|
||||
<!-- Lightweight nav for subsites -->
|
||||
<div class="w-full sticky top-0 z-50">
|
||||
<script src="/scripts/nav-controls.js" defer></script>
|
||||
<nav class="w-full px-4 sm:px-6 py-3 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent hover:opacity-80 transition-opacity">
|
||||
{whitelabel?.logoText ?? detectedSubsite?.short?.toUpperCase() ?? 'Subsite'}
|
||||
</a>
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<svg class="h-4 w-4 text-neutral-700 dark:text-neutral-300" viewBox="0 0 512 512" fill="currentColor">
|
||||
<path d="M448 256c0-106-86-192-192-192v384c106 0 192-86 192-192M0 256a256 256 0 1 1 512 0a256 256 0 1 1-512 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main class="flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-8">
|
||||
<noscript>
|
||||
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||
</div>
|
||||
</noscript>
|
||||
<slot />
|
||||
{!hideFooter && <Footer />}
|
||||
</main>
|
||||
|
||||
<style is:global>
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,30 @@ import { defineMiddleware } from 'astro:middleware';
|
||||
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES } from './config.js';
|
||||
import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js';
|
||||
|
||||
// Polyfill Headers.getSetCookie for environments where it's not present.
|
||||
// Astro's Node adapter expects headers.getSetCookie() to exist when
|
||||
// 'set-cookie' headers are present; in some Node runtimes Headers lacks it
|
||||
// which leads to TypeError: headers.getSetCookie is not a function.
|
||||
if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.prototype.getSetCookie !== 'function') {
|
||||
try {
|
||||
Object.defineProperty(globalThis.Headers.prototype, 'getSetCookie', {
|
||||
value: function () {
|
||||
const cookies = [];
|
||||
for (const [name, val] of this.entries()) {
|
||||
if (name && name.toLowerCase() === 'set-cookie') cookies.push(val);
|
||||
}
|
||||
return cookies;
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch (err) {
|
||||
// If we can't patch Headers, swallow silently — code will still try to
|
||||
// access getSetCookie, but our other guards handle missing function calls.
|
||||
console.warn('[middleware] Failed to polyfill Headers.getSetCookie', err);
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL = "https://api.codey.lol";
|
||||
|
||||
// Auth check function (mirrors requireAuthHook logic but for middleware)
|
||||
@@ -82,14 +106,28 @@ function getProtectedRouteConfig(pathname) {
|
||||
const normalizedRoute = routePath.toLowerCase();
|
||||
const matches = normalizedPath === normalizedRoute || normalizedPath.startsWith(normalizedRoute + '/');
|
||||
if (matches) {
|
||||
// Check if this path is excluded from protection
|
||||
const excludes = (typeof route === 'object' && route.exclude) || [];
|
||||
for (const excludePath of excludes) {
|
||||
const normalizedExclude = excludePath.toLowerCase();
|
||||
if (normalizedPath === normalizedExclude || normalizedPath.startsWith(normalizedExclude + '/')) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Path ${pathname} excluded from protection by ${excludePath}`);
|
||||
return null; // Excluded, not protected
|
||||
}
|
||||
}
|
||||
return typeof route === 'string' ? { path: route, roles: null } : route;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if a path is explicitly public
|
||||
// Check if a path is explicitly public (but NOT if it matches a protected route)
|
||||
function isPublicRoute(pathname) {
|
||||
// If the path matches a protected route, it's NOT public
|
||||
if (getProtectedRouteConfig(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const route of PUBLIC_ROUTES) {
|
||||
// For routes ending with /, match any path starting with that prefix
|
||||
if (route.endsWith('/') && route !== '/') {
|
||||
@@ -130,12 +168,27 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const { authenticated, user, cookies } = await checkAuth(context.request);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Auth result: authenticated=${authenticated}`);
|
||||
|
||||
const isApiRoute = pathname.startsWith('/api/');
|
||||
|
||||
if (!authenticated) {
|
||||
if (isApiRoute) {
|
||||
// Return JSON 401 for API routes
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Redirect to login with return URL
|
||||
const returnUrl = encodeURIComponent(pathname + context.url.search);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Auth required for ${pathname}, redirecting to login`);
|
||||
return context.redirect(`/login?returnUrl=${returnUrl}`, 302);
|
||||
}
|
||||
|
||||
// Expose authenticated user and refreshed cookies to downstream handlers/layouts
|
||||
context.locals.user = user;
|
||||
if (cookies && cookies.length > 0) {
|
||||
context.locals.refreshedCookies = cookies;
|
||||
}
|
||||
|
||||
// Check role-based access if roles are specified
|
||||
if (protectedConfig.roles && protectedConfig.roles.length > 0) {
|
||||
@@ -143,19 +196,29 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const hasRequiredRole = protectedConfig.roles.some(role => userRoles.includes(role));
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] User lacks required role for ${pathname}, redirecting to login`);
|
||||
return context.redirect('/login', 302);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] User lacks required role for ${pathname}`);
|
||||
|
||||
if (isApiRoute) {
|
||||
// Return JSON 403 for API routes
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions',
|
||||
requiredRoles: protectedConfig.roles
|
||||
}), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Store required roles in locals for the login page to access
|
||||
context.locals.accessDenied = true;
|
||||
context.locals.requiredRoles = protectedConfig.roles;
|
||||
context.locals.returnUrl = pathname + context.url.search;
|
||||
// Rewrite to login page - this renders /login but keeps the URL and locals
|
||||
return context.rewrite('/login');
|
||||
}
|
||||
}
|
||||
|
||||
// Store user in locals for downstream use
|
||||
context.locals.user = user;
|
||||
|
||||
// Forward any refreshed cookies
|
||||
if (cookies && cookies.length > 0) {
|
||||
context.locals.refreshedCookies = cookies;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Auth OK for ${pathname}, user: ${user?.username || user?.id || 'unknown'}`);
|
||||
}
|
||||
|
||||
@@ -185,7 +248,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
|
||||
// Debug info for incoming requests
|
||||
if (import.meta.env.DEV) console.log(`[middleware] incoming: host=${hostHeader} ip=${requestIp} path=${context.url.pathname}`);
|
||||
// else console.log(`[middleware] request from ${requestIp}${cfCountry ? ` (${cfCountry})` : ''}${userAgent ? ` [${userAgent}]` : ''}: ${context.url.pathname}`);
|
||||
|
||||
|
||||
// When the host header is missing, log all headers for debugging and
|
||||
// attempt to determine host from forwarded headers or a dev query header.
|
||||
@@ -225,8 +288,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const shouldSkip = skipPrefixes.some((p) => context.url.pathname.startsWith(p));
|
||||
|
||||
if (!shouldSkip && !context.url.pathname.startsWith(subsitePath)) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Rewriting ${host} ${context.url.pathname} -> ${subsitePath}${context.url.pathname}`);
|
||||
context.url.pathname = `${subsitePath}${context.url.pathname}`;
|
||||
const newPath = `${subsitePath}${context.url.pathname}`;
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Rewriting ${host} ${context.url.pathname} -> ${newPath}`);
|
||||
return context.rewrite(newPath);
|
||||
}
|
||||
} else {
|
||||
// If the path appears to be a subsite path (like /subsites/req) but the host isn't a subsite,
|
||||
@@ -304,14 +368,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// In production, consider using nonces or hashes for stricter CSP
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://www.youtube-nocookie.com https://s.ytimg.com",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://www.youtube-nocookie.com https://s.ytimg.com https://challenges.cloudflare.com https://static.cloudflareinsights.com",
|
||||
// Allow Cloudflare's inline event handlers (for Turnstile/challenges)
|
||||
"script-src-attr 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https: http:",
|
||||
"media-src 'self' blob: https:",
|
||||
"connect-src 'self' https://api.codey.lol https://*.codey.lol wss:",
|
||||
// Allow YouTube for video embeds
|
||||
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com",
|
||||
"connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:",
|
||||
// Allow YouTube for video embeds and Cloudflare for challenges/Turnstile
|
||||
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
|
||||
@@ -7,14 +7,14 @@ import Root from "@/components/AppLayout.jsx";
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section class="page-section trip-section">
|
||||
<Root child="qs2.MediaRequestForm" client:only="react" />
|
||||
<section class="page-section trip-section" transition:animate="none">
|
||||
<Root child="qs2.MediaRequestForm" client:only="react" transition:persist />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for TRip pages */
|
||||
body:has(.trip-section) main.page-enter {
|
||||
body:has(.trip-section) main {
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,14 +7,14 @@ import Root from "@/components/AppLayout.jsx";
|
||||
const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base>
|
||||
<section class="page-section trip-section">
|
||||
<Root child="qs2.RequestManagement" client:only="react" />
|
||||
<section class="page-section trip-section" transition:animate="none">
|
||||
<Root child="qs2.RequestManagement" client:only="react" transition:persist />
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for TRip pages */
|
||||
html:has(.trip-section) main.page-enter {
|
||||
html:has(.trip-section) main {
|
||||
max-width: 1400px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
125
src/pages/api/discord/cached-video.js
Normal file
125
src/pages/api/discord/cached-video.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { checkRateLimit, recordRequest } from '../../../utils/rateLimit.js';
|
||||
|
||||
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) {
|
||||
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) {
|
||||
if (!VIDEO_CACHE_SECRET || !signature) return false;
|
||||
const expected = signVideoId(id);
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to stream a file with range support
|
||||
function streamFile(filePath, rangeHeader) {
|
||||
// Ensure file exists
|
||||
if (!fs.existsSync(filePath)) return { status: 404 };
|
||||
const stat = fs.statSync(filePath);
|
||||
const total = stat.size;
|
||||
|
||||
if (!rangeHeader) {
|
||||
const nodeStream = fs.createReadStream(filePath);
|
||||
const stream = Readable.toWeb(nodeStream);
|
||||
return { status: 200, stream, total, start: 0, end: total - 1 };
|
||||
}
|
||||
|
||||
// Parse range header
|
||||
const match = /bytes=(\d*)-(\d*)/.exec(rangeHeader);
|
||||
if (!match) return { status: 416 };
|
||||
let start = match[1] === '' ? 0 : parseInt(match[1], 10);
|
||||
let end = match[2] === '' ? total - 1 : parseInt(match[2], 10);
|
||||
if (isNaN(start) || isNaN(end) || start > end || start >= total) return { status: 416 };
|
||||
|
||||
const nodeStream = fs.createReadStream(filePath, { start, end });
|
||||
const stream = Readable.toWeb(nodeStream);
|
||||
return { status: 206, stream, total, start, end };
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
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);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get('id');
|
||||
const signature = url.searchParams.get('sig');
|
||||
const sourceUrl = url.searchParams.get('url');
|
||||
|
||||
if (!id && !sourceUrl) return new Response('Missing id or url parameter', { status: 400 });
|
||||
|
||||
// If id-based, require signature
|
||||
if (id && !verifySignature(id, signature)) {
|
||||
return new Response('Invalid or missing signature', { status: 403 });
|
||||
}
|
||||
|
||||
let row;
|
||||
if (id) {
|
||||
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE video_id = ${id}`;
|
||||
row = r[0];
|
||||
} else {
|
||||
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE source_url = ${sourceUrl} LIMIT 1`;
|
||||
row = r[0];
|
||||
}
|
||||
|
||||
if (!row) return new Response('Video not found in cache', { status: 404 });
|
||||
|
||||
// If file_path is present, stream from disk
|
||||
if (row.file_path) {
|
||||
// Protect against directory traversal by resolving path and ensuring it's under configured storage
|
||||
const storageRoot = '/storage';
|
||||
const resolved = path.resolve(row.file_path);
|
||||
if (!resolved.startsWith(storageRoot)) {
|
||||
console.error('[cached-video] file_path outside storage root', resolved);
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const range = request.headers.get('range');
|
||||
const result = streamFile(resolved, range);
|
||||
if (result.status === 404) return new Response('File not found', { status: 404 });
|
||||
if (result.status === 416) return new Response('Range Not Satisfiable', { status: 416 });
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', row.content_type || 'video/mp4');
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
|
||||
if (result.status === 200) {
|
||||
headers.set('Content-Length', String(result.total));
|
||||
return new Response(result.stream, { status: 200, headers });
|
||||
}
|
||||
|
||||
// Partial content
|
||||
headers.set('Content-Range', `bytes ${result.start}-${result.end}/${result.total}`);
|
||||
headers.set('Content-Length', String(result.end - result.start + 1));
|
||||
return new Response(result.stream, { status: 206, headers });
|
||||
}
|
||||
|
||||
// No file_path - fallback to proxying source_url (but this endpoint expects id-based cache)
|
||||
return new Response(JSON.stringify({ error: 'No cached file available' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (err) {
|
||||
console.error('cached-video error', err);
|
||||
return new Response('Server error', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
checkRateLimit,
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check
|
||||
@@ -69,6 +70,7 @@ export async function GET({ request }) {
|
||||
c.guild_id,
|
||||
g.name as guild_name,
|
||||
g.icon_url as guild_icon,
|
||||
g.cached_icon_id as guild_cached_icon_id,
|
||||
COALESCE(cc.message_count, 0) as message_count
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
@@ -97,6 +99,7 @@ export async function GET({ request }) {
|
||||
c.guild_id,
|
||||
g.name as guild_name,
|
||||
g.icon_url as guild_icon,
|
||||
g.cached_icon_id as guild_cached_icon_id,
|
||||
COALESCE(cc.message_count, 0) as message_count
|
||||
FROM channels c
|
||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||
@@ -106,19 +109,31 @@ export async function GET({ request }) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Get base URL for cached image URLs
|
||||
const baseUrl = new URL(request.url).origin;
|
||||
|
||||
// Transform to expected format
|
||||
const formattedChannels = channels.map(ch => ({
|
||||
id: ch.channel_id.toString(),
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
position: ch.position,
|
||||
parentId: ch.parent_id?.toString() || null,
|
||||
topic: ch.topic || null,
|
||||
guildId: ch.guild_id?.toString() || null,
|
||||
guildName: ch.guild_name,
|
||||
guildIcon: ch.guild_icon,
|
||||
messageCount: parseInt(ch.message_count) || 0,
|
||||
}));
|
||||
const formattedChannels = channels.map(ch => {
|
||||
// Use cached icon URL if available, otherwise fall back to Discord CDN
|
||||
let guildIcon = ch.guild_icon;
|
||||
if (ch.guild_cached_icon_id) {
|
||||
const sig = signImageId(ch.guild_cached_icon_id);
|
||||
guildIcon = `${baseUrl}/api/discord/cached-image?id=${ch.guild_cached_icon_id}&sig=${sig}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: ch.channel_id.toString(),
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
position: ch.position,
|
||||
parentId: ch.parent_id?.toString() || null,
|
||||
topic: ch.topic || null,
|
||||
guildId: ch.guild_id?.toString() || null,
|
||||
guildName: ch.guild_name,
|
||||
guildIcon,
|
||||
messageCount: parseInt(ch.message_count) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return createApiResponse(formattedChannels, 200, setCookieHeader);
|
||||
} catch (error) {
|
||||
|
||||
@@ -160,13 +160,14 @@ export async function GET({ request }) {
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
const createResponse = (data, status = 200) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (setCookieHeader) {
|
||||
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const c of cookies) if (c) headers.append('Set-Cookie', c.trim());
|
||||
}
|
||||
return new Response(JSON.stringify(data), { status, headers });
|
||||
};
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
recordRequest,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
import { signVideoId } from './cached-video.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const IMAGE_PROXY_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||
@@ -118,13 +119,16 @@ export async function GET({ request }) {
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
const createResponse = (data, status = 200) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (setCookieHeader) {
|
||||
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const c of cookies) {
|
||||
if (c) headers.append('Set-Cookie', c.trim());
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(data), { status, headers });
|
||||
};
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
@@ -137,6 +141,7 @@ export async function GET({ request }) {
|
||||
const after = url.searchParams.get('after');
|
||||
const around = url.searchParams.get('around'); // For jumping to specific message
|
||||
const editedSince = url.searchParams.get('editedSince'); // ISO timestamp for fetching recently edited messages
|
||||
const oldest = url.searchParams.get('oldest') === 'true'; // Fetch oldest messages first
|
||||
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
|
||||
@@ -325,6 +330,7 @@ export async function GET({ request }) {
|
||||
(
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
@@ -362,6 +368,46 @@ export async function GET({ request }) {
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
} else if (oldest) {
|
||||
// Fetch oldest messages first
|
||||
messages = await sql`
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.raw_data,
|
||||
m.message_type,
|
||||
m.content,
|
||||
m.created_at,
|
||||
m.edited_at,
|
||||
m.reference_message_id,
|
||||
m.channel_id,
|
||||
m.webhook_id,
|
||||
m.reference_guild_id,
|
||||
c.guild_id as message_guild_id,
|
||||
u.user_id as author_id,
|
||||
u.username as author_username,
|
||||
u.discriminator as author_discriminator,
|
||||
u.avatar_url as author_avatar,
|
||||
u.is_bot as author_bot,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||
(
|
||||
SELECT r.color
|
||||
FROM roles r
|
||||
WHERE r.role_id = ANY(gm.roles)
|
||||
AND r.color IS NOT NULL
|
||||
AND r.color != 0
|
||||
ORDER BY r.position DESC
|
||||
LIMIT 1
|
||||
) as author_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.author_id = u.user_id
|
||||
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
AND m.is_deleted = FALSE
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
} else {
|
||||
messages = await sql`
|
||||
SELECT
|
||||
@@ -484,10 +530,13 @@ export async function GET({ request }) {
|
||||
// Fetch attachments for all messages
|
||||
const attachments = await sql`
|
||||
SELECT
|
||||
attachment_id, message_id, filename, url, proxy_url,
|
||||
content_type, size, width, height, cached_image_id
|
||||
FROM attachments
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
a.attachment_id, a.message_id, a.filename, a.url, a.proxy_url,
|
||||
a.content_type, a.size, a.width, a.height, a.cached_image_id,
|
||||
a.cached_video_id,
|
||||
vc.file_path as video_file_path
|
||||
FROM attachments a
|
||||
LEFT JOIN video_cache vc ON a.cached_video_id = vc.video_id
|
||||
WHERE a.message_id = ANY(${messageIds})
|
||||
`;
|
||||
|
||||
// Fetch embeds for all messages
|
||||
@@ -542,23 +591,27 @@ export async function GET({ request }) {
|
||||
}
|
||||
|
||||
// Fetch reactions for all messages (aggregate counts since each reaction is a row)
|
||||
// Join with emojis table to get cached_image_id for custom emojis
|
||||
const reactions = await sql`
|
||||
SELECT
|
||||
message_id, emoji_id, emoji_name, emoji_animated,
|
||||
COUNT(*) FILTER (WHERE is_removed = FALSE) as count
|
||||
FROM reactions
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
AND is_removed = FALSE
|
||||
GROUP BY message_id, emoji_id, emoji_name, emoji_animated
|
||||
r.message_id, r.emoji_id, r.emoji_name, r.emoji_animated,
|
||||
e.cached_image_id as emoji_cached_image_id,
|
||||
COUNT(*) FILTER (WHERE r.is_removed = FALSE) as count
|
||||
FROM reactions r
|
||||
LEFT JOIN emojis e ON r.emoji_id = e.emoji_id
|
||||
WHERE r.message_id = ANY(${messageIds})
|
||||
AND r.is_removed = FALSE
|
||||
GROUP BY r.message_id, r.emoji_id, r.emoji_name, r.emoji_animated, e.cached_image_id
|
||||
`;
|
||||
|
||||
// Fetch stickers for all messages
|
||||
// Fetch stickers for all messages (include cached_image_id for locally cached stickers)
|
||||
const stickers = await sql`
|
||||
SELECT
|
||||
ms.message_id,
|
||||
s.sticker_id,
|
||||
s.name,
|
||||
s.format_type
|
||||
s.format_type,
|
||||
s.cached_image_id
|
||||
FROM message_stickers ms
|
||||
JOIN stickers s ON ms.sticker_id = s.sticker_id
|
||||
WHERE ms.message_id = ANY(${messageIds})
|
||||
@@ -595,6 +648,44 @@ export async function GET({ request }) {
|
||||
ORDER BY pa.answer_id
|
||||
`;
|
||||
|
||||
// Extract all custom emoji IDs from message content to look up cached images
|
||||
const allEmojiIds = new Set();
|
||||
for (const msg of messages) {
|
||||
if (msg.content) {
|
||||
// Match <:name:id> and <a:name:id> patterns
|
||||
const matches = msg.content.matchAll(/<a?:\w+:(\d+)>/g);
|
||||
for (const match of matches) {
|
||||
allEmojiIds.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also include poll emojis
|
||||
for (const poll of polls) {
|
||||
if (poll.question_emoji_id) allEmojiIds.add(String(poll.question_emoji_id));
|
||||
}
|
||||
for (const answer of pollAnswers) {
|
||||
if (answer.answer_emoji_id) allEmojiIds.add(String(answer.answer_emoji_id));
|
||||
}
|
||||
|
||||
// Fetch emoji cache info for all referenced emojis
|
||||
let emojiCacheMap = {};
|
||||
const emojiIdArray = Array.from(allEmojiIds);
|
||||
if (emojiIdArray.length > 0) {
|
||||
const emojiCacheRows = await sql`
|
||||
SELECT emoji_id, emoji_animated, cached_image_id
|
||||
FROM emojis
|
||||
WHERE emoji_id = ANY(${emojiIdArray}::bigint[])
|
||||
AND cached_image_id IS NOT NULL
|
||||
`;
|
||||
for (const row of emojiCacheRows) {
|
||||
const sig = signImageId(row.cached_image_id);
|
||||
emojiCacheMap[String(row.emoji_id)] = {
|
||||
url: `${baseUrl}/api/discord/cached-image?id=${row.cached_image_id}&sig=${sig}`,
|
||||
animated: row.emoji_animated,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch poll votes with user info
|
||||
const pollMessageIds = polls.map(p => p.message_id);
|
||||
let pollVotes = [];
|
||||
@@ -640,6 +731,55 @@ export async function GET({ request }) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Build a map of video cache entries for any attachment/embed video URLs
|
||||
const candidateVideoUrls = [];
|
||||
for (const att of attachments) {
|
||||
if (att.content_type?.startsWith('video/')) {
|
||||
candidateVideoUrls.push(att.url || att.proxy_url);
|
||||
}
|
||||
}
|
||||
for (const emb of embeds) {
|
||||
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) => {
|
||||
if (!url) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.origin}${u.pathname}`;
|
||||
} catch {
|
||||
return url.split('#')[0].split('?')[0];
|
||||
}
|
||||
};
|
||||
|
||||
let videoCacheRows = [];
|
||||
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)));
|
||||
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
|
||||
const likePatterns = uniqueUrls.map(u => u + '%');
|
||||
videoCacheRows = await sql`
|
||||
SELECT video_id, source_url, file_path, content_type, is_youtube, youtube_id
|
||||
FROM video_cache
|
||||
WHERE source_url LIKE ANY(${likePatterns})
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Map video cache rows by source_url for quick lookup
|
||||
const videoCacheBySource = {};
|
||||
for (const r of videoCacheRows) {
|
||||
if (r?.source_url) {
|
||||
videoCacheBySource[r.source_url] = r;
|
||||
const normalized = normalizeVideoSrc(r.source_url);
|
||||
videoCacheBySource[normalized] = r;
|
||||
}
|
||||
}
|
||||
|
||||
// Index related data by message_id for quick lookup
|
||||
const attachmentsByMessage = {};
|
||||
const embedsByMessage = {};
|
||||
@@ -653,11 +793,40 @@ export async function GET({ request }) {
|
||||
if (!attachmentsByMessage[att.message_id]) {
|
||||
attachmentsByMessage[att.message_id] = [];
|
||||
}
|
||||
|
||||
// For videos use cached video if available; otherwise use direct URL
|
||||
const isVideo = att.content_type?.startsWith('video/');
|
||||
let attachmentUrl;
|
||||
if (isVideo) {
|
||||
// Use cached_video_id from the JOIN if available
|
||||
if (att.cached_video_id && att.video_file_path) {
|
||||
const sig = signVideoId(att.cached_video_id);
|
||||
attachmentUrl = `${baseUrl}/api/discord/cached-video?id=${att.cached_video_id}&sig=${sig}`;
|
||||
} else {
|
||||
// Fallback to URL-based lookup in videoCacheBySource
|
||||
const srcRaw = att.url || att.proxy_url;
|
||||
const src = normalizeVideoSrc(srcRaw);
|
||||
const cached = videoCacheBySource[src];
|
||||
if (cached && cached.file_path) {
|
||||
const sig = signVideoId(cached.video_id);
|
||||
attachmentUrl = `${baseUrl}/api/discord/cached-video?id=${cached.video_id}&sig=${sig}`;
|
||||
} else {
|
||||
// Fallback: use direct URL (Discord CDN) when no cache entry
|
||||
attachmentUrl = srcRaw;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For images prefer cached image or proxy
|
||||
attachmentUrl = getCachedOrProxyUrl(att.cached_image_id, att.url || att.proxy_url, baseUrl);
|
||||
}
|
||||
|
||||
attachmentsByMessage[att.message_id].push({
|
||||
id: att.attachment_id.toString(),
|
||||
filename: att.filename,
|
||||
url: getCachedOrProxyUrl(att.cached_image_id, att.url || att.proxy_url, baseUrl),
|
||||
contentType: att.content_type,
|
||||
url: attachmentUrl,
|
||||
// Keep original/origin URL so the client can de-dupe previews when we return a cached URL
|
||||
originalUrl: att.url || att.proxy_url || null,
|
||||
content_type: att.content_type,
|
||||
size: att.size,
|
||||
width: att.width,
|
||||
height: att.height,
|
||||
@@ -689,11 +858,16 @@ export async function GET({ request }) {
|
||||
width: embed.thumbnail_width,
|
||||
height: embed.thumbnail_height,
|
||||
} : null,
|
||||
video: embed.video_url ? {
|
||||
url: embed.video_url, // Video URLs usually need to be direct for embedding
|
||||
width: embed.video_width,
|
||||
height: embed.video_height,
|
||||
} : null,
|
||||
video: embed.video_url ? (() => {
|
||||
const src = normalizeVideoSrc(embed.video_url);
|
||||
const cached = videoCacheBySource[src];
|
||||
if (cached && cached.file_path) {
|
||||
const sig = signVideoId(cached.video_id);
|
||||
return { url: `${baseUrl}/api/discord/cached-video?id=${cached.video_id}&sig=${sig}`, width: embed.video_width, height: embed.video_height };
|
||||
}
|
||||
// fallback to original url
|
||||
return { url: embed.video_url, width: embed.video_width, height: embed.video_height };
|
||||
})() : null,
|
||||
provider: embed.provider_name ? {
|
||||
name: embed.provider_name,
|
||||
url: embed.provider_url,
|
||||
@@ -712,10 +886,25 @@ export async function GET({ request }) {
|
||||
if (!reactionsByMessage[reaction.message_id]) {
|
||||
reactionsByMessage[reaction.message_id] = [];
|
||||
}
|
||||
// Build emoji object with optional cached image URL for custom emojis
|
||||
let emojiObj;
|
||||
if (reaction.emoji_id) {
|
||||
// Custom emoji - use cached image if available, otherwise construct Discord CDN URL
|
||||
let emojiUrl;
|
||||
if (reaction.emoji_cached_image_id) {
|
||||
const sig = signImageId(reaction.emoji_cached_image_id);
|
||||
emojiUrl = `${baseUrl}/api/discord/cached-image?id=${reaction.emoji_cached_image_id}&sig=${sig}`;
|
||||
} else {
|
||||
const ext = reaction.emoji_animated ? 'gif' : 'png';
|
||||
emojiUrl = `https://cdn.discordapp.com/emojis/${reaction.emoji_id}.${ext}?size=32`;
|
||||
}
|
||||
emojiObj = { id: reaction.emoji_id.toString(), name: reaction.emoji_name, animated: reaction.emoji_animated, url: emojiUrl };
|
||||
} else {
|
||||
// Standard Unicode emoji
|
||||
emojiObj = { name: reaction.emoji_name };
|
||||
}
|
||||
reactionsByMessage[reaction.message_id].push({
|
||||
emoji: reaction.emoji_id
|
||||
? { id: reaction.emoji_id.toString(), name: reaction.emoji_name, animated: reaction.emoji_animated }
|
||||
: { name: reaction.emoji_name },
|
||||
emoji: emojiObj,
|
||||
count: parseInt(reaction.count, 10),
|
||||
});
|
||||
}
|
||||
@@ -727,11 +916,19 @@ export async function GET({ request }) {
|
||||
stickersByMessage[sticker.message_id] = [];
|
||||
}
|
||||
const ext = stickerExtensions[sticker.format_type] || 'png';
|
||||
// Use cached sticker image if available, otherwise fall back to Discord CDN
|
||||
let stickerUrl;
|
||||
if (sticker.cached_image_id) {
|
||||
const sig = signImageId(sticker.cached_image_id);
|
||||
stickerUrl = `${baseUrl}/api/discord/cached-image?id=${sticker.cached_image_id}&sig=${sig}`;
|
||||
} else {
|
||||
stickerUrl = `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`;
|
||||
}
|
||||
stickersByMessage[sticker.message_id].push({
|
||||
id: sticker.sticker_id.toString(),
|
||||
name: sticker.name,
|
||||
formatType: sticker.format_type,
|
||||
url: `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`,
|
||||
url: stickerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -908,7 +1105,7 @@ export async function GET({ request }) {
|
||||
};
|
||||
});
|
||||
|
||||
return createResponse({ messages: formattedMessages, users: usersMap });
|
||||
return createResponse({ messages: formattedMessages, users: usersMap, emojiCache: emojiCacheMap });
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
return createResponse({ error: 'Failed to fetch messages' }, 500);
|
||||
|
||||
@@ -34,13 +34,14 @@ export async function GET({ request }) {
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
const createResponse = (data, status = 200) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (setCookieHeader) {
|
||||
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const c of cookies) if (c) headers.append('Set-Cookie', c.trim());
|
||||
}
|
||||
return new Response(JSON.stringify(data), { status, headers });
|
||||
};
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
@@ -73,14 +74,15 @@ export async function GET({ request }) {
|
||||
let users;
|
||||
if (emojiId) {
|
||||
users = await sql`
|
||||
SELECT
|
||||
SELECT DISTINCT ON (u.user_id)
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.avatar_url,
|
||||
u.cached_avatar_id,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as guild_avatar,
|
||||
gm.cached_guild_avatar_id
|
||||
gm.cached_guild_avatar_id,
|
||||
r.added_at
|
||||
FROM reactions r
|
||||
JOIN users u ON r.user_id = u.user_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
@@ -88,18 +90,19 @@ export async function GET({ request }) {
|
||||
AND r.emoji_name = ${emojiName}
|
||||
AND r.emoji_id = ${emojiId}
|
||||
AND r.is_removed = FALSE
|
||||
ORDER BY r.added_at ASC
|
||||
ORDER BY u.user_id, r.added_at ASC
|
||||
`;
|
||||
} else {
|
||||
users = await sql`
|
||||
SELECT
|
||||
SELECT DISTINCT ON (u.user_id)
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.avatar_url,
|
||||
u.cached_avatar_id,
|
||||
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||
COALESCE(gm.guild_avatar_url, u.avatar_url) as guild_avatar,
|
||||
gm.cached_guild_avatar_id
|
||||
gm.cached_guild_avatar_id,
|
||||
r.added_at
|
||||
FROM reactions r
|
||||
JOIN users u ON r.user_id = u.user_id
|
||||
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||
@@ -107,7 +110,7 @@ export async function GET({ request }) {
|
||||
AND r.emoji_name = ${emojiName}
|
||||
AND r.emoji_id IS NULL
|
||||
AND r.is_removed = FALSE
|
||||
ORDER BY r.added_at ASC
|
||||
ORDER BY u.user_id, r.added_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,47 @@ import {
|
||||
recordRequest,
|
||||
getCookieId,
|
||||
} from '../../../utils/rateLimit.js';
|
||||
import { signVideoId } from './cached-video.js';
|
||||
import { signImageId } from './cached-image.js';
|
||||
|
||||
// Escape LIKE/ILIKE metacharacters to prevent pattern injection
|
||||
/**
|
||||
* Escapes special characters in a string for safe use in SQL LIKE/ILIKE queries.
|
||||
*
|
||||
* This function prevents SQL injection and pattern manipulation by escaping
|
||||
* the LIKE/ILIKE metacharacters: %, _, and \. It should be used on any user input
|
||||
* that will be interpolated into a LIKE or ILIKE pattern.
|
||||
*
|
||||
* @param {string} str - The input string to escape.
|
||||
* @returns {string} The escaped string, safe for use in LIKE/ILIKE patterns.
|
||||
*/
|
||||
function escapeLikePattern(str) {
|
||||
return str.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize video URL by removing query parameters and hash fragments for cache lookup
|
||||
*/
|
||||
function normalizeVideoUrl(url) {
|
||||
if (!url) return url;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.origin + urlObj.pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns cached image URL if available, otherwise returns proxy URL
|
||||
*/
|
||||
function getCachedOrProxyUrl(cachedImageId, proxyUrl) {
|
||||
if (cachedImageId) {
|
||||
const sig = signImageId(cachedImageId);
|
||||
return `/api/discord/cached-image?id=${cachedImageId}&sig=${sig}`;
|
||||
}
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Rate limit check for authenticated endpoints
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
@@ -41,13 +76,14 @@ export async function GET({ request }) {
|
||||
if (authError) return authError;
|
||||
|
||||
// Helper to create responses with optional Set-Cookie header
|
||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(setCookieHeader && { 'Set-Cookie': setCookieHeader }),
|
||||
},
|
||||
});
|
||||
const createResponse = (data, status = 200) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (setCookieHeader) {
|
||||
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const c of cookies) if (c) headers.append('Set-Cookie', c.trim());
|
||||
}
|
||||
return new Response(JSON.stringify(data), { status, headers });
|
||||
};
|
||||
|
||||
// Helper to validate Discord snowflake IDs (17-20 digit strings)
|
||||
const isValidSnowflake = (id) => !id || /^\d{17,20}$/.test(id);
|
||||
@@ -127,23 +163,43 @@ export async function GET({ request }) {
|
||||
// Get message IDs for fetching related data
|
||||
const messageIds = messages.map(m => m.message_id);
|
||||
|
||||
// Fetch attachments
|
||||
// Fetch attachments with cached video info via JOIN
|
||||
const attachments = await sql`
|
||||
SELECT
|
||||
message_id,
|
||||
attachment_id,
|
||||
filename,
|
||||
url,
|
||||
proxy_url,
|
||||
content_type,
|
||||
size,
|
||||
width,
|
||||
height
|
||||
FROM attachments
|
||||
WHERE message_id = ANY(${messageIds})
|
||||
ORDER BY attachment_id
|
||||
a.message_id,
|
||||
a.attachment_id,
|
||||
a.filename,
|
||||
a.url,
|
||||
a.proxy_url,
|
||||
a.content_type,
|
||||
a.size,
|
||||
a.width,
|
||||
a.height,
|
||||
a.cached_image_id,
|
||||
a.cached_video_id,
|
||||
vc.file_path as video_file_path
|
||||
FROM attachments a
|
||||
LEFT JOIN video_cache vc ON a.cached_video_id = vc.video_id
|
||||
WHERE a.message_id = ANY(${messageIds})
|
||||
ORDER BY a.attachment_id
|
||||
`;
|
||||
|
||||
// Fetch cached video URLs for video attachments
|
||||
const videoUrls = attachments
|
||||
.filter(att => att.content_type && att.content_type.startsWith('video/'))
|
||||
.map(att => normalizeVideoUrl(att.url));
|
||||
|
||||
const cachedVideos = videoUrls.length > 0 ? await sql`
|
||||
SELECT source_url, video_id
|
||||
FROM video_cache
|
||||
WHERE source_url = ANY(${videoUrls})
|
||||
` : [];
|
||||
|
||||
const videoCacheMap = {};
|
||||
cachedVideos.forEach(v => {
|
||||
videoCacheMap[v.source_url] = v.video_id;
|
||||
});
|
||||
|
||||
// Fetch embeds
|
||||
const embeds = await sql`
|
||||
SELECT
|
||||
@@ -215,12 +271,41 @@ export async function GET({ request }) {
|
||||
if (!attachmentsByMessage[att.message_id]) {
|
||||
attachmentsByMessage[att.message_id] = [];
|
||||
}
|
||||
|
||||
// Determine the best URL to use
|
||||
let attachmentUrl = att.url;
|
||||
let attachmentProxyUrl = att.proxy_url;
|
||||
|
||||
// Check if this is a video and has a cached version
|
||||
if (att.content_type && att.content_type.startsWith('video/')) {
|
||||
// Use cached_video_id from the JOIN if available
|
||||
if (att.cached_video_id && att.video_file_path) {
|
||||
const sig = signVideoId(att.cached_video_id);
|
||||
attachmentUrl = `/api/discord/cached-video?id=${att.cached_video_id}&sig=${sig}`;
|
||||
attachmentProxyUrl = attachmentUrl;
|
||||
} else {
|
||||
// Fallback to URL-based lookup
|
||||
const normalizedUrl = normalizeVideoUrl(att.url);
|
||||
const cachedVideoId = videoCacheMap[normalizedUrl];
|
||||
if (cachedVideoId) {
|
||||
const sig = signVideoId(cachedVideoId);
|
||||
attachmentUrl = `/api/discord/cached-video?id=${cachedVideoId}&sig=${sig}`;
|
||||
attachmentProxyUrl = attachmentUrl;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For images, use cached version if available
|
||||
const cachedUrl = getCachedOrProxyUrl(att.cached_image_id, att.proxy_url);
|
||||
attachmentUrl = cachedUrl;
|
||||
attachmentProxyUrl = cachedUrl;
|
||||
}
|
||||
|
||||
attachmentsByMessage[att.message_id].push({
|
||||
id: att.attachment_id,
|
||||
filename: att.filename,
|
||||
url: att.url,
|
||||
proxyUrl: att.proxy_url,
|
||||
contentType: att.content_type,
|
||||
url: attachmentUrl,
|
||||
proxyUrl: attachmentProxyUrl,
|
||||
content_type: att.content_type,
|
||||
size: att.size,
|
||||
width: att.width,
|
||||
height: att.height,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createNonceCookie,
|
||||
getClientIp,
|
||||
} from '../../utils/rateLimit.js';
|
||||
import { validateCsrfToken, generateCsrfToken } from '../../utils/csrf.js';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const host = request.headers.get('host');
|
||||
@@ -15,6 +16,47 @@ export async function POST({ request }) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
|
||||
// Validate CSRF token before rate limiting (to avoid consuming rate limit on invalid requests)
|
||||
let csrfToken;
|
||||
try {
|
||||
const body = await request.json();
|
||||
csrfToken = body.csrfToken;
|
||||
|
||||
if (!validateCsrfToken(csrfToken, cookieId)) {
|
||||
const response = new Response(JSON.stringify({
|
||||
error: 'Invalid or expired security token. Please refresh the page and try again.'
|
||||
}), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
console.log(`[submit] CSRF validation failed: cookieId=${cookieId}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Re-parse body for later use (since we already consumed the stream)
|
||||
request.bodyData = body;
|
||||
} catch (error) {
|
||||
const response = new Response(JSON.stringify({
|
||||
error: 'Invalid request format'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Rate limit check (1 request per 15 seconds, flood protection at 10/30s)
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
limit: 1,
|
||||
@@ -23,12 +65,6 @@ export async function POST({ request }) {
|
||||
burstWindowMs: 30_000,
|
||||
});
|
||||
|
||||
let cookieId = getCookieId(request);
|
||||
const hadCookie = !!cookieId;
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const errorMsg = rateCheck.isFlooding
|
||||
? { error: 'Too many requests - please slow down' }
|
||||
@@ -62,7 +98,8 @@ export async function POST({ request }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, year, type, requester } = await request.json();
|
||||
// Use pre-parsed body data
|
||||
const { title, year, type, requester } = request.bodyData;
|
||||
|
||||
// Input validation
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
@@ -233,7 +270,10 @@ export async function POST({ request }) {
|
||||
// Record the request for rate limiting after successful submission
|
||||
recordRateLimitRequest(request, 15_000);
|
||||
|
||||
const response = new Response(JSON.stringify({ success: true }), {
|
||||
// Generate a new CSRF token for the next submission
|
||||
const newCsrfToken = generateCsrfToken(cookieId);
|
||||
|
||||
const response = new Response(JSON.stringify({ success: true, csrfToken: newCsrfToken }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!hadCookie) {
|
||||
|
||||
@@ -13,6 +13,18 @@ Astro.response.headers.set('Pragma', 'no-cache');
|
||||
---
|
||||
|
||||
<Base title="Discord Archive" description="Archived Discord channel logs">
|
||||
<script is:inline>
|
||||
// Suppress "unreachable code" warnings from YouTube's embedded player
|
||||
(function() {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
if (args[0] && typeof args[0] === 'string' && args[0].includes('unreachable code')) {
|
||||
return; // Suppress YouTube player warnings
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<section class="page-section discord-logs-section">
|
||||
<Root child="DiscordLogs" client:only="react" />
|
||||
</section>
|
||||
@@ -20,7 +32,7 @@ Astro.response.headers.set('Pragma', 'no-cache');
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for Discord logs */
|
||||
body:has(.discord-logs-section) main.page-enter {
|
||||
body:has(.discord-logs-section) main {
|
||||
max-width: 1400px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,12 @@ import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from '@/hooks/requireAuthHook';
|
||||
const user = await requireAuthHook(Astro);
|
||||
const isLoggedIn = Boolean(user);
|
||||
const accessDenied = Astro.locals.accessDenied || false;
|
||||
const requiredRoles = Astro.locals.requiredRoles || [];
|
||||
|
||||
---
|
||||
<Base>
|
||||
<section class="page-section">
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" />
|
||||
<Root child="LoginPage" loggedIn={isLoggedIn} accessDenied={accessDenied} requiredRoles={requiredRoles} client:only="react" />
|
||||
</section>
|
||||
</Base>
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<h2>Req subsite debug</h2>
|
||||
<pre>{JSON.stringify({ headers: Object.fromEntries(Astro.request.headers.entries()), pathname: Astro.url.pathname }, null, 2)}</pre>
|
||||
</Base>
|
||||
@@ -1,8 +1,30 @@
|
||||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
import ReqForm from "../../../components/req/ReqForm.jsx";
|
||||
import SubsiteBase from "../../../layouts/SubsiteBase.astro";
|
||||
import SubsiteRoot from "../../../components/SubsiteAppLayout.jsx";
|
||||
import { generateCsrfToken } from "../../../utils/csrf.js";
|
||||
import { getCookieId, generateNonce, createNonceCookie } from "../../../utils/rateLimit.js";
|
||||
|
||||
// Generate CSRF token during SSR
|
||||
let cookieId = getCookieId(Astro.request);
|
||||
const hadCookie = !!cookieId;
|
||||
|
||||
if (!cookieId) {
|
||||
cookieId = generateNonce();
|
||||
}
|
||||
|
||||
const csrfToken = generateCsrfToken(cookieId);
|
||||
|
||||
// Set cookie if new session
|
||||
if (!hadCookie) {
|
||||
Astro.response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||
}
|
||||
---
|
||||
|
||||
<Base>
|
||||
<ReqForm client:load />
|
||||
</Base>
|
||||
<SubsiteBase title="Request Media">
|
||||
<script is:inline define:vars={{ csrfToken }}>
|
||||
(function(w,d){w[d]=atob(btoa(csrfToken))})(window,'_t');
|
||||
</script>
|
||||
<section class="page-section">
|
||||
<SubsiteRoot child="ReqForm" client:only="react" />
|
||||
</section>
|
||||
</SubsiteBase>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Short-term failure cache for API auth timeouts
|
||||
let lastAuthFailureTime = 0;
|
||||
const AUTH_FAILURE_CACHE_MS = 30000; // 30 seconds
|
||||
/**
|
||||
* API route authentication helper
|
||||
* Validates user session for protected API endpoints
|
||||
@@ -10,6 +13,17 @@ import { API_URL } from '@/config';
|
||||
* @returns {Promise<{user: object|null, error: Response|null, setCookieHeader: string|null}>}
|
||||
*/
|
||||
export async function requireApiAuth(request) {
|
||||
// If we recently failed due to API timeout, immediately fail closed
|
||||
if (Date.now() - lastAuthFailureTime < AUTH_FAILURE_CACHE_MS) {
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out (cached)' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||
|
||||
@@ -25,20 +39,57 @@ export async function requireApiAuth(request) {
|
||||
}
|
||||
|
||||
// Try to get user identity
|
||||
let res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
// Add timeout to fetch
|
||||
let controller = new AbortController();
|
||||
let timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
lastAuthFailureTime = Date.now();
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
let newSetCookieHeader = null;
|
||||
|
||||
// If unauthorized, try to refresh the token
|
||||
if (res.status === 401) {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
// Add timeout to refresh fetch
|
||||
controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), 3000);
|
||||
let refreshRes;
|
||||
try {
|
||||
refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
return {
|
||||
@@ -51,13 +102,19 @@ export async function requireApiAuth(request) {
|
||||
};
|
||||
}
|
||||
|
||||
// Capture the Set-Cookie header from the refresh response to forward to client
|
||||
newSetCookieHeader = refreshRes.headers.get('set-cookie');
|
||||
|
||||
// Capture the Set-Cookie headers from the refresh response to forward to client
|
||||
if (typeof refreshRes.headers.getSetCookie === 'function') {
|
||||
newSetCookieHeader = refreshRes.headers.getSetCookie();
|
||||
} else {
|
||||
const sc = refreshRes.headers.get('set-cookie');
|
||||
if (sc) newSetCookieHeader = sc.split(/,(?=\s*\w+=)/).map(s => s.trim());
|
||||
}
|
||||
|
||||
let newCookieHeader = cookieHeader;
|
||||
|
||||
if (newSetCookieHeader) {
|
||||
const cookiesArray = newSetCookieHeader.split(/,(?=\s*\w+=)/);
|
||||
if (newSetCookieHeader && newSetCookieHeader.length > 0) {
|
||||
// newSetCookieHeader is an array of cookie strings; build a cookie header for the retry
|
||||
const cookiesArray = Array.isArray(newSetCookieHeader) ? newSetCookieHeader : [newSetCookieHeader];
|
||||
newCookieHeader = cookiesArray.map(c => c.split(';')[0]).join('; ');
|
||||
} else {
|
||||
return {
|
||||
@@ -70,10 +127,28 @@ export async function requireApiAuth(request) {
|
||||
};
|
||||
}
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: 'include',
|
||||
});
|
||||
// Add timeout to retry fetch
|
||||
let retryController = new AbortController();
|
||||
let retryTimeout = setTimeout(() => retryController.abort(), 3000);
|
||||
try {
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: 'include',
|
||||
signal: retryController.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(retryTimeout);
|
||||
lastAuthFailureTime = Date.now();
|
||||
return {
|
||||
user: null,
|
||||
error: new Response(JSON.stringify({ error: 'Authentication error', message: 'API unreachable or timed out' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
setCookieHeader: null,
|
||||
};
|
||||
}
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -109,9 +184,12 @@ export async function requireApiAuth(request) {
|
||||
* @param {string|null} setCookieHeader - Set-Cookie header from auth refresh
|
||||
*/
|
||||
export function createApiResponse(data, status = 200, setCookieHeader = null) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (setCookieHeader) {
|
||||
headers['Set-Cookie'] = setCookieHeader;
|
||||
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
||||
for (const c of cookies) {
|
||||
if (c) headers.append('Set-Cookie', c.trim());
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(data), { status, headers });
|
||||
}
|
||||
|
||||
79
src/utils/csrf.js
Normal file
79
src/utils/csrf.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* CSRF token utilities
|
||||
* Generates and validates CSRF tokens to prevent cross-site request forgery
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
|
||||
const CSRF_TOKEN_LENGTH = 32;
|
||||
const CSRF_TOKEN_EXPIRY = 3600000; // 1 hour in milliseconds
|
||||
|
||||
// In-memory token store (for production, use Redis or database)
|
||||
const tokenStore = new Map();
|
||||
|
||||
// Cleanup expired tokens periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, data] of tokenStore.entries()) {
|
||||
if (data.expiresAt < now) {
|
||||
tokenStore.delete(token);
|
||||
}
|
||||
}
|
||||
}, 300000); // Clean every 5 minutes
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token
|
||||
* @param {string} sessionId - Unique identifier for the user session (e.g., cookie ID)
|
||||
* @returns {string} The generated CSRF token
|
||||
*/
|
||||
export function generateCsrfToken(sessionId) {
|
||||
const token = crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('hex');
|
||||
const expiresAt = Date.now() + CSRF_TOKEN_EXPIRY;
|
||||
|
||||
tokenStore.set(token, {
|
||||
sessionId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function validateCsrfToken(token, sessionId) {
|
||||
if (!token || !sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = tokenStore.get(token);
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (data.expiresAt < Date.now()) {
|
||||
tokenStore.delete(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check session ID match
|
||||
if (data.sessionId !== sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token is valid - consume it (one-time use)
|
||||
tokenStore.delete(token);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token store size (for monitoring)
|
||||
*/
|
||||
export function getTokenStoreSize() {
|
||||
return tokenStore.size;
|
||||
}
|
||||
Reference in New Issue
Block a user