From 9db0df39827117c6051aae3c3f4b44e852d93c95 Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:18:27 +0800 Subject: [PATCH] added auth with Resend env vars --- .env.example | 8 ++ README.md | 21 +++-- docker-compose.yml | 2 + package-lock.json | 52 +++++++++++ package.json | 3 + src/lib/server/api.ts | 115 ++++++++++++++++++++--- src/lib/server/db.ts | 12 ++- src/lib/server/email.ts | 27 ++++++ src/lib/stores/auth.ts | 10 +- src/routes/api/+server.ts | 2 +- src/routes/auth/login/+page.svelte | 144 +++++++++++++++++++++-------- 11 files changed, 327 insertions(+), 69 deletions(-) create mode 100644 .env.example create mode 100644 src/lib/server/email.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..11d600a --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d108682..cb9d35c 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,15 @@ A centralised resource hub for Deakin University students studying **SIT** (IT, npm install ``` -2. Configure environment variables (optional): +2. Configure environment variables: ```sh 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 ). Without it, email + verification will fail. 3. Start the development server: @@ -55,11 +57,13 @@ A centralised resource hub for Deakin University students studying **SIT** (IT, ## Environment variables -| Variable | Description | Default | -| --------------- | --------------------------------------- | -------------- | -| `DATABASE_PATH` | Path to the SQLite database file | `data/dsec.db` | -| `HOST` | Host the Node server binds to (build) | `0.0.0.0` | -| `PORT` | Port the Node server listens on (build) | `3000` | +| Variable | Description | Default | +| ---------------- | --------------------------------------- | --------------------------------------- | +| `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 ` | +| `HOST` | Host the Node server binds to (build) | `0.0.0.0` | +| `PORT` | Port the Node server listens on (build) | `3000` | ## Scripts @@ -114,7 +118,7 @@ src/ - 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. - 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 @@ -127,6 +131,7 @@ The SQLite database contains the following tables: - `questions` — student questions - `comments` — note comments and question answers - `votes` — upvotes/downvotes on notes and questions +- `email_verifications` — pending email verification codes ## Disclaimer diff --git a/docker-compose.yml b/docker-compose.yml index f559f79..8070a23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: - "4073:3000" environment: DATABASE_PATH: data/dsec.db + RESEND_API_KEY: ${RESEND_API_KEY:-} + RESEND_FROM: ${RESEND_FROM:-DSEC Notebook } volumes: - dsec-data:/app/data diff --git a/package-lock.json b/package-lock.json index 9a5a952..f41cc5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "dsec-notebook", "version": "0.0.1", + "dependencies": { + "resend": "^6.25.0" + }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", @@ -1242,6 +1245,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2065,6 +2074,12 @@ "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": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2640,6 +2655,12 @@ "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": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -2683,6 +2704,27 @@ "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": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2843,6 +2885,16 @@ "dev": true, "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", diff --git a/package.json b/package.json index e7ddf12..e06ef45 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "fmt": "oxfmt", "fmt:check": "oxfmt --check" }, + "dependencies": { + "resend": "^6.25.0" + }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", diff --git a/src/lib/server/api.ts b/src/lib/server/api.ts index 5b82271..b5dcb02 100644 --- a/src/lib/server/api.ts +++ b/src/lib/server/api.ts @@ -1,7 +1,11 @@ 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 CODE_TTL_MS = 10 * 60 * 1000; +const REQUEST_COOLDOWN_MS = 60 * 1000; +const MAX_ATTEMPTS = 5; type Db = ReturnType; @@ -18,6 +22,23 @@ function generateToken(): string { 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 { const user = db .prepare( @@ -34,13 +55,7 @@ function mapQuestion(row: Record): Record { // ---- users ---- -function usersRegister(db: Db, args: { 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`); - } - +function createOrReuseUser(db: Db, email: string, name: string) { const existing = db .prepare( "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(); if (existing) { - db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, existing._id); - return { userId: existing._id, token, name: existing.name }; + db.prepare("UPDATE users SET sessionToken = ?, name = ? WHERE id = ?").run( + token, + name, + existing._id, + ); + return { userId: existing._id, token, name }; } const id = newId(); db.prepare( "INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)", - ).run(id, email, args.name, token, Date.now()); - return { userId: id, token, name: args.name }; + ).run(id, email, name, token, Date.now()); + 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 }) { @@ -414,7 +498,8 @@ function getQuestionWithDetails(db: Db, args: { id: string }) { type Handler = (db: Db, args: any) => any; const handlers: Record = { - "users:register": usersRegister, + "auth:requestCode": authRequestCode, + "auth:verifyCode": authVerifyCode, "users:getByToken": usersGetByToken, "topics:getBySlug": topicsGetBySlug, "topics:getAll": topicsGetAll, @@ -441,8 +526,8 @@ const handlers: Record = { "details:getQuestionWithDetails": getQuestionWithDetails, }; -export function call(fn: string, args: Record = {}): any { +export async function call(fn: string, args: Record = {}): Promise { const handler = handlers[fn]; if (!handler) throw new Error(`Unknown function: ${fn}`); - return handler(getDb(), args ?? {}); + return await handler(getDb(), args ?? {}); } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 83a97e8..a6ff345 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -2,8 +2,9 @@ import { DatabaseSync } from "node:sqlite"; import { mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; 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; @@ -79,6 +80,15 @@ function createSchema(database: DatabaseSync) { 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_unit ON notes(unitId); CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt); diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..7f9f0d8 --- /dev/null +++ b/src/lib/server/email.ts @@ -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 { + 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); + } +} diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index d940d06..5b14c6d 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -25,13 +25,17 @@ export async function initAuth() { } } -export async function login(email: string, name: string) { - const result = await mutation("users:register", { email, name }); +export async function requestCode(email: string, name: string) { + 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 })); currentUser.set({ _id: result.userId, email: email.toLowerCase(), - name, + name: result.name, sessionToken: result.token, _creationTime: Date.now(), } as UserDoc); diff --git a/src/routes/api/+server.ts b/src/routes/api/+server.ts index 16ed0e2..a87279e 100644 --- a/src/routes/api/+server.ts +++ b/src/routes/api/+server.ts @@ -15,7 +15,7 @@ export const POST: RequestHandler = async ({ request }) => { } try { - const result = call(fn, args as Record); + const result = await call(fn, args as Record); return json({ ok: true, result }); } catch (err: any) { const message = err?.message ?? "Internal error"; diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index caa0462..de5330d 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -1,9 +1,11 @@ @@ -43,43 +69,79 @@

Sign in

Use your Deakin email to contribute.

-
-
- - -
+ {#if step === "email"} + +
+ + +
-
- - -

Must be an @deakin.edu.au address

-
+
+ + +

Must be an @deakin.edu.au address

+
- {#if error} -

{error}

- {/if} + {#if error} +

{error}

+ {/if} - + -

- By signing in, you agree that your contributions are public. -

-
+

+ By signing in, you agree that your contributions are public. +

+ + {:else} +
+

+ We sent a 6-digit code to {email}. It expires in + 10 minutes. +

+ +
+ + +
+ + {#if error} +

{error}

+ {/if} + + + + +
+ {/if}