+
+
{isSubmitting ? (
diff --git a/src/hooks/requireAuthHook.js b/src/hooks/requireAuthHook.js
index 473e11a..60e667f 100644
--- a/src/hooks/requireAuthHook.js
+++ b/src/hooks/requireAuthHook.js
@@ -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;
}
diff --git a/src/middleware.js b/src/middleware.js
index f71f850..3a01ed5 100644
--- a/src/middleware.js
+++ b/src/middleware.js
@@ -27,61 +27,147 @@ 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`, {
- headers: { Cookie: cookieHeader },
- credentials: "include",
- });
-
- if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Initial auth/id status: ${res.status}`);
-
- if (res.status === 401) {
- // Try refresh
- const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
- method: "POST",
+ // 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,
});
+ } 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 (!refreshRes.ok) {
+ if (res.status === 401) {
+ // Check if we even have a refresh token before attempting refresh
+ if (!cookieHeader.includes('refresh_token=')) {
return { authenticated: false, user: null, cookies: null };
}
- // Get refreshed cookies
- let setCookies = [];
- if (typeof refreshRes.headers.getSetCookie === 'function') {
- setCookies = refreshRes.headers.getSetCookie();
- } else {
- const setCookieHeader = refreshRes.headers.get("set-cookie");
- if (setCookieHeader) {
- setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/);
+ // 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,
+ });
+ } 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 (setCookies.length === 0) {
- return { authenticated: false, user: null, cookies: null };
- }
+ 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 };
+ }
- // Build new cookie header for retry
- const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
+ console.log(`[middleware] Token refresh succeeded`);
- res = await fetch(`${API_URL}/auth/id`, {
- headers: { Cookie: newCookieHeader },
- credentials: "include",
+ // Get refreshed cookies
+ let setCookies = [];
+ if (typeof refreshRes.headers.getSetCookie === 'function') {
+ setCookies = refreshRes.headers.getSetCookie();
+ } else {
+ const setCookieHeader = refreshRes.headers.get("set-cookie");
+ if (setCookieHeader) {
+ setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/);
+ }
+ }
+
+ 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("; ");
+
+ // 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 (!retryRes.ok) {
+ console.error(`[middleware] auth/id retry failed with status ${retryRes.status}`);
+ return { authenticated: false, user: null, cookies: null };
+ }
+
+ 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;
});
- if (!res.ok) {
- return { authenticated: false, user: null, cookies: null };
- }
-
- const user = await res.json();
- return { authenticated: true, user, cookies: setCookies };
+ return refreshPromise;
}
if (!res.ok) {
diff --git a/src/pages/api/discord/messages.js b/src/pages/api/discord/messages.js
index a32ed52..3507549 100644
--- a/src/pages/api/discord/messages.js
+++ b/src/pages/api/discord/messages.js
@@ -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,
});
}
diff --git a/src/pages/api/disk-space.js b/src/pages/api/disk-space.js
new file mode 100644
index 0000000..7be1305
--- /dev/null
+++ b/src/pages/api/disk-space.js
@@ -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];
+}