Public Access
feat: implement retake exam functionality and update translations for exam status #2
No files matched your search
@@ -27,10 +27,8 @@ jobs:
|
|||||||
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Write Inventory
|
- name: Write Inventory
|
||||||
# StrictHostKeyChecking=no is intentional for a known home-network host.
|
|
||||||
# To harden, add the host fingerprint as a secret and use a known_hosts file.
|
|
||||||
run: |
|
run: |
|
||||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
|
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
|
||||||
"${{ secrets.ANSIBLE_HOST }}" \
|
"${{ secrets.ANSIBLE_HOST }}" \
|
||||||
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
|
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
|
||||||
|
|
||||||
|
|||||||
Generated
+10
@@ -14,6 +14,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
@@ -1862,6 +1863,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const config = {
|
|||||||
jwtSecret: env('JWT_SECRET'),
|
jwtSecret: env('JWT_SECRET'),
|
||||||
dbPath: env('DB_PATH', './db/brainwave.db'),
|
dbPath: env('DB_PATH', './db/brainwave.db'),
|
||||||
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
||||||
bodyLimit: env('BODY_LIMIT', '10mb'),
|
bodyLimit: env('BODY_LIMIT', '100kb'),
|
||||||
|
|
||||||
email: {
|
email: {
|
||||||
smtpHost: env('SMTP_HOST', ''),
|
smtpHost: env('SMTP_HOST', ''),
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,8 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
import { getDb, closeDb } from './db/database.js'
|
import { getDb, closeDb } from './db/database.js'
|
||||||
import authRoutes from './routes/auth.js'
|
import authRoutes from './routes/auth.js'
|
||||||
import examRoutes from './routes/exams.js'
|
import examRoutes from './routes/exams.js'
|
||||||
@@ -20,9 +22,25 @@ const PORT = config.port
|
|||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use(helmet())
|
||||||
|
|
||||||
|
// CORS
|
||||||
app.use(cors({ origin: config.corsOrigin }))
|
app.use(cors({ origin: config.corsOrigin }))
|
||||||
|
|
||||||
|
// Body parsing with reasonable limit
|
||||||
app.use(express.json({ limit: config.bodyLimit }))
|
app.use(express.json({ limit: config.bodyLimit }))
|
||||||
|
|
||||||
|
// Global API rate limiting
|
||||||
|
const globalLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 200,
|
||||||
|
message: { error: 'Too many requests, please try again later' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
})
|
||||||
|
app.use('/api/', globalLimiter)
|
||||||
|
|
||||||
// Request logging
|
// Request logging
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
@@ -63,7 +81,6 @@ app.get('/api/health', (_req, res) => {
|
|||||||
res.status(overall === 'healthy' ? 200 : 503).json({
|
res.status(overall === 'healthy' ? 200 : 503).json({
|
||||||
status: overall,
|
status: overall,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
version: process.env['npm_package_version'] ?? '0.0.1',
|
|
||||||
checks,
|
checks,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express'
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
|
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
|
||||||
|
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
@@ -22,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
|||||||
try {
|
try {
|
||||||
const token = header.slice(7)
|
const token = header.slice(7)
|
||||||
req.user = verifyToken(token)
|
req.user = verifyToken(token)
|
||||||
|
|
||||||
|
// Check if the token has been blacklisted (e.g., after logout)
|
||||||
|
if (isTokenBlacklisted(hashToken(token))) {
|
||||||
|
res.status(401).json({ error: 'Token has been revoked' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
} catch {
|
} catch {
|
||||||
res.status(401).json({ error: 'Invalid or expired token' })
|
res.status(401).json({ error: 'Invalid or expired token' })
|
||||||
|
|||||||
@@ -17,15 +17,21 @@ export function errorHandler(
|
|||||||
_next: any
|
_next: any
|
||||||
) {
|
) {
|
||||||
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
||||||
const message = err.message || 'Internal Server Error';
|
const isOperational = err instanceof AppError && err.isOperational;
|
||||||
|
|
||||||
console.error(`[Error] ${statusCode} - ${message}`);
|
// Log full details server-side
|
||||||
if (!(err instanceof AppError) || !err.isOperational) {
|
console.error(`[Error] ${statusCode} - ${err.message}`);
|
||||||
|
if (!isOperational) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never leak internal error details to the client for unexpected 500s
|
||||||
|
const clientMessage = statusCode === 500 && !isOperational
|
||||||
|
? 'Internal Server Error'
|
||||||
|
: (err.message || 'Internal Server Error');
|
||||||
|
|
||||||
res.status(statusCode).json({
|
res.status(statusCode).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message,
|
message: clientMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -63,14 +63,14 @@ export function getUserById(id: number): DbUser | undefined {
|
|||||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
|
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserByVerifyToken(token: string): DbUser | undefined {
|
export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined
|
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserByResetToken(token: string): DbUser | undefined {
|
export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined
|
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUser(id: number, data: UpdateUserData): boolean {
|
export function updateUser(id: number, data: UpdateUserData): boolean {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { requireAuth } from '../middleware/auth.js'
|
|||||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import * as authService from '../services/authService.js'
|
import * as authService from '../services/authService.js'
|
||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
|
import { hashToken } from '../utils/tokens.js'
|
||||||
|
import { blacklistToken } from '../utils/tokenBlacklist.js'
|
||||||
import {
|
import {
|
||||||
registerSchema,
|
registerSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
@@ -113,6 +115,23 @@ router.post(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// POST /api/auth/logout
|
||||||
|
router.post(
|
||||||
|
'/logout',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const header = req.headers.authorization
|
||||||
|
if (header?.startsWith('Bearer ')) {
|
||||||
|
const token = header.slice(7)
|
||||||
|
const tokenHash = hashToken(token)
|
||||||
|
// Blacklist the token so it cannot be reused after logout
|
||||||
|
// Expiry is 24h from now (matching JWT expiry)
|
||||||
|
blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// DELETE /api/auth/me
|
// DELETE /api/auth/me
|
||||||
router.delete(
|
router.delete(
|
||||||
'/me',
|
'/me',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireAuth } from '../middleware/auth.js'
|
|||||||
import { AppError } from '../middleware/error.js'
|
import { AppError } from '../middleware/error.js'
|
||||||
import * as examRepo from '../repositories/examRepo.js'
|
import * as examRepo from '../repositories/examRepo.js'
|
||||||
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||||
|
import { sanitizeExam } from '../utils/sanitizeExam.js'
|
||||||
import type { Request } from 'express'
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -41,7 +42,7 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
validateBody(saveExamSchema),
|
validateBody(saveExamSchema),
|
||||||
asyncHandler(async (req: Request, res) => {
|
asyncHandler(async (req: Request, res) => {
|
||||||
const exam = req.body.exam
|
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||||
const { id } = examRepo.createExam(
|
const { id } = examRepo.createExam(
|
||||||
req.user!.userId,
|
req.user!.userId,
|
||||||
exam.title,
|
exam.title,
|
||||||
@@ -59,7 +60,7 @@ router.put(
|
|||||||
validateBody(updateExamSchema),
|
validateBody(updateExamSchema),
|
||||||
asyncHandler(async (req: Request, res) => {
|
asyncHandler(async (req: Request, res) => {
|
||||||
const id = Number(req.params.id)
|
const id = Number(req.params.id)
|
||||||
const exam = req.body.exam
|
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||||
const updated = examRepo.updateExam(
|
const updated = examRepo.updateExam(
|
||||||
id,
|
id,
|
||||||
req.user!.userId,
|
req.user!.userId,
|
||||||
|
|||||||
+54
-42
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'
|
|||||||
import { validateBody } from '../middleware/validate.js'
|
import { validateBody } from '../middleware/validate.js'
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import { AppError } from '../middleware/error.js'
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import { getDb } from '../db/database.js'
|
||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||||
import { setupSchema } from '../validators/setupSchemas.js'
|
import { setupSchema } from '../validators/setupSchemas.js'
|
||||||
@@ -33,54 +34,65 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
validateBody(setupSchema),
|
validateBody(setupSchema),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
if (getSiteTitle()) {
|
const db = getDb()
|
||||||
throw new AppError(400, 'Setup already completed')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRepo.countUsers() > 0) {
|
// Atomic transaction prevents race conditions between concurrent setup requests
|
||||||
throw new AppError(400, 'Users already exist')
|
const tx = db.transaction(() => {
|
||||||
}
|
const title = settingsRepo.getSetting('site_title')
|
||||||
|
if (title) {
|
||||||
|
throw new AppError(400, 'Setup already completed')
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const userCount = userRepo.countUsers()
|
||||||
email,
|
if (userCount > 0) {
|
||||||
password,
|
throw new AppError(400, 'Users already exist')
|
||||||
displayName,
|
}
|
||||||
siteTitle,
|
|
||||||
language,
|
|
||||||
smtpHost,
|
|
||||||
smtpPort,
|
|
||||||
smtpUser,
|
|
||||||
smtpPass,
|
|
||||||
smtpFrom,
|
|
||||||
smtpSecure,
|
|
||||||
appUrl,
|
|
||||||
} = req.body
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
const {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
siteTitle,
|
||||||
|
language,
|
||||||
|
smtpHost,
|
||||||
|
smtpPort,
|
||||||
|
smtpUser,
|
||||||
|
smtpPass,
|
||||||
|
smtpFrom,
|
||||||
|
smtpSecure,
|
||||||
|
appUrl,
|
||||||
|
} = req.body
|
||||||
|
|
||||||
const { id } = userRepo.createUser({
|
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||||
email,
|
|
||||||
passwordHash,
|
const { id } = userRepo.createUser({
|
||||||
displayName,
|
email,
|
||||||
role: 'admin',
|
passwordHash,
|
||||||
emailVerified: 1,
|
displayName,
|
||||||
|
role: 'admin',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const settings: Array<[string, string]> = [
|
||||||
|
['site_title', siteTitle?.trim() || 'BrainWave'],
|
||||||
|
['site_language', language || 'en'],
|
||||||
|
['maintenance_mode', 'false'],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
|
||||||
|
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
|
||||||
|
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
|
||||||
|
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
|
||||||
|
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
|
||||||
|
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
|
||||||
|
if (appUrl !== undefined) settings.push(['app_url', appUrl])
|
||||||
|
|
||||||
|
settingsRepo.setSettingsBatch(settings)
|
||||||
|
|
||||||
|
return { id, email, displayName }
|
||||||
})
|
})
|
||||||
|
|
||||||
const settings: Array<[string, string]> = [
|
const { id, email, displayName } = tx()
|
||||||
['site_title', siteTitle?.trim() || 'BrainWave'],
|
|
||||||
['site_language', language || 'en'],
|
|
||||||
['maintenance_mode', 'false'],
|
|
||||||
]
|
|
||||||
|
|
||||||
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
|
|
||||||
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
|
|
||||||
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
|
|
||||||
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
|
|
||||||
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
|
|
||||||
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
|
|
||||||
if (appUrl !== undefined) settings.push(['app_url', appUrl])
|
|
||||||
|
|
||||||
settingsRepo.setSettingsBatch(settings)
|
|
||||||
|
|
||||||
const token = signToken({
|
const token = signToken({
|
||||||
userId: id,
|
userId: id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
|
||||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||||
import { AppError } from '../middleware/error.js'
|
import { AppError } from '../middleware/error.js'
|
||||||
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
||||||
@@ -22,7 +22,8 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
|||||||
|
|
||||||
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
||||||
const role = isFirstUser ? 'admin' : 'user'
|
const role = isFirstUser ? 'admin' : 'user'
|
||||||
const verifyToken = generateVerificationToken()
|
const rawVerifyToken = generateVerificationToken()
|
||||||
|
const verifyTokenHash = hashToken(rawVerifyToken)
|
||||||
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
||||||
|
|
||||||
const { id } = userRepo.createUser({
|
const { id } = userRepo.createUser({
|
||||||
@@ -31,14 +32,14 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
|||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
role,
|
role,
|
||||||
emailVerified: 0,
|
emailVerified: 0,
|
||||||
verifyToken,
|
verifyToken: verifyTokenHash,
|
||||||
verifyExpires,
|
verifyExpires,
|
||||||
})
|
})
|
||||||
|
|
||||||
const user = userRepo.getUserById(id)!
|
const user = userRepo.getUserById(id)!
|
||||||
|
|
||||||
// Don't await — fire and forget so registration isn't blocked by email failures
|
// Don't await — fire and forget so registration isn't blocked by email failures
|
||||||
sendVerificationEmail(user.email, verifyToken).catch(() => {})
|
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: signToken({
|
token: signToken({
|
||||||
@@ -67,6 +68,10 @@ export async function login(input: LoginInput): Promise<AuthResult> {
|
|||||||
throw new AppError(401, 'Invalid email or password')
|
throw new AppError(401, 'Invalid email or password')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.email_verified) {
|
||||||
|
throw new AppError(403, 'Please verify your email before logging in')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: signToken({
|
token: signToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -118,7 +123,8 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyEmail(input: VerifyEmailInput) {
|
export async function verifyEmail(input: VerifyEmailInput) {
|
||||||
const user = userRepo.getUserByVerifyToken(input.token)
|
const tokenHash = hashToken(input.token)
|
||||||
|
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
|
||||||
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
||||||
throw new AppError(400, 'Invalid or expired verification token')
|
throw new AppError(400, 'Invalid or expired verification token')
|
||||||
}
|
}
|
||||||
@@ -141,17 +147,19 @@ export async function forgotPassword(email?: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToken = generateVerificationToken()
|
const rawResetToken = generateVerificationToken()
|
||||||
|
const resetTokenHash = hashToken(rawResetToken)
|
||||||
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
||||||
|
|
||||||
userRepo.updateUser(user.id, { resetToken, resetExpires })
|
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
|
||||||
|
|
||||||
// Fire and forget
|
// Fire and forget
|
||||||
sendPasswordResetEmail(user.email, resetToken).catch(() => {})
|
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetPassword(input: ResetPasswordInput) {
|
export async function resetPassword(input: ResetPasswordInput) {
|
||||||
const user = userRepo.getUserByResetToken(input.token)
|
const tokenHash = hashToken(input.token)
|
||||||
|
const user = userRepo.getUserByResetTokenHash(tokenHash)
|
||||||
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
||||||
throw new AppError(400, 'Invalid or expired reset token')
|
throw new AppError(400, 'Invalid or expired reset token')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Server-side sanitization for user-provided exam content.
|
||||||
|
* Removes script tags and event handlers from string fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function sanitizeString(value: unknown): unknown {
|
||||||
|
if (typeof value !== 'string') return value
|
||||||
|
return value
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<script[^>]*\/>/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/on\w+\s*=/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeObject(obj: unknown): unknown {
|
||||||
|
if (typeof obj === 'string') return sanitizeString(obj)
|
||||||
|
if (Array.isArray(obj)) return obj.map(sanitizeObject)
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
result[key] = sanitizeObject(val)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return sanitizeObject(exam) as Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* In-memory token blacklist with SQLite persistence.
|
||||||
|
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
|
||||||
|
const memoryCache = new Set<string>()
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
function initCache(): void {
|
||||||
|
if (initialized) return
|
||||||
|
const db = getDb()
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
|
||||||
|
`)
|
||||||
|
// Load recent blacklisted tokens into memory
|
||||||
|
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
|
||||||
|
.all(Date.now()) as { token_hash: string }[]
|
||||||
|
for (const row of rows) {
|
||||||
|
memoryCache.add(row.token_hash)
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenBlacklisted(tokenHash: string): boolean {
|
||||||
|
initCache()
|
||||||
|
return memoryCache.has(tokenHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blacklistToken(tokenHash: string, expiresAt: number): void {
|
||||||
|
initCache()
|
||||||
|
memoryCache.add(tokenHash)
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
|
||||||
|
.run(tokenHash, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupExpiredTokens(): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
|
||||||
|
}
|
||||||
@@ -20,3 +20,7 @@ export function verifyToken(token: string): JwtPayload {
|
|||||||
export function generateVerificationToken(): string {
|
export function generateVerificationToken(): string {
|
||||||
return crypto.randomBytes(32).toString('hex')
|
return crypto.randomBytes(32).toString('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex')
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => {
|
|||||||
|
|
||||||
describe('updateSettingSchema', () => {
|
describe('updateSettingSchema', () => {
|
||||||
it('accepts valid setting', () => {
|
it('accepts valid setting', () => {
|
||||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' })
|
const result = updateSettingSchema.safeParse({ key: 'site_title', value: 'BrainWave' })
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ describe('updateSettingSchema', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('rejects empty value', () => {
|
it('rejects empty value', () => {
|
||||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' })
|
const result = updateSettingSchema.safeParse({ key: 'site_title', value: '' })
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,26 @@ export const updateUserRoleSchema = z.object({
|
|||||||
role: z.enum(['user', 'admin']),
|
role: z.enum(['user', 'admin']),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ALLOWED_SETTING_KEYS = [
|
||||||
|
'site_title',
|
||||||
|
'site_language',
|
||||||
|
'maintenance_mode',
|
||||||
|
'maintenance_message',
|
||||||
|
'allow_guest_mode',
|
||||||
|
'registration_enabled',
|
||||||
|
'default_theme',
|
||||||
|
'smtp_host',
|
||||||
|
'smtp_port',
|
||||||
|
'smtp_user',
|
||||||
|
'smtp_pass',
|
||||||
|
'smtp_from',
|
||||||
|
'smtp_secure',
|
||||||
|
'app_url',
|
||||||
|
'max_upload_mb',
|
||||||
|
] as const
|
||||||
|
|
||||||
export const updateSettingSchema = z.object({
|
export const updateSettingSchema = z.object({
|
||||||
key: z.string().min(1),
|
key: z.enum(ALLOWED_SETTING_KEYS),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('registerSchema', () => {
|
|||||||
it('accepts valid registration', () => {
|
it('accepts valid registration', () => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
password: 'password123',
|
password: 'Password123!',
|
||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
@@ -14,7 +14,7 @@ describe('registerSchema', () => {
|
|||||||
it('rejects invalid email', () => {
|
it('rejects invalid email', () => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email: 'not-an-email',
|
email: 'not-an-email',
|
||||||
password: 'password123',
|
password: 'Password123!',
|
||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
@@ -32,7 +32,7 @@ describe('registerSchema', () => {
|
|||||||
it('rejects empty display name', () => {
|
it('rejects empty display name', () => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
password: 'password123',
|
password: 'Password123!',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
@@ -65,7 +65,7 @@ describe('verifyEmailSchema', () => {
|
|||||||
|
|
||||||
describe('resetPasswordSchema', () => {
|
describe('resetPasswordSchema', () => {
|
||||||
it('accepts valid reset', () => {
|
it('accepts valid reset', () => {
|
||||||
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' })
|
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ describe('changePasswordSchema', () => {
|
|||||||
it('accepts valid change', () => {
|
it('accepts valid change', () => {
|
||||||
const result = changePasswordSchema.safeParse({
|
const result = changePasswordSchema.safeParse({
|
||||||
currentPassword: 'oldpass',
|
currentPassword: 'oldpass',
|
||||||
newPassword: 'newpass123',
|
newPassword: 'NewPass123!',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||||
|
|
||||||
export const registerSchema = z.object({
|
export const registerSchema = z.object({
|
||||||
email: z.string().email().max(255),
|
email: z.string().email().max(255),
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
password: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -17,7 +21,9 @@ export const verifyEmailSchema = z.object({
|
|||||||
|
|
||||||
export const resetPasswordSchema = z.object({
|
export const resetPasswordSchema = z.object({
|
||||||
token: z.string().min(1, 'Token is required'),
|
token: z.string().min(1, 'Token is required'),
|
||||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
newPassword: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProfileSchema = z.object({
|
export const updateProfileSchema = z.object({
|
||||||
@@ -26,7 +32,9 @@ export const updateProfileSchema = z.object({
|
|||||||
|
|
||||||
export const changePasswordSchema = z.object({
|
export const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string(),
|
currentPassword: z.string(),
|
||||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
newPassword: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const forgotPasswordSchema = z.object({
|
export const forgotPasswordSchema = z.object({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('setupSchema', () => {
|
|||||||
it('accepts valid setup', () => {
|
it('accepts valid setup', () => {
|
||||||
const result = setupSchema.safeParse({
|
const result = setupSchema.safeParse({
|
||||||
email: 'admin@example.com',
|
email: 'admin@example.com',
|
||||||
password: 'adminpass123',
|
password: 'AdminPass123!',
|
||||||
displayName: 'Admin',
|
displayName: 'Admin',
|
||||||
siteTitle: 'BrainWave',
|
siteTitle: 'BrainWave',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
@@ -16,7 +16,7 @@ describe('setupSchema', () => {
|
|||||||
it('rejects invalid email', () => {
|
it('rejects invalid email', () => {
|
||||||
const result = setupSchema.safeParse({
|
const result = setupSchema.safeParse({
|
||||||
email: 'bad',
|
email: 'bad',
|
||||||
password: 'adminpass123',
|
password: 'AdminPass123!',
|
||||||
displayName: 'Admin',
|
displayName: 'Admin',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||||
|
|
||||||
export const setupSchema = z.object({
|
export const setupSchema = z.object({
|
||||||
email: z.string().email().max(255),
|
email: z.string().email().max(255),
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
password: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||||
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
|
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
|
||||||
language: z.enum(['en', 'nl']).optional(),
|
language: z.enum(['en', 'nl']).optional(),
|
||||||
|
|||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "brainwave",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]}
|
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.415200000000027,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":6.726200000000006,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.416300000000035,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.132000000000005,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.569299999999998,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":7.313600000000008,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.513400000000047,"failed":false}],[":backend/dist/config.test.js",{"duration":3.9307000000000016,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.670799999999986,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":10.730400000000031,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.168000000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0274,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.865000000000009,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":130.3977,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.94319999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":130.18120000000005,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.06280000000001,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.779500000000013,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.795000000000016,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.715100000000007,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":3.313999999999993,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.088700000000017,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.638200000000012,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.546299999999974,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.1704000000000008,"failed":false}]]}
|
||||||
Generated
+16
-1
@@ -2,5 +2,20 @@
|
|||||||
"name": "brainwave",
|
"name": "brainwave",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {}
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"helmet": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "brainwave",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user