mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Wrote, generated and adjusted documentation for backend
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
const crypto = require("crypto");
|
||||
|
||||
/**
|
||||
* Password hashing and verification utilities using Node's `crypto.scrypt`.
|
||||
*
|
||||
* These helpers produce a per-user random salt and a hex-encoded hash. The
|
||||
* project stores `salt` and `password_hash` in the `users` table and verifies
|
||||
* logins by recomputing the scrypt hash with the stored salt.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hashes a plaintext password with scrypt and a salt.
|
||||
*
|
||||
* @param {string} password - Plaintext password to hash.
|
||||
* @param {string} [salt] - Optional salt; generates a random 16-byte hex salt when omitted.
|
||||
* @returns {Promise<{ salt: string, hashedPassword: string }>} Resolves with the salt and hex-encoded hash.
|
||||
*/
|
||||
export async function hashPassword(
|
||||
password,
|
||||
salt = crypto.randomBytes(16).toString("hex")
|
||||
@@ -13,6 +28,14 @@ export async function hashPassword(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a plaintext password against a stored salt and hash.
|
||||
*
|
||||
* @param {string} password - Plaintext password provided by the user.
|
||||
* @param {string} salt - The user's stored salt.
|
||||
* @param {string} hashedPasswordFromDB - The user's stored hex-encoded hash.
|
||||
* @returns {Promise<boolean>} Resolves `true` when the recomputed hash matches, else `false`.
|
||||
*/
|
||||
export async function verifyPassword(password, salt, hashedPasswordFromDB) {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
|
||||
@@ -4,9 +4,23 @@ import { cookies } from "next/headers";
|
||||
import { getConfig, isTest } from "../../backend/config";
|
||||
import { AUTH_COOKIE_NAME } from "@/shared/const";
|
||||
|
||||
/**
|
||||
* Session management utilities using signed JWTs stored in HTTP-only cookies.
|
||||
*
|
||||
* - Secret and TTL come from `getConfig()` (`sessionSecret`, `sessionTTL`).
|
||||
* - JWTs are signed with HS256 and include `{ user, expiresAt }`.
|
||||
* - In tests (`isTest`), cookie operations are skipped; `checkSession` returns
|
||||
* `request.testUser` when provided.
|
||||
*/
|
||||
const { sessionSecret, sessionTTL } = getConfig();
|
||||
const encodedKey = new TextEncoder().encode(sessionSecret);
|
||||
|
||||
/**
|
||||
* Signs an arbitrary payload into a JWT using HS256 and configured TTL.
|
||||
*
|
||||
* @param {Record<string, any>} payload - Object to embed in the token.
|
||||
* @returns {Promise<string>} A compact JWT string.
|
||||
*/
|
||||
export async function signPayload(payload) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
@@ -15,6 +29,12 @@ export async function signPayload(payload) {
|
||||
.sign(encodedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a JWT and returns its payload when valid.
|
||||
*
|
||||
* @param {string} session - Compact JWT string from the cookie.
|
||||
* @returns {Promise<Record<string, any> | undefined>} Payload if valid; otherwise `undefined`.
|
||||
*/
|
||||
export async function verifySession(session) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(session, encodedKey, {
|
||||
@@ -26,6 +46,16 @@ export async function verifySession(session) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session cookie for a user.
|
||||
*
|
||||
* Stores a signed JWT in `AUTH_COOKIE_NAME` with `httpOnly`, `sameSite:lax`,
|
||||
* and environment-appropriate `secure`. Skips cookie setting in tests.
|
||||
*
|
||||
* @param {{ id:number, username:string, email:string, is_admin:boolean }} user -
|
||||
* Minimal user info to embed in the session.
|
||||
* @returns {Promise<void>} Resolves when the cookie is set (or skipped in tests).
|
||||
*/
|
||||
export async function createSession(user) {
|
||||
const expiresAt = new Date(Date.now() + sessionTTL);
|
||||
const payload = {
|
||||
@@ -55,6 +85,14 @@ export async function createSession(user) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current session cookie and returns the embedded user.
|
||||
*
|
||||
* In tests, returns `request.testUser` to aid mocking.
|
||||
*
|
||||
* @param {Request & { testUser?: any }} [request] - Optional request object providing `testUser` in tests.
|
||||
* @returns {Promise<{ id:number, username:string, email:string, is_admin:boolean } | null>} The user or `null`.
|
||||
*/
|
||||
export async function checkSession(request) {
|
||||
if (isTest) {
|
||||
return request.testUser;
|
||||
@@ -70,6 +108,10 @@ export async function checkSession(request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current session cookie.
|
||||
* @returns {Promise<void>} Resolves when the cookie is removed.
|
||||
*/
|
||||
export async function deleteSession() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(AUTH_COOKIE_NAME);
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import { config } from "dotenv";
|
||||
|
||||
/**
|
||||
* Environment and configuration helpers.
|
||||
*
|
||||
* This module centralizes reading environment variables from `.env` and
|
||||
* `.env.<NODE_ENV>`, and exposes a structured `getConfig()` object for
|
||||
* application settings, including PostgreSQL connection parameters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Current runtime environment (e.g., `development`, `test`, `production`).
|
||||
* @type {string | undefined}
|
||||
*/
|
||||
export const environment = process.env.NODE_ENV;
|
||||
|
||||
/** Whether running in test environment. */
|
||||
export const isTest = environment === "test";
|
||||
/** Whether running in development environment. */
|
||||
export const isDevelopment = environment === "development";
|
||||
/** Whether running in production environment. */
|
||||
export const isProduction = environment === "production";
|
||||
|
||||
// Loads .env and .env.NODE_ENV (e.g. .env.development)
|
||||
// Shared environment variables are in .env
|
||||
// Environment specific variables are in .env.NODE_ENV
|
||||
// NextJS defaults to 'development' when running npm run dev
|
||||
/**
|
||||
* Loads environment variable files.
|
||||
*
|
||||
* - Always loads `.env`
|
||||
* - Then loads `.env.<environment>` if `environment` is one of
|
||||
* `test`, `development`, or `production`.
|
||||
*
|
||||
* Note: This function is synchronous despite being declared `async` in
|
||||
* some usages; dotenv's `config` is synchronous.
|
||||
*/
|
||||
export const initConfig = async () => {
|
||||
switch (environment) {
|
||||
case "test":
|
||||
@@ -19,7 +44,8 @@ export const initConfig = async () => {
|
||||
config({ path: `${process.cwd()}/.env.${environment}`, override: true });
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unsupported environment: " + env);
|
||||
// Provide a clearer error without referencing undefined variables.
|
||||
throw new Error("Unsupported environment: " + String(environment));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,8 +53,28 @@ export const initConfig = async () => {
|
||||
// It's used to read environment variables from .env and .env.NODE_ENV
|
||||
// So instead of using process.env.DB_HOST, you can use getConfig().db.host
|
||||
// This is useful for keeping the code clean and gives you clear errors if you miss a variable.
|
||||
/** @type {ReturnType<typeof create> | undefined} */
|
||||
let cachedConfig;
|
||||
|
||||
/**
|
||||
* Returns the cached application configuration, creating it on first access.
|
||||
* Ensures environment files are loaded before reading variables.
|
||||
*
|
||||
* @returns {{
|
||||
* url: string,
|
||||
* apiUrl: string,
|
||||
* sessionSecret: string,
|
||||
* sessionTTL: number,
|
||||
* db: {
|
||||
* host: string,
|
||||
* port: number,
|
||||
* user: string,
|
||||
* password: string,
|
||||
* database: string,
|
||||
* ssl: boolean,
|
||||
* }
|
||||
* }}
|
||||
*/
|
||||
export const getConfig = () => {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
@@ -38,6 +84,11 @@ export const getConfig = () => {
|
||||
return cachedConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a raw value from `process.env`.
|
||||
* @param {string} key - Environment variable name.
|
||||
* @returns {string | undefined} The value or `undefined` if not set.
|
||||
*/
|
||||
function getFromEnv(key) {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) {
|
||||
@@ -46,9 +97,22 @@ function getFromEnv(key) {
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the structured configuration object from environment variables.
|
||||
* Validates presence and basic type parsing, accumulating missing keys
|
||||
* and throwing an error if any are invalid.
|
||||
*
|
||||
* @returns {ReturnType<typeof getConfig>}
|
||||
* @throws {Error} If required environment variables are missing or invalid.
|
||||
*/
|
||||
function create() {
|
||||
let errors = [];
|
||||
|
||||
/**
|
||||
* Reads a non-empty string env var; records missing/empty keys.
|
||||
* @param {string} key
|
||||
* @returns {string}
|
||||
*/
|
||||
function readString(key) {
|
||||
const val = getFromEnv(key);
|
||||
if (val === undefined || val.trim() === "") {
|
||||
@@ -57,6 +121,11 @@ function create() {
|
||||
return val ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a required integer env var; records missing keys.
|
||||
* @param {string} key
|
||||
* @returns {number}
|
||||
*/
|
||||
function readInt(key) {
|
||||
const val = getFromEnv(key);
|
||||
if (val === undefined) {
|
||||
@@ -66,11 +135,18 @@ function create() {
|
||||
return parseInt(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an optional integer env var with a default fallback.
|
||||
* @param {string} key
|
||||
* @param {number} defaultValue
|
||||
* @returns {number}
|
||||
*/
|
||||
function readIntOptional(key, defaultValue) {
|
||||
const value = getFromEnv(key);
|
||||
return value === undefined ? defaultValue : parseInt(value);
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof getConfig>} */
|
||||
const config = {
|
||||
url: "http://localhost:3000",
|
||||
apiUrl: "http://localhost:3000/api",
|
||||
|
||||
+82
-15
@@ -1,13 +1,37 @@
|
||||
import { Pool } from "pg";
|
||||
import { getConfig } from "./config";
|
||||
import { isTest } from "./config";
|
||||
import { logger } from "./logging";
|
||||
|
||||
/**
|
||||
* Database utilities for managing a shared PostgreSQL connection pool.
|
||||
*
|
||||
* This module centralizes pool creation, simple queries, and transactional
|
||||
* execution. In test environments (`isTest`), helpers like `set` and `reset`
|
||||
* allow substituting and tearing down the pool safely.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds a PostgreSQL connection URL from a configuration object.
|
||||
*
|
||||
* @param {{ user: string, password: string, host: string, port: number|string, database: string }} config -
|
||||
* Basic connection fields used to construct the URL.
|
||||
* @returns {string} The postgres connection URL (e.g., `postgres://user:pass@host:port/db`).
|
||||
*/
|
||||
export function dbURL(config) {
|
||||
return `postgres://${config.user}:${config.password}@${config.host}:${config.port}/${config.database}`;
|
||||
}
|
||||
import { logger } from "./logging";
|
||||
|
||||
/** @type {import('pg').Pool | undefined} */
|
||||
let _pool;
|
||||
|
||||
/**
|
||||
* Verifies a connection can be obtained from the given pool.
|
||||
* Logs a successful connection (non-test only) and always releases the client.
|
||||
*
|
||||
* @param {import('pg').Pool} pool - A previously constructed Pool instance.
|
||||
* @returns {Promise<import('pg').Pool>} Resolves to the provided pool.
|
||||
*/
|
||||
async function connect(pool) {
|
||||
let client;
|
||||
try {
|
||||
@@ -33,6 +57,12 @@ async function connect(pool) {
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily creates and returns the shared connection pool.
|
||||
*
|
||||
* Configuration is loaded via `getConfig().db` on first access.
|
||||
* @returns {Promise<import('pg').Pool>} Resolves to the shared Pool.
|
||||
*/
|
||||
async function get() {
|
||||
if (!_pool) {
|
||||
const config = getConfig();
|
||||
@@ -42,21 +72,27 @@ async function get() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a database query using the connection pool.
|
||||
* Executes a database query using the shared pool.
|
||||
*
|
||||
* @param {string} text - The SQL query text to be executed.
|
||||
* @param {Array} [params] - An optional array of parameters to be used in the query.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the result of the query.
|
||||
* @param {string} text - SQL query text to be executed.
|
||||
* @param {Array<any>} [params] - Optional positional parameters for the query.
|
||||
* @returns {Promise<import('pg').QueryResult>} Resolves to the query result.
|
||||
*/
|
||||
async function pool(text, params) {
|
||||
return (await get()).query(text, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a database transaction using the connection pool.
|
||||
* Executes a database transaction using the shared pool.
|
||||
*
|
||||
* @param {Function} fn - A function that takes a client as an argument and returns a promise.
|
||||
* @returns {Promise<Object>} - A promise that resolves to the result of the transaction.
|
||||
* The provided function receives a `PoolClient` which must be used
|
||||
* for all queries in the transaction scope.
|
||||
*
|
||||
* @template T
|
||||
* @param {(client: import('pg').PoolClient) => Promise<T>} fn -
|
||||
* Function performing transactional work with the provided client.
|
||||
* @returns {Promise<T>} Resolves to the function's result after COMMIT.
|
||||
* @throws Will rethrow the original error after ROLLBACK on failure.
|
||||
*/
|
||||
async function transaction(fn) {
|
||||
const db = await get();
|
||||
@@ -74,17 +110,28 @@ async function transaction(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: Replace the shared pool with a new one created from `config`.
|
||||
* Ensures a connection can be obtained before swapping the pool.
|
||||
*
|
||||
* @param {import('pg').PoolConfig} config - Configuration for the new pool.
|
||||
* @returns {Promise<void>} Resolves when the pool has been replaced.
|
||||
* @throws If called outside test environment.
|
||||
*/
|
||||
async function set(config) {
|
||||
if (!isTest) {
|
||||
throw new Error(
|
||||
"Cannot set database in non-test environment, was " + environment
|
||||
);
|
||||
throw new Error("Cannot set database in non-test environment");
|
||||
}
|
||||
const newPool = new Pool(config);
|
||||
await connect(newPool);
|
||||
_pool = newPool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully ends the current shared pool, if present.
|
||||
* Safe to call multiple times.
|
||||
* @returns {Promise<void>} Resolves when the pool is shut down.
|
||||
*/
|
||||
async function destroy() {
|
||||
if (_pool !== undefined) {
|
||||
if (!_pool.ended && !_pool.ending) {
|
||||
@@ -95,21 +142,41 @@ async function destroy() {
|
||||
_pool = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the shared pool can provide a client.
|
||||
* Useful as a readiness check; returns the pool itself.
|
||||
*
|
||||
* @returns {Promise<import('pg').Pool>} Resolves to the shared pool.
|
||||
*/
|
||||
async function getClient() {
|
||||
return connect(await get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: Tear down the shared pool and clear internal state.
|
||||
*
|
||||
* @returns {Promise<void>} Resolves when the pool has been reset.
|
||||
* @throws If called outside test environment.
|
||||
*/
|
||||
async function reset() {
|
||||
if (!isTest)
|
||||
throw new Error(
|
||||
"Cannot reset database in non-test environment, was " + environment
|
||||
);
|
||||
if (!isTest) throw new Error("Cannot reset database in non-test environment");
|
||||
if (_pool !== undefined) {
|
||||
await destroy();
|
||||
}
|
||||
_pool = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Facade for database interactions.
|
||||
*
|
||||
* - `get`: Lazily get the shared pool
|
||||
* - `pool`: Run a single query via the shared pool
|
||||
* - `transaction`: Run a function within a transaction
|
||||
* - `set` (test): Replace the pool
|
||||
* - `destroy`: End the pool
|
||||
* - `getClient`: Ensure pool connectivity (returns the pool)
|
||||
* - `reset` (test): Destroy and clear the pool
|
||||
*/
|
||||
export const DB = {
|
||||
get,
|
||||
pool,
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { DB } from "@/backend/db";
|
||||
|
||||
/**
|
||||
* Services for reading and writing HTML page content stored in the database.
|
||||
*
|
||||
* Pages are grouped by `page_group` (e.g., type/category) and identified by a `slug`.
|
||||
* The schema expects columns: `title`, `content`, `page_order`, `slug`, `page_group`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches a single HTML page's data by group and slug.
|
||||
*
|
||||
* @param {string} type - The `page_group` to filter by.
|
||||
* @param {string} slug - The unique slug within the group.
|
||||
* @returns {Promise<{title:string, content:string, page_order:number, slug:string, page_group:string}>}
|
||||
* Resolves with the page row when found.
|
||||
* @throws {Error} If the page does not exist.
|
||||
*/
|
||||
export async function getPageData(type, slug) {
|
||||
const result = await DB.pool(
|
||||
"SELECT title, content, page_order, slug, page_group FROM html_pages WHERE page_group = $1 AND slug = $2",
|
||||
@@ -12,6 +28,24 @@ export async function getPageData(type, slug) {
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an HTML page's core fields (`title`, `slug`, `content`).
|
||||
*
|
||||
* Matching is done by the provided `type` (maps to `page_group`) and the
|
||||
* current `slug` value. If the row is found, its fields are updated and the
|
||||
* operation returns `true`. If no row matches (e.g., wrong `type` or `slug`),
|
||||
* no changes are made and the function returns `false`.
|
||||
*
|
||||
* Important:
|
||||
* - This function can change the `slug`. Subsequent lookups should use the
|
||||
* new slug.
|
||||
* - It does not modify `page_order` or `page_group`.
|
||||
*
|
||||
* @param {string} type - The `page_group` of the page to update.
|
||||
* @param {string} slug - The current slug of the page to update.
|
||||
* @param {{title:string, slug:string, content:string}} newData - New values to set.
|
||||
* @returns {Promise<boolean>} Resolves `true` when exactly one row was updated; otherwise `false`.
|
||||
*/
|
||||
export async function setPageData(type, slug, newData) {
|
||||
const result = await DB.pool(
|
||||
"UPDATE html_pages SET title = $1, slug = $2, content = $3 WHERE page_group = $4 AND slug = $5",
|
||||
@@ -20,6 +54,12 @@ export async function setPageData(type, slug, newData) {
|
||||
return result.rowCount == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists pages (title and slug) within a group, ordered by `page_order` ascending.
|
||||
*
|
||||
* @param {string} type - The `page_group` to list.
|
||||
* @returns {Promise<Array<{title:string, slug:string}>>} Resolves with page summaries.
|
||||
*/
|
||||
export async function getPageList(type) {
|
||||
const result = await DB.pool(
|
||||
"SELECT title, slug FROM html_pages WHERE page_group = $1 ORDER BY page_order ASC",
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { checkSession } from "@/backend/auth/session";
|
||||
|
||||
/**
|
||||
* Wraps a Next.js Route Handler with authentication/authorization checks.
|
||||
*
|
||||
* Usage examples appear in `src/backend/middleware/README.md` and various
|
||||
* API routes (e.g., `src/app/api/.../route.js`).
|
||||
*
|
||||
* Behavior:
|
||||
* - Reads the current user via `checkSession(request)`.
|
||||
* - If `requireAuth` is true and no user, responds `401 Unauthorized`.
|
||||
* - If `requireAdmin` is true and user is not admin, responds `401 Unauthorized`.
|
||||
* - Otherwise attaches `request.user` and invokes `callback(request, ...args)`.
|
||||
*
|
||||
* @param {(request: Request, ...args: any[]) => Promise<Response>|Response} callback -
|
||||
* The original handler to execute when checks pass.
|
||||
* @param {{ requireAuth?: boolean, requireAdmin?: boolean }} [options] -
|
||||
* Control whether authentication and/or admin rights are required.
|
||||
* @returns {(request: Request, ...args: any[]) => Promise<Response>} A wrapped handler.
|
||||
*/
|
||||
export function withAuth(callback, options = {}) {
|
||||
const { requireAuth = true, requireAdmin = false } = options;
|
||||
return async (request, ...args) => {
|
||||
|
||||
@@ -2,6 +2,24 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { logger } from "../logging";
|
||||
|
||||
/**
|
||||
* Wraps a Next.js API route handler with Zod-based input validation.
|
||||
*
|
||||
* The wrapper reads the JSON body, validates it against the provided `schema`,
|
||||
* and forwards the request to `callback` with a new `NextRequest` whose body is
|
||||
* replaced by the validated payload. When validation fails, it returns a
|
||||
* `422 Unprocessable Entity` response including a human-readable error and the
|
||||
* Zod error details. Unexpected errors return `500`.
|
||||
*
|
||||
* Common usage:
|
||||
* - Compose with `withAuth` to enforce auth, then validate input.
|
||||
* - In the callback, call `await req.json()` to read the validated payload.
|
||||
*
|
||||
* @param {z.ZodTypeAny} schema - Zod schema describing expected request body.
|
||||
* @param {(req: NextRequest, res?: Response) => Promise<Response>|Response} callback -
|
||||
* Handler invoked with a request whose body is the validated payload.
|
||||
* @returns {(req: NextRequest, res?: Response) => Promise<Response>} Wrapped handler.
|
||||
*/
|
||||
export const withInputValidation = (schema, callback) => {
|
||||
return async (req, res) => {
|
||||
const request = await req.json();
|
||||
@@ -48,6 +66,14 @@ export const withInputValidation = (schema, callback) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces human-readable and debug-friendly strings from a Zod error.
|
||||
*
|
||||
* @param {unknown} error - The thrown error, expected to be `z.ZodError`.
|
||||
* @returns {{ humanReadableError: string, debugError: string[] } | string}
|
||||
* For Zod errors, returns a summary string and a list of field-specific messages;
|
||||
* otherwise returns `error.toString()`.
|
||||
*/
|
||||
const humanReadableZodError = (error) => {
|
||||
if (!(error instanceof z.ZodError)) {
|
||||
return error.toString();
|
||||
|
||||
Reference in New Issue
Block a user