Add support for Lottie stickers and enhance disk space API endpoint
- Introduced Lottie sticker component with placeholder handling in DiscordLogs. - Expanded Discord message types in DiscordLogs component. - Implemented disk space fetching in MediaRequestForm with visual indicator. - Enhanced API for fetching Discord messages to include Lottie data for stickers. - Added disk space API endpoint with authentication and authorization checks.
This commit is contained in:
@@ -916,13 +916,26 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.discord-sticker-lottie svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.discord-sticker-placeholder {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(88, 101, 242, 0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #72767d);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .discord-sticker-lottie {
|
||||
[data-theme="dark"] .discord-sticker-placeholder {
|
||||
background: rgba(88, 101, 242, 0.15);
|
||||
color: #b9bbbe;
|
||||
}
|
||||
@@ -1762,6 +1775,35 @@ a.discord-embed-title:hover {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Havoc bot disclaimer */
|
||||
.discord-havoc-disclaimer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 1rem 1rem 0.5rem;
|
||||
background: rgba(250, 168, 26, 0.1);
|
||||
border: 1px solid rgba(250, 168, 26, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #b5851b;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.discord-havoc-disclaimer svg {
|
||||
flex-shrink: 0;
|
||||
color: #faa81a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .discord-havoc-disclaimer {
|
||||
background: rgba(250, 168, 26, 0.15);
|
||||
border-color: rgba(250, 168, 26, 0.4);
|
||||
color: #f0c674;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .discord-havoc-disclaimer svg {
|
||||
color: #faa81a;
|
||||
}
|
||||
|
||||
/* Pagination / Load more */
|
||||
.discord-load-more {
|
||||
display: flex;
|
||||
@@ -1966,6 +2008,11 @@ a.discord-embed-title:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.discord-system-unknown {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .discord-system-message {
|
||||
color: #949ba4;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,36 @@ 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_RECIPIENT_ADD = 1;
|
||||
const MESSAGE_TYPE_RECIPIENT_REMOVE = 2;
|
||||
const MESSAGE_TYPE_CALL = 3;
|
||||
const MESSAGE_TYPE_CHANNEL_NAME_CHANGE = 4;
|
||||
const MESSAGE_TYPE_CHANNEL_ICON_CHANGE = 5;
|
||||
const MESSAGE_TYPE_CHANNEL_PINNED_MESSAGE = 6;
|
||||
const MESSAGE_TYPE_USER_JOIN = 7;
|
||||
const MESSAGE_TYPE_GUILD_BOOST = 8;
|
||||
const MESSAGE_TYPE_GUILD_BOOST_TIER_1 = 9;
|
||||
const MESSAGE_TYPE_GUILD_BOOST_TIER_2 = 10;
|
||||
const MESSAGE_TYPE_GUILD_BOOST_TIER_3 = 11;
|
||||
const MESSAGE_TYPE_CHANNEL_FOLLOW_ADD = 12;
|
||||
const MESSAGE_TYPE_GUILD_DISCOVERY_DISQUALIFIED = 14;
|
||||
const MESSAGE_TYPE_GUILD_DISCOVERY_REQUALIFIED = 15;
|
||||
const MESSAGE_TYPE_GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING = 16;
|
||||
const MESSAGE_TYPE_GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING = 17;
|
||||
const MESSAGE_TYPE_THREAD_CREATED = 18;
|
||||
const MESSAGE_TYPE_REPLY = 19;
|
||||
const MESSAGE_TYPE_CHAT_INPUT_COMMAND = 20;
|
||||
const MESSAGE_TYPE_THREAD_STARTER_MESSAGE = 21;
|
||||
const MESSAGE_TYPE_GUILD_INVITE_REMINDER = 22;
|
||||
const MESSAGE_TYPE_CONTEXT_MENU_COMMAND = 23;
|
||||
const MESSAGE_TYPE_AUTO_MODERATION_ACTION = 24;
|
||||
const MESSAGE_TYPE_ROLE_SUBSCRIPTION_PURCHASE = 25;
|
||||
const MESSAGE_TYPE_INTERACTION_PREMIUM_UPSELL = 26;
|
||||
const MESSAGE_TYPE_STAGE_START = 27;
|
||||
const MESSAGE_TYPE_STAGE_END = 28;
|
||||
const MESSAGE_TYPE_STAGE_SPEAKER = 29;
|
||||
const MESSAGE_TYPE_STAGE_TOPIC = 31;
|
||||
const MESSAGE_TYPE_GUILD_APPLICATION_PREMIUM_SUBSCRIPTION = 32;
|
||||
const MESSAGE_TYPE_POLL_RESULT = 46;
|
||||
|
||||
// Image modal context for child components to trigger modal
|
||||
@@ -907,6 +934,60 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Lottie Sticker Component
|
||||
// ============================================================================
|
||||
|
||||
const LottieSticker = memo(function LottieSticker({ data, name }) {
|
||||
const containerRef = useRef(null);
|
||||
const animationRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !data) return;
|
||||
|
||||
// Dynamically import lottie-web only when needed
|
||||
import('lottie-web').then((lottie) => {
|
||||
// Clean up any existing animation
|
||||
if (animationRef.current) {
|
||||
animationRef.current.destroy();
|
||||
}
|
||||
|
||||
animationRef.current = lottie.default.loadAnimation({
|
||||
container: containerRef.current,
|
||||
renderer: 'svg',
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: data,
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('Failed to load lottie-web:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
animationRef.current.destroy();
|
||||
animationRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="discord-sticker-placeholder">
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="discord-sticker-lottie"
|
||||
aria-label={name}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Attachment Component
|
||||
// ============================================================================
|
||||
@@ -1205,19 +1286,109 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
);
|
||||
}
|
||||
// Default system message rendering
|
||||
// Generate system message text based on type
|
||||
const getSystemMessageText = () => {
|
||||
const username = displayAuthor?.displayName || displayAuthor?.username || 'Someone';
|
||||
switch (type) {
|
||||
case MESSAGE_TYPE_USER_JOIN:
|
||||
// Discord has multiple join messages, pick randomly or use content if provided
|
||||
const joinMessages = [
|
||||
'joined the server.',
|
||||
'just joined the server - glhf!',
|
||||
'just joined. Everyone, look busy!',
|
||||
'just arrived.',
|
||||
'just landed.',
|
||||
'just slid into the server.',
|
||||
'hopped into the server.',
|
||||
'appeared.',
|
||||
];
|
||||
return content || joinMessages[0];
|
||||
case MESSAGE_TYPE_GUILD_BOOST:
|
||||
return content || 'just boosted the server!';
|
||||
case MESSAGE_TYPE_GUILD_BOOST_TIER_1:
|
||||
return content || 'just boosted the server! Server has achieved Level 1!';
|
||||
case MESSAGE_TYPE_GUILD_BOOST_TIER_2:
|
||||
return content || 'just boosted the server! Server has achieved Level 2!';
|
||||
case MESSAGE_TYPE_GUILD_BOOST_TIER_3:
|
||||
return content || 'just boosted the server! Server has achieved Level 3!';
|
||||
case MESSAGE_TYPE_CHANNEL_PINNED_MESSAGE:
|
||||
return content || 'pinned a message to this channel.';
|
||||
case MESSAGE_TYPE_THREAD_CREATED:
|
||||
return content || 'started a thread.';
|
||||
case MESSAGE_TYPE_RECIPIENT_ADD:
|
||||
return content || 'added someone to the group.';
|
||||
case MESSAGE_TYPE_RECIPIENT_REMOVE:
|
||||
return content || 'removed someone from the group.';
|
||||
case MESSAGE_TYPE_CALL:
|
||||
return content || 'started a call.';
|
||||
case MESSAGE_TYPE_CHANNEL_NAME_CHANGE:
|
||||
return content || 'changed the channel name.';
|
||||
case MESSAGE_TYPE_CHANNEL_ICON_CHANGE:
|
||||
return content || 'changed the channel icon.';
|
||||
case MESSAGE_TYPE_CHANNEL_FOLLOW_ADD:
|
||||
return content || 'added a channel follow.';
|
||||
case MESSAGE_TYPE_STAGE_START:
|
||||
return content || 'started a Stage.';
|
||||
case MESSAGE_TYPE_STAGE_END:
|
||||
return content || 'ended the Stage.';
|
||||
case MESSAGE_TYPE_STAGE_SPEAKER:
|
||||
return content || 'is now a speaker.';
|
||||
case MESSAGE_TYPE_STAGE_TOPIC:
|
||||
return content || 'changed the Stage topic.';
|
||||
default:
|
||||
return content || '';
|
||||
}
|
||||
};
|
||||
|
||||
const systemText = getSystemMessageText();
|
||||
// Choose icon based on message type
|
||||
const getSystemIcon = () => {
|
||||
switch (type) {
|
||||
case MESSAGE_TYPE_USER_JOIN:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 3l4.5 4.5-1.41 1.41L12 7.83l-3.09 3.08L7.5 9.5 12 5z" />
|
||||
</svg>
|
||||
);
|
||||
case MESSAGE_TYPE_GUILD_BOOST:
|
||||
case MESSAGE_TYPE_GUILD_BOOST_TIER_1:
|
||||
case MESSAGE_TYPE_GUILD_BOOST_TIER_2:
|
||||
case MESSAGE_TYPE_GUILD_BOOST_TIER_3:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="#ff73fa" width="16" height="16">
|
||||
<path d="M12.001 2.5c.512 0 .994.228 1.32.624l.088.114 8.5 12c.25.353.32.805.187 1.214l-.055.134-.061.114a1.5 1.5 0 01-1.191.79l-.119.01H3.331a1.5 1.5 0 01-1.398-2.048l.061-.114 8.5-12a1.5 1.5 0 011.408-.838l.099.01.1.014zM12 7.268L5.416 16.5h13.169L12 7.268z" />
|
||||
</svg>
|
||||
);
|
||||
case MESSAGE_TYPE_CHANNEL_PINNED_MESSAGE:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="discord-message discord-system-message">
|
||||
<div className="discord-system-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M18.5 12c0-1.77-.77-3.37-2-4.47V4.5h-2v1.98A5.96 5.96 0 0 0 12 6c-.97 0-1.89.23-2.71.63L7.11 4.45 5.64 5.92l2.18 2.18A5.96 5.96 0 0 0 6 12c0 3.31 2.69 6 6 6s6-2.69 6-6zm-6 4c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="discord-message discord-system-message">
|
||||
<div className="discord-system-icon">
|
||||
{getSystemIcon()}
|
||||
</div>
|
||||
<span className="discord-system-content">
|
||||
<span className="discord-username" style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}>
|
||||
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'}
|
||||
</span>
|
||||
{' '}
|
||||
<span dangerouslySetInnerHTML={{ __html: parsedContent }} />
|
||||
{systemText ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: parseDiscordMarkdown(systemText, { usersMap, channelMap, emojiCache, rolesMap }) }} />
|
||||
) : (
|
||||
<span className="discord-system-unknown">(system message type {type})</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="discord-timestamp">{formatTimestamp(displayTimestamp)}</span>
|
||||
</div>
|
||||
@@ -1352,18 +1523,21 @@ const DiscordMessage = memo(function DiscordMessage({
|
||||
<div className="discord-stickers">
|
||||
{stickers.map((sticker) => (
|
||||
<div key={sticker.id} className="discord-sticker" title={sticker.name}>
|
||||
{sticker.formatType === 3 ? (
|
||||
// Lottie stickers - show placeholder or name
|
||||
<div className="discord-sticker-lottie">
|
||||
<span>{sticker.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
{sticker.formatType === 3 && sticker.lottieData ? (
|
||||
// Lottie stickers with cached data
|
||||
<LottieSticker data={sticker.lottieData} name={sticker.name} />
|
||||
) : sticker.url ? (
|
||||
<img
|
||||
src={sticker.url}
|
||||
alt={sticker.name}
|
||||
className="discord-sticker-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
// Fallback for missing sticker data
|
||||
<div className="discord-sticker-placeholder">
|
||||
<span>{sticker.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -2811,6 +2985,18 @@ export default function DiscordLogs() {
|
||||
// Check if current channel is dds-archive
|
||||
const isArchiveChannel = selectedChannel?.name === 'dds-archive';
|
||||
|
||||
// Check if Havoc bot is in the channel's member list
|
||||
const HAVOC_BOT_ID = '1219636064608583770';
|
||||
const isHavocInChannel = useMemo(() => {
|
||||
if (!members?.groups) return true; // Assume present if members not loaded
|
||||
for (const group of members.groups) {
|
||||
if (group.members?.some(m => m.id === HAVOC_BOT_ID)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [members]);
|
||||
|
||||
// Group messages by author and time window (5 minutes)
|
||||
// Messages are in ASC order (oldest first, newest at bottom), so we group accordingly
|
||||
// For archive channel, parse messages to get real author/timestamp for grouping
|
||||
@@ -3184,6 +3370,15 @@ export default function DiscordLogs() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Disclaimer when Havoc bot is not in channel */}
|
||||
{!isHavocInChannel && !loadingMembers && (
|
||||
<div className="discord-havoc-disclaimer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||
</svg>
|
||||
<span>Havoc no longer has access to this channel. New activity may not be visible.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function MediaRequestForm() {
|
||||
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null);
|
||||
const [shuffleAlbums, setShuffleAlbums] = useState({});
|
||||
const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 });
|
||||
const [diskSpace, setDiskSpace] = useState(null);
|
||||
|
||||
const debounceTimeout = useRef(null);
|
||||
const autoCompleteRef = useRef(null);
|
||||
@@ -64,6 +65,22 @@ export default function MediaRequestForm() {
|
||||
lastUrlRef.current = window.location.pathname + window.location.search + window.location.hash;
|
||||
}, []);
|
||||
|
||||
// Fetch disk space on mount
|
||||
useEffect(() => {
|
||||
const fetchDiskSpace = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/disk-space');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDiskSpace(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch disk space:', err);
|
||||
}
|
||||
};
|
||||
fetchDiskSpace();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
@@ -999,6 +1016,32 @@ export default function MediaRequestForm() {
|
||||
}
|
||||
`}</style>
|
||||
<BreadcrumbNav currentPage="request" />
|
||||
|
||||
{/* Disk Space Indicator - always visible */}
|
||||
{diskSpace && (
|
||||
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V5c0-1 .5-2 2-2h4c1.5 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className={`font-medium ${
|
||||
diskSpace.usedPercent > 90 ? 'text-red-500' :
|
||||
diskSpace.usedPercent > 75 ? 'text-yellow-500' : 'text-green-600 dark:text-green-400'
|
||||
}`}>{diskSpace.availableFormatted}</span> available
|
||||
</span>
|
||||
<div className="w-20 h-1.5 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${diskSpace.usedPercent > 90 ? 'bg-red-500' :
|
||||
diskSpace.usedPercent > 75 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${diskSpace.usedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-neutral-400">({diskSpace.usedPercent}% used)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -1283,8 +1326,8 @@ export default function MediaRequestForm() {
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={handleSubmitRequest} color="primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
||||
|
||||
@@ -48,6 +48,11 @@ async function performAuth(Astro) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.status === 401) {
|
||||
// Check if we even have a refresh token before attempting refresh
|
||||
if (!cookieHeader.includes('refresh_token=')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try refresh with timeout
|
||||
controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), 3000);
|
||||
@@ -68,7 +73,12 @@ async function performAuth(Astro) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
console.error("Token refresh failed", refreshRes.status);
|
||||
let errorDetail = '';
|
||||
try {
|
||||
const errorBody = await refreshRes.text();
|
||||
errorDetail = ` - ${errorBody}`;
|
||||
} catch {}
|
||||
console.error(`[SSR] Token refresh failed ${refreshRes.status}${errorDetail}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,31 +27,89 @@ if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.proto
|
||||
}
|
||||
|
||||
const API_URL = "https://api.codey.lol";
|
||||
const AUTH_TIMEOUT_MS = 3000; // 3 second timeout for auth requests
|
||||
|
||||
// Deduplication for concurrent refresh requests (prevents race condition where
|
||||
// multiple SSR requests try to refresh simultaneously, causing 401s after the
|
||||
// first one rotates the refresh token)
|
||||
let refreshPromise = null;
|
||||
let lastRefreshResult = null;
|
||||
let lastRefreshTime = 0;
|
||||
const REFRESH_RESULT_TTL = 5000; // Cache successful refresh result for 5 seconds
|
||||
|
||||
// Auth check function (mirrors requireAuthHook logic but for middleware)
|
||||
async function checkAuth(request) {
|
||||
try {
|
||||
const cookieHeader = request.headers.get("cookie") ?? "";
|
||||
if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Cookie header present: ${!!cookieHeader}`);
|
||||
|
||||
let res = await fetch(`${API_URL}/auth/id`, {
|
||||
// Add timeout to prevent hanging
|
||||
let controller = new AbortController;
|
||||
let timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Initial auth/id status: ${res.status}`);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
console.error("[middleware] auth/id failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.status === 401) {
|
||||
// Try refresh
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
// Check if we even have a refresh token before attempting refresh
|
||||
if (!cookieHeader.includes('refresh_token=')) {
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
// Check if we have a recent successful refresh result we can reuse
|
||||
const now = Date.now();
|
||||
if (lastRefreshResult && (now - lastRefreshTime) < REFRESH_RESULT_TTL) {
|
||||
console.log(`[middleware] Reusing cached refresh result from ${now - lastRefreshTime}ms ago`);
|
||||
return lastRefreshResult;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent refresh requests
|
||||
if (refreshPromise) {
|
||||
console.log(`[middleware] Waiting for in-flight refresh request`);
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// Start a new refresh request
|
||||
refreshPromise = (async () => {
|
||||
console.log(`[middleware] Starting token refresh...`);
|
||||
let controller = new AbortController();
|
||||
let timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
|
||||
let refreshRes;
|
||||
try {
|
||||
refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
console.error("[middleware] auth/refresh failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
// Log the response body for debugging
|
||||
let errorDetail = '';
|
||||
try {
|
||||
const errorBody = await refreshRes.text();
|
||||
errorDetail = ` - ${errorBody}`;
|
||||
} catch {}
|
||||
console.error(`[middleware] Token refresh failed ${refreshRes.status}${errorDetail}`);
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
console.log(`[middleware] Token refresh succeeded`);
|
||||
|
||||
// Get refreshed cookies
|
||||
let setCookies = [];
|
||||
@@ -65,23 +123,51 @@ async function checkAuth(request) {
|
||||
}
|
||||
|
||||
if (setCookies.length === 0) {
|
||||
console.error("[middleware] No set-cookie headers in refresh response");
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
// Build new cookie header for retry
|
||||
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
// Retry auth/id with new cookies and timeout
|
||||
controller = new AbortController();
|
||||
timeout = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
|
||||
let retryRes;
|
||||
try {
|
||||
retryRes = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
console.error("[middleware] auth/id retry failed or timed out", err.name === 'AbortError' ? 'timeout' : err);
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
if (!retryRes.ok) {
|
||||
console.error(`[middleware] auth/id retry failed with status ${retryRes.status}`);
|
||||
return { authenticated: false, user: null, cookies: null };
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
const user = await retryRes.json();
|
||||
return { authenticated: true, user, cookies: setCookies };
|
||||
})();
|
||||
|
||||
// Clear the promise when done and cache the result
|
||||
refreshPromise.then(result => {
|
||||
if (result.authenticated) {
|
||||
lastRefreshResult = result;
|
||||
lastRefreshTime = Date.now();
|
||||
}
|
||||
refreshPromise = null;
|
||||
}).catch(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -604,14 +604,15 @@ export async function GET({ request }) {
|
||||
GROUP BY r.message_id, r.emoji_id, r.emoji_name, r.emoji_animated, e.cached_image_id
|
||||
`;
|
||||
|
||||
// Fetch stickers for all messages (include cached_image_id for locally cached stickers)
|
||||
// Fetch stickers for all messages (include cached_image_id and lottie_data for locally cached stickers)
|
||||
const stickers = await sql`
|
||||
SELECT
|
||||
ms.message_id,
|
||||
s.sticker_id,
|
||||
s.name,
|
||||
s.format_type,
|
||||
s.cached_image_id
|
||||
s.cached_image_id,
|
||||
s.lottie_data
|
||||
FROM message_stickers ms
|
||||
JOIN stickers s ON ms.sticker_id = s.sticker_id
|
||||
WHERE ms.message_id = ANY(${messageIds})
|
||||
@@ -910,18 +911,19 @@ export async function GET({ request }) {
|
||||
}
|
||||
|
||||
// Sticker format types: 1=PNG, 2=APNG, 3=Lottie, 4=GIF
|
||||
const stickerExtensions = { 1: 'png', 2: 'png', 3: 'json', 4: 'gif' };
|
||||
const stickerExtensions = { 1: 'png', 2: 'png', 3: 'png', 4: 'gif' };
|
||||
for (const sticker of stickers) {
|
||||
if (!stickersByMessage[sticker.message_id]) {
|
||||
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;
|
||||
let stickerUrl = null;
|
||||
if (sticker.cached_image_id) {
|
||||
const sig = signImageId(sticker.cached_image_id);
|
||||
stickerUrl = `${baseUrl}/api/discord/cached-image?id=${sticker.cached_image_id}&sig=${sig}`;
|
||||
} else {
|
||||
} else if (sticker.format_type !== 3) {
|
||||
// Only use CDN fallback for non-Lottie stickers (Lottie will use lottie_data)
|
||||
stickerUrl = `https://media.discordapp.net/stickers/${sticker.sticker_id}.${ext}?size=160`;
|
||||
}
|
||||
stickersByMessage[sticker.message_id].push({
|
||||
@@ -929,6 +931,7 @@ export async function GET({ request }) {
|
||||
name: sticker.name,
|
||||
formatType: sticker.format_type,
|
||||
url: stickerUrl,
|
||||
lottieData: sticker.lottie_data || null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
70
src/pages/api/disk-space.js
Normal file
70
src/pages/api/disk-space.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { requireApiAuth } from '../../utils/apiAuth.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Check authentication
|
||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Check authorization - must have 'admin' or 'trip' role
|
||||
const userRoles = user?.roles || [];
|
||||
const hasAccess = userRoles.includes('admin') || userRoles.includes('trip');
|
||||
|
||||
if (!hasAccess) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions. Requires admin or trip role.'
|
||||
}), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get disk space for root filesystem
|
||||
const { stdout } = await execAsync("df -B1 / | tail -1 | awk '{print $2,$3,$4}'");
|
||||
const [total, used, available] = stdout.trim().split(/\s+/).map(Number);
|
||||
|
||||
if (!total || !available) {
|
||||
throw new Error('Failed to parse disk space');
|
||||
}
|
||||
|
||||
const usedPercent = Math.round((used / total) * 100);
|
||||
|
||||
const responseHeaders = { 'Content-Type': 'application/json' };
|
||||
if (setCookieHeader) {
|
||||
responseHeaders['Set-Cookie'] = setCookieHeader;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
total,
|
||||
used,
|
||||
available,
|
||||
usedPercent,
|
||||
// Human-readable versions
|
||||
totalFormatted: formatBytes(total),
|
||||
usedFormatted: formatBytes(used),
|
||||
availableFormatted: formatBytes(available),
|
||||
}), {
|
||||
status: 200,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error getting disk space:', err);
|
||||
return new Response(JSON.stringify({ error: 'Failed to get disk space' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
Reference in New Issue
Block a user