Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dae5e7cc0 | ||
|
|
908f5679fd | ||
|
|
f75292f531 | ||
|
|
2cf0528730 | ||
|
|
428b57732e | ||
|
|
61e77e3e28 |
@@ -2,20 +2,14 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
- "caddy/**"
|
||||
- "docker-compose.yml"
|
||||
- ".gitea/workflows/ci.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
- "caddy/**"
|
||||
- "docker-compose.yml"
|
||||
- ".gitea/workflows/ci.yaml"
|
||||
|
||||
concurrency:
|
||||
@@ -23,10 +17,13 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── backend: vet & test ───────────────────────────────────────────────────────
|
||||
test-backend:
|
||||
name: Test backend
|
||||
# ── Go: vet + build + test ────────────────────────────────────────────────
|
||||
backend:
|
||||
name: Backend
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -36,16 +33,23 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: backend
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build backend
|
||||
run: go build -o /dev/null ./cmd/backend
|
||||
|
||||
- name: Build runner
|
||||
run: go build -o /dev/null ./cmd/runner
|
||||
|
||||
- name: Build healthcheck
|
||||
run: go build -o /dev/null ./cmd/healthcheck
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── ui: type-check & build ────────────────────────────────────────────────────
|
||||
check-ui:
|
||||
name: Check ui
|
||||
# ── UI: type-check + build ────────────────────────────────────────────────
|
||||
ui:
|
||||
name: UI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -67,57 +71,3 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── docker: validate Dockerfiles build (no push) ──────────────────────────────
|
||||
docker-backend:
|
||||
name: Docker / backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: backend
|
||||
push: false
|
||||
|
||||
docker-runner:
|
||||
name: Docker / runner
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: runner
|
||||
push: false
|
||||
|
||||
docker-ui:
|
||||
name: Docker / ui
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-ui]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: false
|
||||
|
||||
docker-caddy:
|
||||
name: Docker / caddy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: caddy
|
||||
push: false
|
||||
|
||||
@@ -218,6 +218,7 @@ jobs:
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
cache-to: type=inline
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ services:
|
||||
# No public port — all traffic is routed via Caddy.
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
environment:
|
||||
<<: *infra-env
|
||||
BACKEND_HTTP_ADDR: ":8080"
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
@@ -224,6 +224,7 @@ services:
|
||||
# Kokoro-FastAPI TTS endpoint
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "runner"
|
||||
|
||||
@@ -222,6 +222,10 @@ services:
|
||||
EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
|
||||
EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
|
||||
EMAIL_SMTP_ENABLE_STARTTLS: "false"
|
||||
OAUTH_GOOGLE_CLIENTID: "${OAUTH_GOOGLE_CLIENTID}"
|
||||
OAUTH_GOOGLE_SECRET: "${OAUTH_GOOGLE_SECRET}"
|
||||
OAUTH_GITHUB_CLIENTID: "${OAUTH_GITHUB_CLIENTID}"
|
||||
OAUTH_GITHUB_SECRET: "${OAUTH_GITHUB_SECRET}"
|
||||
|
||||
# ── Dozzle ──────────────────────────────────────────────────────────────────
|
||||
# Watches both homelab and prod containers.
|
||||
|
||||
@@ -14,10 +14,12 @@ COPY . .
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_COMMIT=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
|
||||
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
|
||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -40,5 +42,16 @@ ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Carry build-time metadata into the runtime image so the UI footer can
|
||||
# display the version, commit SHA, and build timestamp.
|
||||
# These must be re-declared after the second FROM — ARG values do not
|
||||
# cross stage boundaries, but ENV values set here persist at runtime.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_COMMIT=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||
|
||||
EXPOSE $PORT
|
||||
CMD ["node", "build"]
|
||||
|
||||
@@ -40,8 +40,8 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
|
||||
* Returns null if no avatar exists.
|
||||
* Returns a presigned GET URL for a user's avatar from MinIO.
|
||||
* Returns null if no avatar object exists in MinIO for this user.
|
||||
*/
|
||||
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||
@@ -54,6 +54,42 @@ export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||
return data.url ? rewriteHost(data.url) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the best available avatar URL for a user.
|
||||
*
|
||||
* Priority:
|
||||
* 1. MinIO — if the user has uploaded a custom avatar it will be found here
|
||||
* (presigned, short-lived GET URL).
|
||||
* 2. OAuth provider URL — stored in avatar_url when the account was created
|
||||
* via Google / GitHub OAuth (e.g. https://lh3.googleusercontent.com/...).
|
||||
* Returned as-is; the browser fetches it directly.
|
||||
*
|
||||
* Pass the raw `avatar_url` field from the PocketBase record as `storedValue`
|
||||
* so this function can distinguish between a MinIO key and a remote URL without
|
||||
* an extra DB round-trip.
|
||||
*
|
||||
* Returns null when neither source yields an avatar.
|
||||
*/
|
||||
export async function resolveAvatarUrl(
|
||||
userId: string,
|
||||
storedValue: string | null | undefined
|
||||
): Promise<string | null> {
|
||||
// 1. Try MinIO first (custom upload takes priority over OAuth picture).
|
||||
try {
|
||||
const minioUrl = await presignAvatarUrl(userId);
|
||||
if (minioUrl) return minioUrl;
|
||||
} catch {
|
||||
// MinIO unavailable — fall through to OAuth fallback.
|
||||
}
|
||||
|
||||
// 2. Fall back to OAuth-provided picture URL if it looks like a remote URL.
|
||||
if (storedValue && storedValue.startsWith('http')) {
|
||||
return storedValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||
*
|
||||
|
||||
@@ -541,6 +541,19 @@ export async function getUserByUsername(username: string): Promise<User | null>
|
||||
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by their PocketBase record ID. Returns null if not found.
|
||||
*/
|
||||
export async function getUserById(id: string): Promise<User | null> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<User>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by email. Returns null if not found.
|
||||
*/
|
||||
|
||||
@@ -433,7 +433,15 @@
|
||||
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
|
||||
<span>© {new Date().getFullYear()} libnovel</span>
|
||||
</div>
|
||||
<!-- Build version / commit SHA -->
|
||||
<!-- Build version / commit SHA / build time -->
|
||||
{#snippet buildTime()}
|
||||
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
|
||||
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
|
||||
<span class="text-zinc-500" title="Build time">
|
||||
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="text-xs tabular-nums font-mono px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700">
|
||||
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
|
||||
<span class="text-zinc-300" title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
|
||||
@@ -442,6 +450,7 @@
|
||||
>+{env.PUBLIC_BUILD_COMMIT.slice(0, 7)}</span
|
||||
>
|
||||
{/if}
|
||||
{@render buildTime()}
|
||||
{:else}
|
||||
<span class="text-zinc-400">dev</span>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
@@ -13,10 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
}
|
||||
// Fetch full record from PocketBase to get avatar_url
|
||||
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
|
||||
return json({
|
||||
id: locals.user.id,
|
||||
username: locals.user.username,
|
||||
role: locals.user.role,
|
||||
avatar_url: record?.avatar_url ?? null
|
||||
avatar_url: avatarUrl
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
listReplies,
|
||||
createComment,
|
||||
getMyVotes,
|
||||
getUserById,
|
||||
type CommentSort
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -38,13 +39,15 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
replies: repliesPerComment[i]
|
||||
}));
|
||||
|
||||
// Batch-resolve avatar presign URLs for all unique user_ids
|
||||
// Batch-resolve avatar URLs for all unique user_ids
|
||||
// MinIO first (custom upload), fall back to OAuth provider picture.
|
||||
const allComments = [...topLevel, ...allReplies];
|
||||
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
|
||||
const avatarEntries = await Promise.all(
|
||||
uniqueUserIds.map(async (userId) => {
|
||||
try {
|
||||
const url = await presignAvatarUrl(userId);
|
||||
const user = await getUserById(userId);
|
||||
const url = await resolveAvatarUrl(userId, user?.avatar_url);
|
||||
return [userId, url] as [string, string | null];
|
||||
} catch {
|
||||
return [userId, null] as [string, null];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { presignAvatarUrl, resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
@@ -63,10 +63,6 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||
if (!record?.avatar_url) {
|
||||
return json({ avatar_url: null });
|
||||
}
|
||||
|
||||
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
|
||||
return json({ avatar_url: avatarUrl });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -15,11 +15,9 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const profile = await getPublicProfile(username);
|
||||
if (!profile) error(404, `User "${username}" not found`);
|
||||
|
||||
// Resolve avatar presigned URL if set
|
||||
// Resolve avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
if (profile.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
||||
}
|
||||
avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null);
|
||||
|
||||
// Is the current logged-in user subscribed?
|
||||
let isSubscribed = false;
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
filterStatus = data.status;
|
||||
});
|
||||
|
||||
function navigateWithFilters(overrides: { sort?: string; genre?: string; status?: string }) {
|
||||
function applyFilters() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('sort', overrides.sort ?? filterSort);
|
||||
params.set('genre', overrides.genre ?? filterGenre);
|
||||
params.set('status', overrides.status ?? filterStatus);
|
||||
params.set('sort', filterSort);
|
||||
params.set('genre', filterGenre);
|
||||
params.set('status', filterStatus);
|
||||
params.set('page', '1');
|
||||
goto(`/catalogue?${params.toString()}`);
|
||||
}
|
||||
@@ -414,7 +414,6 @@
|
||||
id="filter-sort"
|
||||
name="sort"
|
||||
bind:value={filterSort}
|
||||
onchange={() => navigateWithFilters({ sort: filterSort })}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 w-full"
|
||||
>
|
||||
{#each sorts as s}
|
||||
@@ -429,7 +428,6 @@
|
||||
id="filter-genre"
|
||||
name="genre"
|
||||
bind:value={filterGenre}
|
||||
onchange={() => navigateWithFilters({ genre: filterGenre })}
|
||||
disabled={isRankView}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
>
|
||||
@@ -445,7 +443,6 @@
|
||||
id="filter-status"
|
||||
name="status"
|
||||
bind:value={filterStatus}
|
||||
onchange={() => navigateWithFilters({ status: filterStatus })}
|
||||
disabled={isRankView}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
>
|
||||
@@ -464,6 +461,13 @@
|
||||
<a href="/catalogue" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
|
||||
Reset
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={applyFilters}
|
||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
@@ -16,13 +16,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
// Fetch avatar presigned URL if user has one
|
||||
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
if (record?.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||
}
|
||||
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getUserPublicLibrary,
|
||||
getUserCurrentlyReading
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
@@ -15,11 +15,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const profile = await getPublicProfile(username).catch(() => null);
|
||||
if (!profile) error(404, `User "${username}" not found`);
|
||||
|
||||
// Resolve avatar
|
||||
// Resolve avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
if (profile.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
||||
}
|
||||
avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null);
|
||||
|
||||
// Subscription state for the logged-in visitor
|
||||
let isSubscribed = false;
|
||||
|
||||
Reference in New Issue
Block a user