initial commit, boilerplate stuff
This commit is contained in:
3
api/.env.example
Normal file
3
api/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
PROTOCOL=http
|
||||
HOSTNAME=localhost
|
||||
PORT=8000
|
||||
4
api/config.ts
Normal file
4
api/config.ts
Normal 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
56
api/lib/jwt.ts
Normal 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
16
api/lib/static.ts
Normal 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
42
api/main.ts
Normal 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
50
api/middleware/auth.ts
Normal 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
37
api/middleware/error.ts
Normal 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
97
api/model/db.ts
Normal 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
154
api/model/interfaces.ts
Normal 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
130
api/routes/dumps.ts
Normal 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
132
api/routes/users.ts
Normal 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;
|
||||
99
api/services/dump-service.ts
Normal file
99
api/services/dump-service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
130
api/services/user-service.ts
Normal file
130
api/services/user-service.ts
Normal 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
18
api/sql/schema.sql
Normal 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
|
||||
);
|
||||
Reference in New Issue
Block a user