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();
// 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, "
"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 "
"WHERE r.is_live = true AND r.is_active = true "
"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["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["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
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';
// 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) {
const response = await fetch(`${API_URL}${endpoint}`, {
...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
// Uses centralized refresh from auth.js (includes retry logic)
if (response.status === 401 && retryAfterRefresh) {
const refreshed = await attemptTokenRefresh();
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry the original request (but don't retry again if it fails)
return fetchAPI(endpoint, options, false);

View file

@ -451,8 +451,9 @@ class ChatWebSocket {
/**
* 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)
* @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');
if (this.reconnectResetTimer) {
clearTimeout(this.reconnectResetTimer);
@ -460,9 +461,25 @@ class ChatWebSocket {
}
this.reconnectAttempts = 0;
// Get fresh token from localStorage (may have been set by login)
const freshToken =
typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
// Get fresh token - use provided token, or try to refresh via cookie-based auth
let freshToken = token;
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
// This handles the case where user logs in while already connected as guest

View file

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

View file

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

View file

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

View file

@ -15,10 +15,10 @@
$: liveRealms = 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 = (() => {
const path = $page.url.pathname;
const match = path.match(/^\/([^/]+)\/live$/);
const match = path.match(/^\/([^/]+)\/(live|watch)$/);
return match ? match[1] : null;
})();
@ -138,10 +138,11 @@
</a>
<button
class="tile-btn"
class:tiled={isTiled && !isViewing}
class:remove={isTiled && !isViewing}
class:viewing={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}
>{isViewing ? '*' : isTiled ? '' : '+'}</button>
</div>
@ -175,10 +176,11 @@
</a>
<button
class="tile-btn"
class:tiled={isTiled && !isViewing}
class:remove={isTiled && !isViewing}
class:viewing={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}
>{isViewing ? '*' : isTiled ? '' : '+'}</button>
</div>

View file

@ -1,8 +1,13 @@
<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 duration = 0;
export let realmName = '';
export let username = '';
export let titleColor = '#ffffff';
export let colorCode = '#561D5E';
export let avatarUrl = '';
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
@ -54,9 +59,7 @@
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
background: #000;
}
.progress-bar-container {
@ -137,11 +140,75 @@
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;
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;
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>
@ -213,9 +280,23 @@
</div>
</div>
{#if !$canControl}
<div class="no-control-hint">
Only designated users can control playback
<div class="room-info-row">
<div class="owner-info">
<div class="owner-avatar" style="background: {colorCode}; border-color: {colorCode}">
{#if avatarUrl}
<img src={avatarUrl} alt={username} />
{:else}
{username?.charAt(0).toUpperCase() || '?'}
{/if}
</div>
<a href="/profile/{username}" class="owner-name" style="color: {colorCode}">{username}</a>
</div>
{/if}
<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>

View file

@ -4,14 +4,17 @@ import { goto } from '$app/navigation';
// Token refresh interval (2 hours - before the 2.5-hour access token expires)
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 isRefreshing = false;
let refreshPromise = null;
let consecutiveFailures = 0;
// Silent token refresh function
async function refreshAccessToken() {
if (isRefreshing) return false;
isRefreshing = true;
// Silent token refresh function with retry logic
// Returns: { success: boolean, isAuthError: boolean }
async function attemptSingleRefresh() {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
@ -24,21 +27,65 @@ async function refreshAccessToken() {
if (data.user) {
auth.updateUser(data.user);
}
isRefreshing = false;
return true;
return { success: true, isAuthError: false };
} else {
// Refresh failed - session expired
console.log('Token refresh failed - session expired');
isRefreshing = false;
return false;
// 401/403 = auth error (token truly invalid/expired)
// 5xx = server error (should retry)
const isAuthError = response.status === 401 || response.status === 403;
console.log(`Token refresh failed with status ${response.status}`);
return { success: false, isAuthError };
}
} catch (error) {
console.error('Token refresh error:', error);
isRefreshing = false;
return false;
// Network error - should retry
console.error('Token refresh network error:', error);
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
function startTokenRefresh() {
if (!browser) return;
@ -47,14 +94,22 @@ function startTokenRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
}
consecutiveFailures = 0;
// Refresh every 12 minutes
// Refresh every 2 hours (before the 2.5-hour access token expires)
refreshInterval = setInterval(async () => {
const success = await refreshAccessToken();
if (!success) {
// Stop refresh interval and logout
stopTokenRefresh();
auth.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();
auth.logout();
} else {
console.log(`Refresh failed (${consecutiveFailures}/2 consecutive failures) - will retry next interval`);
}
}
}, REFRESH_INTERVAL_MS);
}

View file

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

View file

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

View file

@ -191,7 +191,18 @@
// Prevent duplicate skip calls
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) {
skipInProgress = true;
watchSync.skip();
@ -488,41 +499,20 @@
<PlaybackControls
{currentTime}
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>
{#if realm.description}
<div class="room-info-section">
<div class="room-description">
{realm.description}
</div>
{#if realm.description}
<div class="room-description">
{realm.description}
</div>
{/if}
</div>
</div>
{/if}
</div>
<div class="sidebar">