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:
2025-12-19 10:26:22 -05:00
parent 2cc07b6cc2
commit 95a59e9395
7 changed files with 509 additions and 55 deletions

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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,
});
}

View 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];
}