feat(auth): add email verification to registration flow
Some checks failed
CI / Test backend (pull_request) Failing after 11s
CI / Check ui (pull_request) Failing after 11s
CI / Docker / backend (pull_request) Has been skipped
CI / Docker / runner (pull_request) Has been skipped
CI / Docker / ui (pull_request) Has been skipped
CI / Docker / caddy (pull_request) Successful in 2m53s
Release / Test backend (push) Successful in 19s
Release / Check ui (push) Successful in 33s
Release / Docker / caddy (push) Failing after 1m26s
Release / Docker / ui (push) Failing after 11s
Release / Docker / backend (push) Successful in 2m2s
Release / Docker / runner (push) Successful in 2m24s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / Test backend (pull_request) Failing after 11s
CI / Check ui (pull_request) Failing after 11s
CI / Docker / backend (pull_request) Has been skipped
CI / Docker / runner (pull_request) Has been skipped
CI / Docker / ui (pull_request) Has been skipped
CI / Docker / caddy (pull_request) Successful in 2m53s
Release / Test backend (push) Successful in 19s
Release / Check ui (push) Successful in 33s
Release / Docker / caddy (push) Failing after 1m26s
Release / Docker / ui (push) Failing after 11s
Release / Docker / backend (push) Successful in 2m2s
Release / Docker / runner (push) Successful in 2m24s
Release / Gitea Release (push) Has been skipped
- Add email/email_verified/verification_token/verification_token_exp fields
to app_users PocketBase schema (pb-init-v3.sh)
- Add SMTP env vars to UI service in docker-compose.yml
- New email.ts: raw TLS SMTP mailer via Node tls module, sendVerificationEmail()
- createUser() now takes email param, stores verification token (24h TTL)
- loginUser() throws 'Email not verified' when email_verified is false
- New /verify-email route: validates token, verifies user, auto-logs in
- Login page: email field in register form, check-inbox state after register
- /api/auth/register (iOS): returns { pending_verification, email } instead of token
- Add pb.libnovel.cc and storage.libnovel.cc Caddy virtual hosts for homelab runner
- Add homelab runner docker-compose and libnovel.sh helper script
This commit is contained in:
195
ui/src/lib/server/email.ts
Normal file
195
ui/src/lib/server/email.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Minimal SMTP mailer for email verification.
|
||||
*
|
||||
* Uses Node's built-in `tls` module to connect to smtp.resend.com:465
|
||||
* (implicit TLS / SMTPS) — no external dependencies required.
|
||||
*
|
||||
* Env vars (injected by docker-compose via Doppler):
|
||||
* SMTP_HOST smtp.resend.com
|
||||
* SMTP_PORT 465
|
||||
* SMTP_USER resend
|
||||
* SMTP_PASSWORD re_...
|
||||
* SMTP_FROM noreply@libnovel.cc
|
||||
* APP_URL https://libnovel.cc (used to build verification links)
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import * as tls from 'node:tls';
|
||||
|
||||
const SMTP_HOST = env.SMTP_HOST ?? 'smtp.resend.com';
|
||||
const SMTP_PORT = parseInt(env.SMTP_PORT ?? '465', 10);
|
||||
const SMTP_USER = env.SMTP_USER ?? '';
|
||||
const SMTP_PASSWORD = env.SMTP_PASSWORD ?? '';
|
||||
const SMTP_FROM = env.SMTP_FROM ?? 'noreply@libnovel.cc';
|
||||
export const APP_URL = (env.APP_URL ?? 'https://libnovel.cc').replace(/\/$/, '');
|
||||
|
||||
// ─── Low-level SMTP over implicit TLS ────────────────────────────────────────
|
||||
|
||||
function smtpEncode(s: string): string {
|
||||
return Buffer.from(s).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a raw email via SMTP over implicit TLS (port 465).
|
||||
* Returns true on success, throws on failure.
|
||||
*/
|
||||
async function sendSmtp(opts: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = tls.connect(
|
||||
{ host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true },
|
||||
() => {
|
||||
// TLS handshake complete — SMTP conversation begins
|
||||
}
|
||||
);
|
||||
|
||||
socket.setEncoding('utf8');
|
||||
socket.setTimeout(15_000);
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy(new Error('SMTP connection timed out'));
|
||||
});
|
||||
|
||||
let buf = '';
|
||||
let step = 0;
|
||||
|
||||
const send = (cmd: string) => socket.write(cmd + '\r\n');
|
||||
|
||||
const boundary = `----=_Part_${Date.now()}`;
|
||||
const multipart = [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=UTF-8',
|
||||
'',
|
||||
opts.text,
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset=UTF-8',
|
||||
'',
|
||||
opts.html,
|
||||
`--${boundary}--`
|
||||
].join('\r\n');
|
||||
|
||||
const message = [
|
||||
`From: LibNovel <${SMTP_FROM}>`,
|
||||
`To: ${opts.to}`,
|
||||
`Subject: ${opts.subject}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
'',
|
||||
multipart
|
||||
].join('\r\n');
|
||||
|
||||
socket.on('data', (chunk: string) => {
|
||||
buf += chunk;
|
||||
// Process complete lines
|
||||
const lines = buf.split('\r\n');
|
||||
buf = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
const code = parseInt(line.slice(0, 3), 10);
|
||||
// Only act on the final response line (no continuation dash)
|
||||
if (line[3] === '-') continue;
|
||||
|
||||
if (code >= 400) {
|
||||
socket.destroy(new Error(`SMTP error: ${line}`));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (step) {
|
||||
case 0: // 220 banner
|
||||
send(`EHLO libnovel.cc`);
|
||||
step++;
|
||||
break;
|
||||
case 1: // 250 EHLO
|
||||
send('AUTH LOGIN');
|
||||
step++;
|
||||
break;
|
||||
case 2: // 334 Username prompt
|
||||
send(smtpEncode(SMTP_USER));
|
||||
step++;
|
||||
break;
|
||||
case 3: // 334 Password prompt
|
||||
send(smtpEncode(SMTP_PASSWORD));
|
||||
step++;
|
||||
break;
|
||||
case 4: // 235 Auth success
|
||||
send(`MAIL FROM:<${SMTP_FROM}>`);
|
||||
step++;
|
||||
break;
|
||||
case 5: // 250 MAIL FROM ok
|
||||
send(`RCPT TO:<${opts.to}>`);
|
||||
step++;
|
||||
break;
|
||||
case 6: // 250 RCPT TO ok
|
||||
send('DATA');
|
||||
step++;
|
||||
break;
|
||||
case 7: // 354 Start data
|
||||
send(message + '\r\n.');
|
||||
step++;
|
||||
break;
|
||||
case 8: // 250 Message accepted
|
||||
send('QUIT');
|
||||
step++;
|
||||
break;
|
||||
case 9: // 221 Bye
|
||||
socket.destroy();
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => reject(err));
|
||||
socket.on('close', () => {
|
||||
if (step < 9) reject(new Error('SMTP connection closed unexpectedly'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Email templates ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function sendVerificationEmail(to: string, token: string): Promise<void> {
|
||||
const link = `${APP_URL}/verify-email?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:sans-serif;background:#18181b;color:#f4f4f5;padding:32px;">
|
||||
<div style="max-width:480px;margin:0 auto;">
|
||||
<h1 style="color:#f59e0b;font-size:24px;margin-bottom:8px;">Verify your email</h1>
|
||||
<p style="color:#a1a1aa;margin-bottom:24px;">
|
||||
Thanks for signing up to LibNovel. Click the button below to verify your email address.
|
||||
The link expires in 24 hours.
|
||||
</p>
|
||||
<a href="${link}"
|
||||
style="display:inline-block;background:#f59e0b;color:#18181b;font-weight:600;
|
||||
padding:12px 24px;border-radius:6px;text-decoration:none;font-size:15px;">
|
||||
Verify email
|
||||
</a>
|
||||
<p style="margin-top:24px;color:#71717a;font-size:13px;">
|
||||
Or copy this link:<br>
|
||||
<a href="${link}" style="color:#f59e0b;word-break:break-all;">${link}</a>
|
||||
</p>
|
||||
<p style="margin-top:32px;color:#52525b;font-size:12px;">
|
||||
If you didn't create a LibNovel account, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = `Verify your LibNovel email address\n\nClick this link to verify your account (expires in 24 hours):\n${link}\n\nIf you didn't sign up, ignore this email.`;
|
||||
|
||||
try {
|
||||
await sendSmtp({ to, subject: 'Verify your LibNovel email', html, text });
|
||||
log.info('email', 'verification email sent', { to });
|
||||
} catch (err) {
|
||||
log.error('email', 'failed to send verification email', { to, err: String(err) });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,10 @@ export interface User {
|
||||
role: string;
|
||||
created: string;
|
||||
avatar_url?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
verification_token?: string;
|
||||
verification_token_exp?: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -486,21 +490,52 @@ export async function getUserByUsername(username: string): Promise<User | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with a hashed password. Throws if username already exists.
|
||||
* Look up a user by email. Returns null if not found.
|
||||
*/
|
||||
export async function createUser(username: string, password: string, role = 'user'): Promise<User> {
|
||||
export async function getUserByEmail(email: string): Promise<User | null> {
|
||||
return listOne<User>('app_users', `email="${email.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by verification token. Returns null if not found.
|
||||
*/
|
||||
export async function getUserByVerificationToken(token: string): Promise<User | null> {
|
||||
return listOne<User>('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with a hashed password. Throws if username already exists.
|
||||
* Stores email + verification token but does NOT log the user in.
|
||||
*/
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password: string,
|
||||
email: string,
|
||||
role = 'user'
|
||||
): Promise<User> {
|
||||
log.info('pocketbase', 'createUser: checking for existing username', { username });
|
||||
const existing = await getUserByUsername(username);
|
||||
if (existing) {
|
||||
log.warn('pocketbase', 'createUser: username already taken', { username });
|
||||
throw new Error('Username already taken');
|
||||
}
|
||||
const existingEmail = await getUserByEmail(email);
|
||||
if (existingEmail) {
|
||||
log.warn('pocketbase', 'createUser: email already in use', { email });
|
||||
throw new Error('Email already in use');
|
||||
}
|
||||
const password_hash = hashPassword(password);
|
||||
log.info('pocketbase', 'createUser: inserting new user', { username, role });
|
||||
const verification_token = randomBytes(32).toString('hex');
|
||||
const verification_token_exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
log.info('pocketbase', 'createUser: inserting new user', { username, email, role });
|
||||
const res = await pbPost('/api/collections/app_users/records', {
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
email,
|
||||
email_verified: false,
|
||||
verification_token,
|
||||
verification_token_exp,
|
||||
created: new Date().toISOString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -516,6 +551,23 @@ export async function createUser(username: string, password: string, role = 'use
|
||||
return res.json() as Promise<User>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a user's email as verified and clear the verification token.
|
||||
*/
|
||||
export async function verifyUserEmail(userId: string): Promise<void> {
|
||||
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
|
||||
email_verified: true,
|
||||
verification_token: '',
|
||||
verification_token_exp: ''
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'verifyUserEmail: PATCH failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to verify email: ${res.status}`);
|
||||
}
|
||||
log.info('pocketbase', 'verifyUserEmail: success', { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a user's password. Verifies the current password first.
|
||||
* Returns true on success, false if currentPassword is wrong.
|
||||
@@ -556,6 +608,7 @@ export async function changePassword(
|
||||
|
||||
/**
|
||||
* Verify username + password. Returns the user on success, null on failure.
|
||||
* Throws with message 'Email not verified' if the account exists but hasn't been verified.
|
||||
*/
|
||||
export async function loginUser(username: string, password: string): Promise<User | null> {
|
||||
log.debug('pocketbase', 'loginUser: lookup', { username });
|
||||
@@ -569,6 +622,10 @@ export async function loginUser(username: string, password: string): Promise<Use
|
||||
log.warn('pocketbase', 'loginUser: wrong password', { username });
|
||||
return null;
|
||||
}
|
||||
if (!user.email_verified) {
|
||||
log.warn('pocketbase', 'loginUser: email not verified', { username });
|
||||
throw new Error('Email not verified');
|
||||
}
|
||||
log.info('pocketbase', 'loginUser: success', { username, role: user.role });
|
||||
return user;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user