fix: cover proxy routing, session filtering, library tab deep-link, profile UX
Some checks failed
Release / Test backend (push) Successful in 1m3s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 5m55s
Release / Deploy to prod (push) Failing after 48s
Release / Deploy to homelab (push) Successful in 21s
Release / Gitea Release (push) Successful in 29s
Some checks failed
Release / Test backend (push) Successful in 1m3s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 5m55s
Release / Deploy to prod (push) Failing after 48s
Release / Deploy to homelab (push) Successful in 21s
Release / Gitea Release (push) Successful in 29s
- Catalogue/cover: rewrite raw scraped cover URLs to /api/cover/{domain}/{slug}
in handleCatalogue so all covers route through the backend proxy; fix broken
cdn.novelfire.net fallback in handleGetCover to read stored URL from PocketBase
- Catalogue/profile: add Svelte 5 onerror handlers on cover <img> tags to show
letter-initial placeholder when image fails to load
- Library page: read ?status URL param to initialise activeShelf tab on load so
/books?status=reading correctly pre-selects the Reading tab
- Sessions: filter bot/tool user-agents (curl, python, wget, etc.) and debug-IP
sessions from listUserSessions display; also purge them in pruneStaleUserSessions
- Profile: show email under username, quick stats chips (streak/chapters/completed)
in header, reading count on Library row, dedicated Sign out row, history covers
routed through /api/cover proxy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1431,10 +1431,34 @@ export async function isSessionRevoked(authSessionId: string): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active sessions for a user.
|
||||
* Returns true for user-agents that are clearly automated tools (curl, scrapers,
|
||||
* debug logins, etc.) that should not appear in the user-facing sessions list.
|
||||
* These sessions still exist in the DB so auth checks continue to work.
|
||||
*/
|
||||
function isBotUserAgent(ua: string): boolean {
|
||||
if (!ua) return false;
|
||||
const lower = ua.toLowerCase();
|
||||
return (
|
||||
lower.startsWith('curl/') ||
|
||||
lower.startsWith('python') ||
|
||||
lower.startsWith('wget/') ||
|
||||
lower.startsWith('go-http-client') ||
|
||||
lower.startsWith('axios/') ||
|
||||
lower.startsWith('node-fetch') ||
|
||||
lower.startsWith('undici') ||
|
||||
lower.startsWith('okhttp') ||
|
||||
lower.startsWith('java/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active sessions for a user, excluding non-browser/tool sessions
|
||||
* (curl, debug-login artifacts, scrapers, etc.) from the displayed list.
|
||||
* The records still exist in the DB so auth validity checks are unaffected.
|
||||
*/
|
||||
export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
return all.filter((s) => !isBotUserAgent(s.user_agent) && s.ip !== 'debug');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1453,9 +1477,11 @@ async function pruneStaleUserSessions(
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const toDelete = new Set<string>();
|
||||
|
||||
// Mark stale sessions
|
||||
// Mark stale sessions and debug/tool sessions for deletion
|
||||
for (const s of all) {
|
||||
if (s.last_seen < cutoff) toDelete.add(s.id);
|
||||
if (s.last_seen < cutoff || s.ip === 'debug' || isBotUserAgent(s.user_agent)) {
|
||||
toDelete.add(s.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -17,7 +19,22 @@
|
||||
}
|
||||
|
||||
type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
|
||||
let activeShelf = $state<Shelf | 'all'>('all');
|
||||
|
||||
// Map the ?status URL param to the internal shelf key so that links like
|
||||
// /books?status=reading correctly pre-select the Reading tab.
|
||||
function urlStatusToShelf(status: string | null): Shelf | 'all' {
|
||||
switch (status) {
|
||||
case 'reading': return '';
|
||||
case 'plan_to_read': return 'plan_to_read';
|
||||
case 'completed': return 'completed';
|
||||
case 'dropped': return 'dropped';
|
||||
default: return 'all';
|
||||
}
|
||||
}
|
||||
|
||||
let activeShelf = $state<Shelf | 'all'>(
|
||||
untrack(() => urlStatusToShelf(page.url.searchParams.get('status')))
|
||||
);
|
||||
|
||||
const shelfLabels: Record<string, string> = {
|
||||
all: 'All',
|
||||
|
||||
@@ -542,7 +542,12 @@
|
||||
alt={novel.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
onerror={(e) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
(img.nextElementSibling as HTMLElement | null)?.style.setProperty('display', 'flex');
|
||||
}}
|
||||
/><div class="w-full h-full absolute inset-0 items-center justify-center bg-(--color-surface-3)" style="display:none"><span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span></div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
|
||||
<span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
|
||||
@@ -630,7 +635,18 @@
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-(--color-surface) relative">
|
||||
{#if novel.cover}
|
||||
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
<img
|
||||
src={novel.cover}
|
||||
alt={novel.title}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={(e) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
const fb = img.parentElement?.querySelector('.cover-fallback') as HTMLElement | null;
|
||||
if (fb) fb.style.display = 'flex';
|
||||
}}
|
||||
/><div class="cover-fallback w-full h-full absolute inset-0 items-center justify-center bg-(--color-surface-3)" style="display:none"><span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span></div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
|
||||
<span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
|
||||
|
||||
@@ -427,7 +427,10 @@
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{#if data.email}
|
||||
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{data.email}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
|
||||
{#if data.isPro}
|
||||
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
|
||||
@@ -435,6 +438,29 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Quick stats chips -->
|
||||
{#if data.stats.totalChaptersRead > 0 || data.stats.streak > 0}
|
||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{#if data.stats.streak > 0}
|
||||
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
|
||||
<svg class="w-3 h-3 text-orange-400" fill="currentColor" viewBox="0 0 24 24"><path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67z"/></svg>
|
||||
{data.stats.streak}d streak
|
||||
</span>
|
||||
{/if}
|
||||
{#if data.stats.totalChaptersRead > 0}
|
||||
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
{data.stats.totalChaptersRead.toLocaleString()} chapters
|
||||
</span>
|
||||
{/if}
|
||||
{#if data.stats.booksCompleted > 0}
|
||||
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
|
||||
<svg class="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{data.stats.booksCompleted} completed
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if avatarError}
|
||||
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
||||
{/if}
|
||||
@@ -455,6 +481,9 @@
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text)">Library</span>
|
||||
{#if data.stats.booksReading > 0}
|
||||
<span class="text-xs text-(--color-muted) mr-2 hidden sm:inline">{data.stats.booksReading} reading</span>
|
||||
{/if}
|
||||
<svg class={chevronClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
@@ -550,12 +579,14 @@
|
||||
href="/books/{item.slug}/chapters/{item.chapter}"
|
||||
class="flex items-center gap-3 px-5 py-3 hover:bg-(--color-surface-3)/60 transition-colors group"
|
||||
>
|
||||
<div class="w-7 h-10 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
|
||||
{#if item.cover}
|
||||
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
<div class="w-7 h-10 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0 relative">
|
||||
<img
|
||||
src="/api/cover/novelfire.net/{item.slug}"
|
||||
alt={item.title}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
|
||||
@@ -1103,6 +1134,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sign out -->
|
||||
<form method="POST" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
|
||||
>
|
||||
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-danger) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-(--color-text) group-hover:text-(--color-danger) transition-colors">Sign out</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
|
||||
|
||||
Reference in New Issue
Block a user