initial commit, boilerplate stuff

This commit is contained in:
khannurien
2026-03-15 17:15:46 +00:00
commit 6207a7549f
52 changed files with 4400 additions and 0 deletions

3
api/.env.example Normal file
View File

@@ -0,0 +1,3 @@
PROTOCOL=http
HOSTNAME=localhost
PORT=8000

4
api/config.ts Normal file
View File

@@ -0,0 +1,4 @@
export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http";
export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost";
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;

56
api/lib/jwt.ts Normal file
View File

@@ -0,0 +1,56 @@
import { randomBytes, scrypt } from "node:crypto";
import { jwtVerify, SignJWT } from "@panva/jose";
import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts";
const JWT_SECRET = "tp-M1-SOR-2026";
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
export async function createJWT(
payload: Omit<AuthPayload, "exp">,
): Promise<string> {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.sign(JWT_KEY);
}
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_KEY);
if (!isAuthPayload(payload)) {
return null;
}
return payload;
} catch (err) {
console.error("JWT verification failed:", err);
return null;
}
}
export function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
return new Promise((resolve, reject) => {
scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
else resolve(`${derivedKey.toString("hex")}.${salt}`);
});
});
}
export function verifyPassword(
password: string,
storedHash: string,
): Promise<boolean> {
const [hash, salt] = storedHash.split(".");
return new Promise((resolve, reject) => {
scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
else resolve(hash === derivedKey.toString("hex"));
});
});
}

16
api/lib/static.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Context, Next } from "@oak/oak";
export default function routeStaticFilesFrom(staticPaths: string[]) {
return async (context: Context<Record<string, object>>, next: Next) => {
for (const path of staticPaths) {
try {
await context.send({ root: path, index: "index.html" });
return;
} catch {
continue;
}
}
await next();
};
}

42
api/main.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Application } from "@oak/oak";
import { oakCors } from "@tajpouria/cors";
import dumpsRouter from "./routes/dumps.ts";
import usersRouter from "./routes/users.ts";
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts";
import routeStaticFilesFrom from "./lib/static.ts";
const app = new Application();
app.use(errorMiddleware);
app.use(oakCors());
app.use(
dumpsRouter.routes(),
dumpsRouter.allowedMethods(),
);
app.use(
usersRouter.routes(),
usersRouter.allowedMethods(),
);
app.use(routeStaticFilesFrom([
`${Deno.cwd()}/dist`,
`${Deno.cwd()}/public`,
]));
app.addEventListener(
"listen",
() => console.log(`Server listening on ${BASE_URL}`),
);
app.addEventListener(
"error",
(e) => console.log(`Uncaught error: ${e.message}`),
);
if (import.meta.main) {
await app.listen({ hostname: HOSTNAME, port: PORT });
}
export { app };

50
api/middleware/auth.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Context, Next, State } from "@oak/oak";
import { verifyJWT } from "../lib/jwt.ts";
import {
APIErrorCode,
APIException,
type AuthPayload,
} from "../model/interfaces.ts";
export interface AuthContext extends Context {
state: AuthState;
}
export interface AuthState extends State {
user: AuthPayload;
}
export async function authMiddleware(ctx: AuthContext, next: Next) {
const authHeader = ctx.request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Missing or invalid token",
);
}
const token = authHeader.substring(7);
const payload = await verifyJWT(token);
if (!payload) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
}
ctx.state.user = payload;
await next();
}
export async function adminOnlyMiddleware(ctx: AuthContext, next: Next) {
if (!ctx.state.user?.isAdmin) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Admin access required",
);
}
await next();
}

37
api/middleware/error.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Context, Next } from "@oak/oak";
import { APIErrorCode, APIException, APIFailure } from "../model/interfaces.ts";
export async function errorMiddleware(ctx: Context, next: Next) {
try {
await next();
} catch (err) {
if (err instanceof APIException) {
const responseBody: APIFailure = {
success: false,
error: {
code: err.code,
message: err.message,
},
};
ctx.response.status = err.status;
ctx.response.body = responseBody;
console.log(responseBody);
} else {
console.error(err);
const responseBody: APIFailure = {
success: false,
error: {
code: APIErrorCode.SERVER_ERROR,
message: "Unexpected server error",
},
};
ctx.response.status = 500;
ctx.response.body = responseBody;
}
}
}

97
api/model/db.ts Normal file
View File

@@ -0,0 +1,97 @@
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import { Dump, type User } from "./interfaces.ts";
export const db = new DatabaseSync("api/sql/gerbeur.db");
/**
* Database Row Types
*/
export interface DumpRow {
id: string;
title: string;
description: string | null;
user_id: string;
created_at: string;
[key: string]: SQLOutputValue; // Index signature
}
export interface UserRow {
id: string;
username: string;
password_hash: string;
is_admin: number;
created_at: string;
[key: string]: SQLOutputValue; // Index signature
}
/**
* Type Guards
*/
export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"title" in obj && typeof obj.title === "string" &&
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
"user_id" in obj && typeof obj.user_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string";
}
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"username" in obj && typeof obj.username === "string" &&
"password_hash" in obj && typeof obj.password_hash === "string" &&
"is_admin" in obj && typeof obj.is_admin === "number" &&
"created_at" in obj && typeof obj.created_at === "string";
}
/**
* Conversion Helpers
*/
export function dumpRowToApi(
row: DumpRow,
): Dump {
return {
id: row.id,
title: row.title,
description: row.description ?? undefined,
userId: row.user_id,
createdAt: new Date(row.created_at),
};
}
export function dumpApiToRow(dump: Dump): DumpRow {
return {
id: dump.id,
title: dump.title,
description: dump.description ?? null,
user_id: dump.userId,
created_at: dump.createdAt.toISOString(),
};
}
export function userRowToApi(row: UserRow): User {
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
isAdmin: Boolean(row.is_admin),
createdAt: new Date(row.created_at),
};
}
export function userApiToRow(user: User): UserRow {
return {
id: user.id,
username: user.username,
password_hash: user.passwordHash,
is_admin: user.isAdmin ? 1 : 0,
created_at: user.createdAt.toISOString(),
};
}

154
api/model/interfaces.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Backend
*/
export interface Dump {
id: string;
title: string;
description?: string;
userId: string;
createdAt: Date;
}
/**
* Authentication
*/
export interface User {
id: string;
username: string;
passwordHash: string;
isAdmin: boolean;
createdAt: Date;
}
export interface LoginUserRequest {
username: string;
password: string;
}
export interface RegisterUserRequest {
username: string;
password: string;
}
export interface UpdateUserRequest {
username?: string;
password?: string;
isAdmin?: boolean;
}
export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
return !!obj && typeof obj === "object" &&
"username" in obj && typeof obj.username === "string" &&
"password" in obj && typeof obj.password === "string";
}
export function isRegisterUserRequest(
obj: unknown,
): obj is RegisterUserRequest {
return !!obj && typeof obj === "object" &&
"username" in obj && typeof obj.username === "string" &&
"password" in obj && typeof obj.password === "string";
}
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
return !!obj && typeof obj === "object" &&
(!("username" in obj) || typeof obj.username === "string") &&
(!("password" in obj) || typeof obj.password === "string") &&
(!("isAdmin" in obj) || typeof obj.isAdmin === "boolean");
}
export interface AuthResponse {
token: string;
user: User;
}
export interface AuthPayload {
userId: string;
username: string;
isAdmin: boolean;
exp: number;
}
export function isAuthPayload(obj: unknown): obj is AuthPayload {
return !!obj &&
typeof obj === "object" &&
"userId" in obj && typeof obj.userId === "string" &&
"username" in obj && typeof obj.username === "string" &&
"isAdmin" in obj && typeof obj.isAdmin === "boolean" &&
"exp" in obj && typeof obj.exp === "number";
}
/**
* API
*/
export enum APIErrorCode {
BAD_REQUEST = "BAD_REQUEST",
NOT_FOUND = "NOT_FOUND",
SERVER_ERROR = "SERVER_ERROR",
TIMEOUT = "TIMEOUT",
UNAUTHORIZED = "UNAUTHORIZED",
VALIDATION_ERROR = "VALIDATION_ERROR",
}
export interface APIError {
code: APIErrorCode;
message: string;
}
export interface APISuccess<T> {
success: true;
data: T;
error?: never;
}
export interface APIFailure {
success: false;
data?: never;
error: APIError;
}
export type APIResponse<T> = APISuccess<T> | APIFailure;
export class APIException extends Error {
readonly code: APIErrorCode;
readonly status: number;
constructor(code: APIErrorCode, status: number, message: string) {
super(message);
this.code = code;
this.status = status;
}
}
/**
* Request DTOs
*/
export interface CreateDumpRequest {
title: string;
description?: string;
}
export function isCreateDumpRequest(obj: unknown): obj is CreateDumpRequest {
return !!obj &&
typeof obj === "object" &&
"title" in obj && typeof obj.title === "string" &&
(!("description" in obj) ||
(typeof obj.description === "string" || obj.description === null));
}
export interface UpdateDumpRequest {
title?: string;
description?: string;
}
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
return !!obj &&
typeof obj === "object" &&
(!("title" in obj) || typeof obj.title === "string") &&
(!("description" in obj) ||
(typeof obj.description === "string" || obj.description === null));
}

130
api/routes/dumps.ts Normal file
View File

@@ -0,0 +1,130 @@
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Dump,
isCreateDumpRequest,
isUpdateDumpRequest,
} from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts";
import {
createDump,
deleteDump,
getDump,
listDumps,
updateDump,
} from "../services/dump-service.ts";
const router = new Router({ prefix: "/api/dumps" });
router.post(
"/",
authMiddleware,
async (ctx) => {
const createDumpRequest = await ctx.request.body.json();
if (!isCreateDumpRequest(createDumpRequest)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid dump data",
);
}
const userId = ctx.state.user.userId;
const dump = createDump(createDumpRequest, userId);
const responseBody: APIResponse<Dump> = {
success: true,
data: dump,
};
ctx.response.status = 201;
ctx.response.body = responseBody;
},
);
router.get("/:dumpId", (ctx) => {
const dumpId = ctx.params.dumpId;
const dump = getDump(dumpId);
const responseBody: APIResponse<Dump> = {
success: true,
data: dump,
};
ctx.response.body = responseBody;
});
router.get("/", (ctx) => {
const dumps = listDumps();
const responseBody: APIResponse<Dump[]> = {
success: true,
data: dumps,
};
ctx.response.body = responseBody;
});
router.put("/:dumpId", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;
const updateDumpRequest = await ctx.request.body.json();
if (!isUpdateDumpRequest(updateDumpRequest)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
422,
"Erroneous user input",
);
}
const dump = getDump(dumpId);
if (userId !== dump.userId) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authorized to update dump",
);
}
const updatedDump = updateDump(dumpId, updateDumpRequest);
const responseBody: APIResponse<Dump> = {
success: true,
data: updatedDump,
};
ctx.response.body = responseBody;
});
router.delete("/:dumpId", authMiddleware, (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;
const dump = getDump(dumpId);
if (userId !== dump.userId) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authorized to update dump",
);
}
deleteDump(dumpId);
const responseBody: APIResponse<null> = {
success: true,
data: null,
};
ctx.response.body = responseBody;
});
export default router;

132
api/routes/users.ts Normal file
View File

@@ -0,0 +1,132 @@
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
isLoginUserRequest,
isRegisterUserRequest,
} from "../model/interfaces.ts";
import { createJWT, verifyPassword } from "../lib/jwt.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import {
createUser,
getUserById,
getUserByUsername,
} from "../services/user-service.ts";
// Users router
const router = new Router({ prefix: "/api/users" });
// Register a new user
router.post("/register", async (ctx) => {
try {
const body = await ctx.request.body.json();
if (!isRegisterUserRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
const user = await createUser(body);
const token = await createJWT({
userId: user.id,
username: user.username,
isAdmin: user.isAdmin,
});
ctx.response.status = 201;
ctx.response.body = {
success: true,
data: {
token,
user,
},
};
} catch (err) {
console.error(err);
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Failed to register user",
);
}
});
// Login
router.post("/login", async (ctx) => {
try {
const body = await ctx.request.body.json();
if (!isLoginUserRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
const user = getUserByUsername(body.username);
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Invalid username or password",
);
}
const token = await createJWT({
userId: user.id,
username: user.username,
isAdmin: user.isAdmin,
});
ctx.response.body = {
success: true,
data: {
token,
user,
},
};
} catch (err) {
console.error(err);
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login");
}
});
// Get current user profile
router.get("/me", authMiddleware, (ctx: AuthContext) => {
try {
if (!ctx.state.user) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authenticated",
);
}
const user = getUserById(ctx.state.user.userId);
ctx.response.body = {
success: true,
data: user,
};
} catch (err) {
console.error(err);
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Failed to fetch user profile",
);
}
});
export default router;

View File

@@ -0,0 +1,99 @@
import {
APIErrorCode,
APIException,
type CreateDumpRequest,
type Dump,
type UpdateDumpRequest,
} from "../model/interfaces.ts";
import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts";
export function createDump(
request: CreateDumpRequest,
userId: string,
): Dump {
const dumpId = crypto.randomUUID();
const createdAt = new Date();
db.prepare(
`INSERT INTO dumps (id, title, description, user_id, created_at)
VALUES (?, ?, ?, ?, ?);`,
).run(
dumpId,
request.title,
request.description ?? null,
userId,
createdAt.toISOString(),
);
return {
id: dumpId,
title: request.title,
description: request.description ?? undefined,
userId: userId,
createdAt,
};
}
export function getDump(dumpId: string): Dump {
const dumpRow = db.prepare(
`SELECT id, title, description, user_id, created_at
FROM dumps WHERE id = ?;`,
).get(dumpId);
if (!dumpRow || !isDumpRow(dumpRow)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
return dumpRowToApi(dumpRow);
}
export function listDumps(): Dump[] {
const dumpRows = db.prepare(
`SELECT id, title, description, user_id, created_at FROM dumps;`,
).all();
if (!dumpRows || !dumpRows.every(isDumpRow)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No dump found");
}
const dumps: Dump[] = dumpRows.map(dumpRowToApi);
return dumps;
}
export function updateDump(
dumpId: string,
request: UpdateDumpRequest,
): Dump {
const dump = getDump(dumpId);
const updatedDump = {
...dump,
...request,
};
const updatedDumpRow = dumpApiToRow(updatedDump);
const dumpResult = db.prepare(
`UPDATE dumps SET title = ?, description = ? WHERE id = ?;`,
).run(
updatedDumpRow.title,
updatedDumpRow.description,
updatedDumpRow.id,
);
if (dumpResult.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
return updatedDump;
}
export function deleteDump(dumpId: string): void {
const result = db.prepare(
`DELETE FROM dumps WHERE id = ?;`,
).run(dumpId);
if (result.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
}

View File

@@ -0,0 +1,130 @@
import {
APIErrorCode,
APIException,
type RegisterUserRequest,
type UpdateUserRequest,
type User,
} from "../model/interfaces.ts";
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
import { hashPassword } from "../lib/jwt.ts";
export async function createUser(
request: RegisterUserRequest,
): Promise<User> {
const userId = crypto.randomUUID();
const createdAt = new Date();
const existingUser = db.prepare(
"SELECT id FROM users WHERE username = ?;",
).get(request.username);
if (existingUser) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Username already exists",
);
}
const passwordHash = await hashPassword(request.password);
db.prepare(
`INSERT INTO users (id, username, password_hash, is_admin, created_at)
VALUES (?, ?, ?, ?, ?);`,
).run(
userId,
request.username,
passwordHash,
0,
createdAt.toISOString(),
);
return {
id: userId,
username: request.username,
passwordHash,
isAdmin: false,
createdAt,
};
}
export function getUserById(userId: string): User {
const userRow = db.prepare(
`SELECT id, username, password_hash, is_admin, created_at
FROM users WHERE id = ?`,
).get(userId);
if (!userRow || !isUserRow(userRow)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
}
return userRowToApi(userRow);
}
export function getUserByUsername(username: string): User {
const userRow = db.prepare(
`SELECT id, username, password_hash, is_admin, created_at
FROM users WHERE username = ?`,
).get(username);
if (!userRow || !isUserRow(userRow)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
}
return userRowToApi(userRow);
}
export function listUsers(): User[] {
const userRows = db.prepare(
`SELECT id, username, password_hash, is_admin, created_at FROM users`,
).all();
if (!userRows || !userRows.every(isUserRow)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No user found");
}
return userRows.map(userRowToApi);
}
export async function updateUser(
userId: string,
request: UpdateUserRequest,
): Promise<User> {
const user = getUserById(userId);
const { password, ...requestFields } = request;
const updatedUser: User = {
...user,
passwordHash: password ? await hashPassword(password) : user.passwordHash,
...requestFields,
};
const updatedUserRow = userApiToRow(updatedUser);
const userResult = db.prepare(
`UPDATE users SET username = ?, password_hash = ?, is_admin = ? WHERE id = ?`,
).run(
updatedUserRow.username,
updatedUserRow.password_hash,
updatedUserRow.is_admin,
updatedUserRow.id,
);
if (userResult.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
return updatedUser;
}
export function deleteUser(userId: string): void {
const result = db.prepare(
`DELETE FROM users WHERE id = ?;`,
).run(userId);
if (result.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
}
}

18
api/sql/schema.sql Normal file
View File

@@ -0,0 +1,18 @@
CREATE TABLE dumps (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
url TEXT,
rich_content TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);