added auth with Resend env vars

This commit is contained in:
liyunze 2026-08-31 16:18:27 +08:00
parent ffa6b84da3
commit 9db0df3982
11 changed files with 327 additions and 69 deletions

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# Path to the SQLite database file (relative to the project root).
DATABASE_PATH=data/dsec.db
# Resend API key for sending verification emails (https://resend.com/api-keys).
RESEND_API_KEY=""
# Optional "from" address for verification emails.
RESEND_FROM=DSEC Notebook <onboarding@resend.dev>

View file

@ -37,13 +37,15 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
npm install npm install
``` ```
2. Configure environment variables (optional): 2. Configure environment variables:
```sh ```sh
cp .env .env.local cp .env .env.local
``` ```
The only variable is `DATABASE_PATH`, which defaults to `data/dsec.db`. `DATABASE_PATH` defaults to `data/dsec.db`. To send verification emails, set
`RESEND_API_KEY` (get one at <https://resend.com/api-keys>). Without it, email
verification will fail.
3. Start the development server: 3. Start the development server:
@ -56,8 +58,10 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
## Environment variables ## Environment variables
| Variable | Description | Default | | Variable | Description | Default |
| --------------- | --------------------------------------- | -------------- | | ---------------- | --------------------------------------- | --------------------------------------- |
| `DATABASE_PATH` | Path to the SQLite database file | `data/dsec.db` | | `DATABASE_PATH` | Path to the SQLite database file | `data/dsec.db` |
| `RESEND_API_KEY` | Resend API key for verification emails | _(required)_ |
| `RESEND_FROM` | "From" address for verification emails | `DSEC Notebook <onboarding@resend.dev>` |
| `HOST` | Host the Node server binds to (build) | `0.0.0.0` | | `HOST` | Host the Node server binds to (build) | `0.0.0.0` |
| `PORT` | Port the Node server listens on (build) | `3000` | | `PORT` | Port the Node server listens on (build) | `3000` |
@ -114,7 +118,7 @@ src/
- The frontend calls a single JSON API endpoint (`POST /api`) with a function name and arguments. - The frontend calls a single JSON API endpoint (`POST /api`) with a function name and arguments.
- The server dispatches those calls to handlers in `src/lib/server/api.ts`, backed by SQLite. - The server dispatches those calls to handlers in `src/lib/server/api.ts`, backed by SQLite.
- On first run, the database is created automatically and seeded with common Deakin SIT/Math units and CS/maths topics. - On first run, the database is created automatically and seeded with common Deakin SIT/Math units and CS/maths topics.
- Authentication is session-token based: signing in with a valid `@deakin.edu.au` email creates or reuses a user and stores a token in `localStorage`. - Authentication is email-verified: signing in with a `@deakin.edu.au` address sends a 6-digit code via Resend, which the user must enter to prove they own the inbox. Verified users are created or reused and given a session token stored in `localStorage`.
## Data model ## Data model
@ -127,6 +131,7 @@ The SQLite database contains the following tables:
- `questions` — student questions - `questions` — student questions
- `comments` — note comments and question answers - `comments` — note comments and question answers
- `votes` — upvotes/downvotes on notes and questions - `votes` — upvotes/downvotes on notes and questions
- `email_verifications` — pending email verification codes
## Disclaimer ## Disclaimer

View file

@ -5,6 +5,8 @@ services:
- "4073:3000" - "4073:3000"
environment: environment:
DATABASE_PATH: data/dsec.db DATABASE_PATH: data/dsec.db
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM: ${RESEND_FROM:-DSEC Notebook <onboarding@resend.dev>}
volumes: volumes:
- dsec-data:/app/data - dsec-data:/app/data

52
package-lock.json generated
View file

@ -7,6 +7,9 @@
"": { "": {
"name": "dsec-notebook", "name": "dsec-notebook",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"resend": "^6.25.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
@ -1242,6 +1245,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -2065,6 +2074,12 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2640,6 +2655,12 @@
"node": ">=14.19.0" "node": ">=14.19.0"
} }
}, },
"node_modules/postal-mime": {
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz",
"integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==",
"license": "MIT-0"
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@ -2683,6 +2704,27 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resend": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.25.0.tgz",
"integrity": "sha512-iptUEycs+6Hu+W8mExK708LrDiMYOuGgnCP+wTD5L4Zrqqd2B86h1oyAmNe0khZWQw0ccI7jz8OgAaplEsyaIA==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.5",
"standardwebhooks": "1.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -2843,6 +2885,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",

View file

@ -15,6 +15,9 @@
"fmt": "oxfmt", "fmt": "oxfmt",
"fmt:check": "oxfmt --check" "fmt:check": "oxfmt --check"
}, },
"dependencies": {
"resend": "^6.25.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",

View file

@ -1,7 +1,11 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { randomUUID } from "node:crypto"; import { createHash, randomInt, randomUUID } from "node:crypto";
import { sendVerificationCode } from "./email";
const DEAKIN_DOMAIN = "deakin.edu.au"; const DEAKIN_DOMAIN = "deakin.edu.au";
const CODE_TTL_MS = 10 * 60 * 1000;
const REQUEST_COOLDOWN_MS = 60 * 1000;
const MAX_ATTEMPTS = 5;
type Db = ReturnType<typeof getDb>; type Db = ReturnType<typeof getDb>;
@ -18,6 +22,23 @@ function generateToken(): string {
return result; return result;
} }
function generateCode(): string {
return String(randomInt(0, 1000000)).padStart(6, "0");
}
function hashCode(code: string): string {
return createHash("sha256").update(code).digest("hex");
}
function normalizeEmail(email: string): string {
const normalized = email.trim().toLowerCase();
const emailDomain = normalized.split("@")[1]?.toLowerCase();
if (emailDomain !== DEAKIN_DOMAIN) {
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
}
return normalized;
}
function requireAuth(db: Db, token: string): Record<string, any> { function requireAuth(db: Db, token: string): Record<string, any> {
const user = db const user = db
.prepare( .prepare(
@ -34,13 +55,7 @@ function mapQuestion(row: Record<string, any>): Record<string, any> {
// ---- users ---- // ---- users ----
function usersRegister(db: Db, args: { email: string; name: string }) { function createOrReuseUser(db: Db, email: string, name: string) {
const email = args.email.toLowerCase();
const emailDomain = email.split("@")[1]?.toLowerCase();
if (emailDomain !== DEAKIN_DOMAIN) {
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
}
const existing = db const existing = db
.prepare( .prepare(
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?", "SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?",
@ -50,15 +65,84 @@ function usersRegister(db: Db, args: { email: string; name: string }) {
const token = generateToken(); const token = generateToken();
if (existing) { if (existing) {
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, existing._id); db.prepare("UPDATE users SET sessionToken = ?, name = ? WHERE id = ?").run(
return { userId: existing._id, token, name: existing.name }; token,
name,
existing._id,
);
return { userId: existing._id, token, name };
} }
const id = newId(); const id = newId();
db.prepare( db.prepare(
"INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)", "INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)",
).run(id, email, args.name, token, Date.now()); ).run(id, email, name, token, Date.now());
return { userId: id, token, name: args.name }; return { userId: id, token, name };
}
async function authRequestCode(db: Db, args: { email: string; name: string }) {
const email = normalizeEmail(args.email);
if (!args.name || !args.name.trim()) {
throw new Error("Name is required");
}
const name = args.name.trim();
const existing = db
.prepare("SELECT email, createdAt FROM email_verifications WHERE email = ?")
.get(email) as { email: string; createdAt: number } | undefined;
if (existing && Date.now() - existing.createdAt < REQUEST_COOLDOWN_MS) {
throw new Error("A code was just sent. Please wait a minute before trying again.");
}
const code = generateCode();
const codeHash = hashCode(code);
const now = Date.now();
const expiresAt = now + CODE_TTL_MS;
if (existing) {
db.prepare(
"UPDATE email_verifications SET name = ?, codeHash = ?, expiresAt = ?, attempts = 0, createdAt = ? WHERE email = ?",
).run(name, codeHash, expiresAt, now, email);
} else {
db.prepare(
"INSERT INTO email_verifications (email, name, codeHash, expiresAt, attempts, createdAt) VALUES (?, ?, ?, ?, 0, ?)",
).run(email, name, codeHash, expiresAt, now);
}
try {
await sendVerificationCode(email, code);
} catch (err) {
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
throw err;
}
return { ok: true };
}
function authVerifyCode(db: Db, args: { email: string; code: string }) {
const email = normalizeEmail(args.email);
const pending = db
.prepare(
"SELECT name, codeHash, expiresAt, attempts FROM email_verifications WHERE email = ?",
)
.get(email) as
| { name: string; codeHash: string; expiresAt: number; attempts: number }
| undefined;
if (!pending) throw new Error("No verification code requested for this email");
if (Date.now() > pending.expiresAt) throw new Error("Verification code has expired");
if (pending.attempts >= MAX_ATTEMPTS) throw new Error("Too many attempts. Request a new code.");
if (hashCode(args.code) !== pending.codeHash) {
db.prepare("UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?").run(
email,
);
throw new Error("Invalid verification code");
}
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
return createOrReuseUser(db, email, pending.name);
} }
function usersGetByToken(db: Db, args: { token: string }) { function usersGetByToken(db: Db, args: { token: string }) {
@ -414,7 +498,8 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
type Handler = (db: Db, args: any) => any; type Handler = (db: Db, args: any) => any;
const handlers: Record<string, Handler> = { const handlers: Record<string, Handler> = {
"users:register": usersRegister, "auth:requestCode": authRequestCode,
"auth:verifyCode": authVerifyCode,
"users:getByToken": usersGetByToken, "users:getByToken": usersGetByToken,
"topics:getBySlug": topicsGetBySlug, "topics:getBySlug": topicsGetBySlug,
"topics:getAll": topicsGetAll, "topics:getAll": topicsGetAll,
@ -441,8 +526,8 @@ const handlers: Record<string, Handler> = {
"details:getQuestionWithDetails": getQuestionWithDetails, "details:getQuestionWithDetails": getQuestionWithDetails,
}; };
export function call(fn: string, args: Record<string, any> = {}): any { export async function call(fn: string, args: Record<string, any> = {}): Promise<any> {
const handler = handlers[fn]; const handler = handlers[fn];
if (!handler) throw new Error(`Unknown function: ${fn}`); if (!handler) throw new Error(`Unknown function: ${fn}`);
return handler(getDb(), args ?? {}); return await handler(getDb(), args ?? {});
} }

View file

@ -2,8 +2,9 @@ import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { DATABASE_PATH } from "$env/static/private";
const DB_PATH = resolve(process.env.DATABASE_PATH ?? "data/dsec.db"); const DB_PATH = resolve(DATABASE_PATH ?? "data/dsec.db");
let db: DatabaseSync | null = null; let db: DatabaseSync | null = null;
@ -79,6 +80,15 @@ function createSchema(database: DatabaseSync) {
value INTEGER NOT NULL value INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS email_verifications (
email TEXT PRIMARY KEY,
name TEXT NOT NULL,
codeHash TEXT NOT NULL,
expiresAt INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
createdAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topicId); CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topicId);
CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId); CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId);
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt); CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt);

27
src/lib/server/email.ts Normal file
View file

@ -0,0 +1,27 @@
import { Resend } from "resend";
import { RESEND_API_KEY, RESEND_FROM } from "$env/static/private";
const API_KEY = RESEND_API_KEY;
const FROM = RESEND_FROM;
export async function sendVerificationCode(email: string, code: string): Promise<void> {
if (!API_KEY) {
throw new Error("Server error: API error is not configured");
}
if (!FROM) {
throw new Error("Server error: Email is not configured");
}
const resend = new Resend(API_KEY);
const { error } = await resend.emails.send({
from: FROM,
to: email,
subject: "Your DSEC Notebook verification code",
text: `Your verification code is ${code}. It expires in 10 minutes.`,
});
if (error) {
throw new Error(error.message);
}
}

View file

@ -25,13 +25,17 @@ export async function initAuth() {
} }
} }
export async function login(email: string, name: string) { export async function requestCode(email: string, name: string) {
const result = await mutation("users:register", { email, name }); return await mutation("auth:requestCode", { email, name });
}
export async function verifyCode(email: string, code: string) {
const result = await mutation("auth:verifyCode", { email, code });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token })); localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
currentUser.set({ currentUser.set({
_id: result.userId, _id: result.userId,
email: email.toLowerCase(), email: email.toLowerCase(),
name, name: result.name,
sessionToken: result.token, sessionToken: result.token,
_creationTime: Date.now(), _creationTime: Date.now(),
} as UserDoc); } as UserDoc);

View file

@ -15,7 +15,7 @@ export const POST: RequestHandler = async ({ request }) => {
} }
try { try {
const result = call(fn, args as Record<string, any>); const result = await call(fn, args as Record<string, any>);
return json({ ok: true, result }); return json({ ok: true, result });
} catch (err: any) { } catch (err: any) {
const message = err?.message ?? "Internal error"; const message = err?.message ?? "Internal error";

View file

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { login } from "$lib/stores/auth"; import { requestCode, verifyCode } from "$lib/stores/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
let step = $state<"email" | "code">("email");
let email = $state(""); let email = $state("");
let name = $state(""); let name = $state("");
let code = $state("");
let error = $state(""); let error = $state("");
let loading = $state(false); let loading = $state(false);
@ -11,6 +13,7 @@
e.preventDefault(); e.preventDefault();
error = ""; error = "";
if (step === "email") {
if (!email.trim() || !name.trim()) { if (!email.trim() || !name.trim()) {
error = "Please fill in all fields"; error = "Please fill in all fields";
return; return;
@ -23,13 +26,36 @@
loading = true; loading = true;
try { try {
await login(email.trim(), name.trim()); await requestCode(email.trim(), name.trim());
goto("/"); step = "code";
} catch (err: any) { } catch (err: any) {
error = err.message ?? "Login failed. Please try again."; error = err.message ?? "Failed to send the code. Please try again.";
} finally { } finally {
loading = false; loading = false;
} }
return;
}
if (!code.trim()) {
error = "Please enter the verification code";
return;
}
loading = true;
try {
await verifyCode(email.trim(), code.trim());
goto("/");
} catch (err: any) {
error = err.message ?? "Verification failed. Please try again.";
} finally {
loading = false;
}
}
function goBack() {
step = "email";
code = "";
error = "";
} }
</script> </script>
@ -43,6 +69,7 @@
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">Sign in</h1> <h1 class="text-ink mt-2 font-serif text-4xl font-medium">Sign in</h1>
<p class="text-muted mt-2 mb-8 text-[15px]">Use your Deakin email to contribute.</p> <p class="text-muted mt-2 mb-8 text-[15px]">Use your Deakin email to contribute.</p>
{#if step === "email"}
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8"> <form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<div> <div>
<label for="name" class="kicker mb-2 block">Full name</label> <label for="name" class="kicker mb-2 block">Full name</label>
@ -74,12 +101,47 @@
{/if} {/if}
<button type="submit" disabled={loading} class="btn-primary w-full"> <button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Signing in..." : "Sign in / Register"} {loading ? "Sending code..." : "Send verification code"}
</button> </button>
<p class="text-faint text-xs"> <p class="text-faint text-xs">
By signing in, you agree that your contributions are public. By signing in, you agree that your contributions are public.
</p> </p>
</form> </form>
{:else}
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<p class="text-muted text-sm">
We sent a 6-digit code to <span class="text-ink">{email}</span>. It expires in
10 minutes.
</p>
<div>
<label for="code" class="kicker mb-2 block">Verification code</label>
<input
id="code"
type="text"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
bind:value={code}
placeholder="000000"
class="field"
required
/>
</div>
{#if error}
<p class="text-primary text-sm">{error}</p>
{/if}
<button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Verifying..." : "Verify & sign in"}
</button>
<button type="button" onclick={goBack} class="text-muted hover:text-ink text-sm">
Use a different email
</button>
</form>
{/if}
</div> </div>
</div> </div>