fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 8m4s

This commit is contained in:
doomtube 2026-01-09 01:56:05 -05:00
parent 2cf84704da
commit a206a606f7
12 changed files with 231 additions and 111 deletions

View file

@ -824,7 +824,7 @@ void RealmController::getLiveRealms(const HttpRequestPtr &,
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
// SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking // SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking
*dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, r.offline_image_url, r.title_color, " *dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, r.offline_image_url, r.title_color, "
"u.username, u.avatar_url " "u.username, u.avatar_url, u.user_color "
"FROM realms r JOIN users u ON r.user_id = u.id " "FROM realms r JOIN users u ON r.user_id = u.id "
"WHERE r.is_live = true AND r.is_active = true " "WHERE r.is_live = true AND r.is_active = true "
"ORDER BY (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC" "ORDER BY (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC"
@ -850,6 +850,7 @@ void RealmController::getLiveRealms(const HttpRequestPtr &,
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>(); realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as<std::string>(); realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as<std::string>();
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>(); realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
realm["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
resp.append(realm); resp.append(realm);
} }

View file

@ -1,31 +1,7 @@
import { refreshAccessToken } from './stores/auth.js';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api';
// Track if we're currently refreshing to avoid multiple simultaneous refreshes
let isRefreshing = false;
let refreshPromise = null;
// Attempt to refresh the access token
async function attemptTokenRefresh() {
if (isRefreshing) {
// Wait for the existing refresh to complete
return refreshPromise;
}
isRefreshing = true;
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
}).then(response => {
isRefreshing = false;
return response.ok;
}).catch(() => {
isRefreshing = false;
return false;
});
return refreshPromise;
}
async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) { async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) {
const response = await fetch(`${API_URL}${endpoint}`, { const response = await fetch(`${API_URL}${endpoint}`, {
...options, ...options,
@ -37,8 +13,9 @@ async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) {
}); });
// If we get a 401 and haven't already retried, attempt token refresh // If we get a 401 and haven't already retried, attempt token refresh
// Uses centralized refresh from auth.js (includes retry logic)
if (response.status === 401 && retryAfterRefresh) { if (response.status === 401 && retryAfterRefresh) {
const refreshed = await attemptTokenRefresh(); const refreshed = await refreshAccessToken();
if (refreshed) { if (refreshed) {
// Retry the original request (but don't retry again if it fails) // Retry the original request (but don't retry again if it fails)
return fetchAPI(endpoint, options, false); return fetchAPI(endpoint, options, false);

View file

@ -451,8 +451,9 @@ class ChatWebSocket {
/** /**
* Manually trigger reconnection - resets attempt counter and tries immediately * Manually trigger reconnection - resets attempt counter and tries immediately
* Also handles authentication on existing connection (e.g., when user logs in while connected as guest) * Also handles authentication on existing connection (e.g., when user logs in while connected as guest)
* @param {string} [token] - Optional token to use for authentication. If not provided, will attempt to refresh.
*/ */
async manualReconnect() { async manualReconnect(token = null) {
console.log('Manual reconnect triggered'); console.log('Manual reconnect triggered');
if (this.reconnectResetTimer) { if (this.reconnectResetTimer) {
clearTimeout(this.reconnectResetTimer); clearTimeout(this.reconnectResetTimer);
@ -460,9 +461,25 @@ class ChatWebSocket {
} }
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
// Get fresh token from localStorage (may have been set by login) // Get fresh token - use provided token, or try to refresh via cookie-based auth
const freshToken = let freshToken = token;
typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; if (!freshToken) {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.token) {
freshToken = data.token;
console.log('[ChatWebSocket] Got fresh token from refresh endpoint');
}
}
} catch (e) {
console.warn('[ChatWebSocket] Could not refresh token:', e);
}
}
// If we have a token and connection is open, just send auth message // If we have a token and connection is open, just send auth message
// This handles the case where user logs in while already connected as guest // This handles the case where user logs in while already connected as guest

View file

@ -408,7 +408,6 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
padding: 0; padding: 0;
border-top: 1px solid var(--border);
background: var(--bg-surface); background: var(--bg-surface);
flex-shrink: 0; /* Prevent input from shrinking */ flex-shrink: 0; /* Prevent input from shrinking */
} }
@ -419,13 +418,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--bg-input); background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
overflow: visible; overflow: visible;
} }
.input-wrapper:focus-within { .input-wrapper:focus-within {
border-color: var(--accent-blue); outline: 1px solid var(--accent-blue);
} }
.input-icons { .input-icons {

View file

@ -217,10 +217,11 @@
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
} }
function formatEpoch(timestamp) { function formatEpoch() {
const epoch = new Date(timestamp).getTime(); // Use high-resolution time for microsecond precision (1000x more granular than milliseconds)
// Get last 8 digits of epoch for maximum randomness const highResTime = performance.timeOrigin + performance.now();
const last8 = String(epoch).slice(-8); const microEpoch = Math.floor(highResTime * 1000);
const last8 = String(microEpoch).slice(-8);
return `No.${last8}`; return `No.${last8}`;
} }
@ -409,7 +410,7 @@
</button> </button>
{/if} {/if}
<span class="timestamp">{formatTime(message.timestamp)}</span> <span class="timestamp">{formatTime(message.timestamp)}</span>
<span class="epoch">{formatEpoch(message.timestamp)}</span> <span class="epoch">{formatEpoch()}</span>
</div> </div>
</div> </div>
{/if} {/if}
@ -431,7 +432,7 @@
</button> </button>
{/if} {/if}
<span class="timestamp">{formatTime(message.timestamp)}</span> <span class="timestamp">{formatTime(message.timestamp)}</span>
<span class="epoch">{formatEpoch(message.timestamp)}</span> <span class="epoch">{formatEpoch()}</span>
</div> </div>
{/if} {/if}
</div> </div>
@ -649,7 +650,7 @@
.epoch { .epoch {
color: var(--text-faint); color: var(--text-faint);
font-size: 0.6rem; font-size: 0.65rem;
font-family: monospace; font-family: monospace;
} }

View file

@ -719,7 +719,6 @@
max-height: 100%; max-height: 100%;
min-height: 0; min-height: 0;
background: #1a1a1a; background: #1a1a1a;
border-left: 1px solid #333;
position: relative; position: relative;
overflow: hidden; /* Prevent panel itself from scrolling */ overflow: hidden; /* Prevent panel itself from scrolling */
} }

View file

@ -15,10 +15,10 @@
$: liveRealms = allRealms.filter(r => r.isLive); $: liveRealms = allRealms.filter(r => r.isLive);
$: offlineRealms = allRealms.filter(r => !r.isLive); $: offlineRealms = allRealms.filter(r => !r.isLive);
// Get the current realm from URL if on a live page (e.g., /realmname/live) // Get the current realm from URL if on a realm page (e.g., /realmname/live or /realmname/watch)
$: currentLiveRealm = (() => { $: currentLiveRealm = (() => {
const path = $page.url.pathname; const path = $page.url.pathname;
const match = path.match(/^\/([^/]+)\/live$/); const match = path.match(/^\/([^/]+)\/(live|watch)$/);
return match ? match[1] : null; return match ? match[1] : null;
})(); })();
@ -138,10 +138,11 @@
</a> </a>
<button <button
class="tile-btn" class="tile-btn"
class:tiled={isTiled && !isViewing}
class:remove={isTiled && !isViewing} class:remove={isTiled && !isViewing}
class:viewing={isViewing} class:viewing={isViewing}
on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)} on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)}
title={isViewing ? 'Currently viewing' : isTiled ? 'Remove from tiles' : 'Add to tiles'} title={isViewing ? 'Currently viewing this realm' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
disabled={isViewing} disabled={isViewing}
>{isViewing ? '*' : isTiled ? '' : '+'}</button> >{isViewing ? '*' : isTiled ? '' : '+'}</button>
</div> </div>
@ -175,10 +176,11 @@
</a> </a>
<button <button
class="tile-btn" class="tile-btn"
class:tiled={isTiled && !isViewing}
class:remove={isTiled && !isViewing} class:remove={isTiled && !isViewing}
class:viewing={isViewing} class:viewing={isViewing}
on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)} on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)}
title={isViewing ? 'Currently viewing' : isTiled ? 'Remove from tiles' : 'Add to tiles'} title={isViewing ? 'Currently viewing this realm' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
disabled={isViewing} disabled={isViewing}
>{isViewing ? '*' : isTiled ? '' : '+'}</button> >{isViewing ? '*' : isTiled ? '' : '+'}</button>
</div> </div>

View file

@ -1,8 +1,13 @@
<script> <script>
import { watchSync, isPlaying, canControl, currentVideo } from '$lib/stores/watchSync'; import { watchSync, isPlaying, canControl, currentVideo, viewerCount } from '$lib/stores/watchSync';
export let currentTime = 0; export let currentTime = 0;
export let duration = 0; export let duration = 0;
export let realmName = '';
export let username = '';
export let titleColor = '#ffffff';
export let colorCode = '#561D5E';
export let avatarUrl = '';
function formatTime(seconds) { function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@ -54,9 +59,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: #111; background: #000;
border: 1px solid var(--border);
border-radius: 8px;
} }
.progress-bar-container { .progress-bar-container {
@ -137,11 +140,75 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.no-control-hint { .room-info-row {
display: flex;
align-items: center;
gap: 1rem;
padding-top: 0.5rem;
}
.room-info-row > * {
flex: 1;
}
.owner-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.owner-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
overflow: hidden;
border: 2px solid;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--gray); color: var(--white);
flex-shrink: 0;
}
.owner-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.owner-name {
font-weight: 500;
font-size: 0.9rem;
text-decoration: none;
transition: opacity 0.2s;
}
.owner-name:hover {
opacity: 0.8;
}
.room-title {
margin: 0;
font-size: 1rem;
color: var(--white);
text-align: center; text-align: center;
margin-top: 0.25rem; font-weight: 600;
}
.viewer-badge {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--primary);
}
.viewer-badge svg {
width: 16px;
height: 16px;
} }
</style> </style>
@ -213,9 +280,23 @@
</div> </div>
</div> </div>
{#if !$canControl} <div class="room-info-row">
<div class="no-control-hint"> <div class="owner-info">
Only designated users can control playback <div class="owner-avatar" style="background: {colorCode}; border-color: {colorCode}">
</div> {#if avatarUrl}
<img src={avatarUrl} alt={username} />
{:else}
{username?.charAt(0).toUpperCase() || '?'}
{/if} {/if}
</div>
<a href="/profile/{username}" class="owner-name" style="color: {colorCode}">{username}</a>
</div>
<h2 class="room-title" style="color: {titleColor}">{realmName}</h2>
<div class="viewer-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{$viewerCount} watching
</div>
</div>
</div> </div>

View file

@ -4,14 +4,17 @@ import { goto } from '$app/navigation';
// Token refresh interval (2 hours - before the 2.5-hour access token expires) // Token refresh interval (2 hours - before the 2.5-hour access token expires)
const REFRESH_INTERVAL_MS = 2 * 60 * 60 * 1000; const REFRESH_INTERVAL_MS = 2 * 60 * 60 * 1000;
// Retry configuration for token refresh
const MAX_REFRESH_RETRIES = 3;
const INITIAL_RETRY_DELAY_MS = 1000; // 1 second
let refreshInterval = null; let refreshInterval = null;
let isRefreshing = false; let isRefreshing = false;
let refreshPromise = null;
let consecutiveFailures = 0;
// Silent token refresh function // Silent token refresh function with retry logic
async function refreshAccessToken() { // Returns: { success: boolean, isAuthError: boolean }
if (isRefreshing) return false; async function attemptSingleRefresh() {
isRefreshing = true;
try { try {
const response = await fetch('/api/auth/refresh', { const response = await fetch('/api/auth/refresh', {
method: 'POST', method: 'POST',
@ -24,21 +27,65 @@ async function refreshAccessToken() {
if (data.user) { if (data.user) {
auth.updateUser(data.user); auth.updateUser(data.user);
} }
isRefreshing = false; return { success: true, isAuthError: false };
return true;
} else { } else {
// Refresh failed - session expired // 401/403 = auth error (token truly invalid/expired)
console.log('Token refresh failed - session expired'); // 5xx = server error (should retry)
isRefreshing = false; const isAuthError = response.status === 401 || response.status === 403;
return false; console.log(`Token refresh failed with status ${response.status}`);
return { success: false, isAuthError };
} }
} catch (error) { } catch (error) {
console.error('Token refresh error:', error); // Network error - should retry
isRefreshing = false; console.error('Token refresh network error:', error);
return false; return { success: false, isAuthError: false };
} }
} }
// Refresh with retry logic - exported for use by other modules (api.js)
export async function refreshAccessToken() {
// If already refreshing, wait for the existing refresh to complete
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
let lastResult = { success: false, isAuthError: false };
for (let attempt = 1; attempt <= MAX_REFRESH_RETRIES; attempt++) {
lastResult = await attemptSingleRefresh();
if (lastResult.success) {
consecutiveFailures = 0;
isRefreshing = false;
refreshPromise = null;
return true;
}
// If it's an auth error (401/403), don't retry - token is invalid
if (lastResult.isAuthError) {
console.log('Auth error - token is invalid, not retrying');
break;
}
// Network/server error - retry with exponential backoff
if (attempt < MAX_REFRESH_RETRIES) {
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
console.log(`Retrying token refresh in ${delay}ms (attempt ${attempt}/${MAX_REFRESH_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// All retries exhausted or auth error
isRefreshing = false;
refreshPromise = null;
return false;
})();
return refreshPromise;
}
// Start automatic token refresh // Start automatic token refresh
function startTokenRefresh() { function startTokenRefresh() {
if (!browser) return; if (!browser) return;
@ -47,14 +94,22 @@ function startTokenRefresh() {
if (refreshInterval) { if (refreshInterval) {
clearInterval(refreshInterval); clearInterval(refreshInterval);
} }
consecutiveFailures = 0;
// Refresh every 12 minutes // Refresh every 2 hours (before the 2.5-hour access token expires)
refreshInterval = setInterval(async () => { refreshInterval = setInterval(async () => {
const success = await refreshAccessToken(); const success = await refreshAccessToken();
if (!success) { if (!success) {
// Stop refresh interval and logout consecutiveFailures++;
// Only logout after 2 consecutive interval failures
// This gives more resilience against temporary network issues
if (consecutiveFailures >= 2) {
console.log('Multiple consecutive refresh failures - logging out');
stopTokenRefresh(); stopTokenRefresh();
auth.logout(); auth.logout();
} else {
console.log(`Refresh failed (${consecutiveFailures}/2 consecutive failures) - will retry next interval`);
}
} }
}, REFRESH_INTERVAL_MS); }, REFRESH_INTERVAL_MS);
} }

View file

@ -96,8 +96,7 @@
} }
.nav { .nav {
background: #111; background: #000;
border-bottom: 1px solid var(--border);
padding: var(--nav-padding-y) 0; padding: var(--nav-padding-y) 0;
margin-bottom: var(--nav-margin-bottom); margin-bottom: var(--nav-margin-bottom);
} }

View file

@ -1024,7 +1024,7 @@
{:else} {:else}
<div class="streamer-avatar"></div> <div class="streamer-avatar"></div>
{/if} {/if}
<span>{stream.username}</span> <span style="color: {stream.colorCode}">{stream.username}</span>
</div> </div>
<div class="viewer-count"> <div class="viewer-count">
{stream.viewerCount} {stream.viewerCount === 1 ? 'viewer' : 'viewers'} {stream.viewerCount} {stream.viewerCount === 1 ? 'viewer' : 'viewers'}

View file

@ -191,7 +191,18 @@
// Prevent duplicate skip calls // Prevent duplicate skip calls
if (skipInProgress) return; if (skipInProgress) return;
// When a video ends, skip to the next one // Check if current video is locked (looped) - if so, let the server handle the restart
// The server will send a 'locked_restart' event to loop the video
const currentVid = $currentVideo;
if (currentVid?.isLocked) {
// Locked video - request sync to get the restart state from server
setTimeout(() => {
watchSync.requestSync();
}, 500);
return;
}
// When a video ends, skip to the next one (only for non-locked videos)
if ($canControl) { if ($canControl) {
skipInProgress = true; skipInProgress = true;
watchSync.skip(); watchSync.skip();
@ -488,42 +499,21 @@
<PlaybackControls <PlaybackControls
{currentTime} {currentTime}
duration={$currentVideo?.durationSeconds || duration} duration={$currentVideo?.durationSeconds || duration}
realmName={realm.name}
username={realm.username}
titleColor={realm.titleColor}
colorCode={realm.colorCode}
avatarUrl={realm.avatarUrl}
/> />
<div class="room-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="room-header">
<div class="header-top">
<div class="owner-info">
<div
class="owner-avatar"
class:has-color={realm.colorCode}
style="--user-color: {realm.colorCode || '#561D5E'}"
>
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} />
{:else}
{realm.username?.charAt(0).toUpperCase() || '?'}
{/if}
</div>
<a href="/profile/{realm.username}" class="owner-name">{realm.username}</a>
</div>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<div class="viewer-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{$viewerCount} watching
</div>
</div>
{#if realm.description} {#if realm.description}
<div class="room-info-section">
<div class="room-description"> <div class="room-description">
{realm.description} {realm.description}
</div> </div>
</div>
{/if} {/if}
</div> </div>
</div>
</div>
<div class="sidebar"> <div class="sidebar">
<div class="playlist-section"> <div class="playlist-section">