feat(v3): add v3 stack — backend rewrite, renamed env vars, docs
- New Go backend binary (backend + runner) replacing old scraper/ - Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose - Rename scraperFetch → backendFetch across all 19 UI server files - Remove SCRAPER_PROXY env var and proxy transport from browser.Config - Add Meilisearch, Valkey, Caddy to docker-compose - Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
This commit is contained in:
142
v3/ui/src/routes/login/+page.server.ts
Normal file
142
v3/ui/src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../hooks.server';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Already logged in — send to home
|
||||
if (locals.user) {
|
||||
redirect(302, '/');
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
login: async ({ request, cookies, locals }) => {
|
||||
const data = await request.formData();
|
||||
const username = (data.get('username') as string | null)?.trim() ?? '';
|
||||
const password = (data.get('password') as string | null) ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { action: 'login', error: 'Username and password are required.' });
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await loginUser(username, password);
|
||||
} catch (err) {
|
||||
log.error('auth', 'login unexpected error', { username, err: String(err) });
|
||||
return fail(500, { action: 'login', error: 'An error occurred. Please try again.' });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return fail(401, { action: 'login', error: 'Invalid username or password.' });
|
||||
}
|
||||
|
||||
// Merge any anonymous session progress into the user's account so that
|
||||
// chapters read before logging in are preserved and portable across devices.
|
||||
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
|
||||
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
|
||||
// Create a unique auth session ID for this login
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
// Record the session in PocketBase (best-effort, non-fatal)
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||
log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
redirect(302, '/');
|
||||
},
|
||||
|
||||
register: async ({ request, cookies, locals }) => {
|
||||
const data = await request.formData();
|
||||
const username = (data.get('username') as string | null)?.trim() ?? '';
|
||||
const password = (data.get('password') as string | null) ?? '';
|
||||
const confirm = (data.get('confirm') as string | null) ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { action: 'register', error: 'Username and password are required.' });
|
||||
}
|
||||
if (username.length < 3 || username.length > 32) {
|
||||
return fail(400, {
|
||||
action: 'register',
|
||||
error: 'Username must be between 3 and 32 characters.'
|
||||
});
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return fail(400, {
|
||||
action: 'register',
|
||||
error: 'Username may only contain letters, numbers, underscores and hyphens.'
|
||||
});
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return fail(400, {
|
||||
action: 'register',
|
||||
error: 'Password must be at least 8 characters.'
|
||||
});
|
||||
}
|
||||
if (password !== confirm) {
|
||||
return fail(400, { action: 'register', error: 'Passwords do not match.' });
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await createUser(username, password);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Registration failed.';
|
||||
if (msg.includes('Username already taken')) {
|
||||
return fail(409, { action: 'register', error: 'That username is already taken.' });
|
||||
}
|
||||
log.error('auth', 'register unexpected error', { username, err: String(err) });
|
||||
return fail(500, { action: 'register', error: 'An error occurred. Please try again.' });
|
||||
}
|
||||
|
||||
// Merge any anonymous session progress into the newly created account.
|
||||
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
|
||||
log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
|
||||
// Create a unique auth session ID for this registration
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
// Record the session in PocketBase (best-effort, non-fatal)
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||
log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user