chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)

This commit is contained in:
Git Agent
2026-08-23 09:56:53 +02:00
commit 8f1140127f
79 changed files with 6456 additions and 0 deletions

View File

@ -0,0 +1,43 @@
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
import "@fastify/cookie";
import { FastifyReply } from "fastify";
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
import { CurrentUser, CurrentUserParam } from "./current-user.js";
@Controller("auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("bootstrap")
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
return this.auth.bootstrapAdmin(body);
}
@Post("login")
async login(@Body(new ZodValidationPipe(LoginSchema)) body: LoginDto, @Res({ passthrough: true }) reply: FastifyReply) {
const result = await this.auth.login(body);
reply.setCookie(this.auth.cookieName, result.token, {
httpOnly: true,
sameSite: "lax",
secure: this.auth.cookieSecure,
path: "/",
maxAge: 7 * 24 * 60 * 60
});
return { user: result.user };
}
@Post("logout")
logout(@Res({ passthrough: true }) reply: FastifyReply) {
reply.clearCookie(this.auth.cookieName, { path: "/" });
return { ok: true };
}
@Get("me")
@UseGuards(AuthGuard)
me(@CurrentUserParam() user: CurrentUser) {
return { user };
}
}

View File

@ -0,0 +1,17 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "./auth.service.js";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.cookies?.[this.auth.cookieName];
if (!token) {
throw new UnauthorizedException("Authentication required");
}
request.user = await this.auth.verifySession(token);
return true;
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { AuthController } from "./auth.controller.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
import { RolesGuard } from "./roles.guard.js";
@Module({
imports: [DatabaseModule],
controllers: [AuthController],
providers: [AuthService, AuthGuard, RolesGuard],
exports: [AuthService, AuthGuard, RolesGuard]
})
export class AuthModule {}

View File

@ -0,0 +1,128 @@
import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { SignJWT, jwtVerify } from "jose";
import argon2 from "argon2";
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { users } from "../database/schema.js";
import { CurrentUser } from "./current-user.js";
@Injectable()
export class AuthService {
readonly cookieName: string;
private readonly secret: Uint8Array;
constructor(private readonly database: DatabaseService) {
this.cookieName = database.config.cookieName;
this.secret = new TextEncoder().encode(database.config.jwtSecret);
}
get cookieSecure(): boolean {
return this.database.config.cookieSecure;
}
async bootstrapAdmin(input: BootstrapAdminDto) {
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
if (existing) {
throw new ConflictException("Bootstrap already completed");
}
return this.createUser({ ...input, role: "admin" });
}
async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> {
const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get();
if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
throw new UnauthorizedException("Invalid credentials");
}
const sessionUser = { id: user.id, email: user.email, role: user.role };
return { token: await this.signSession(sessionUser), user: sessionUser };
}
async verifySession(token: string): Promise<CurrentUser> {
try {
const { payload } = await jwtVerify(token, this.secret);
const id = Number(payload.sub);
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
if (!user) {
throw new UnauthorizedException("Invalid session");
}
return { id: user.id, email: user.email, role: user.role };
} catch {
throw new UnauthorizedException("Invalid session");
}
}
listUsers() {
return this.database.db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.from(users)
.all();
}
async createUser(input: CreateUserDto) {
const now = this.database.now();
const passwordHash = await argon2.hash(input.password);
try {
const user = this.database.db
.insert(users)
.values({
email: input.email.toLowerCase(),
name: input.name ?? null,
passwordHash,
role: input.role,
createdAt: now,
updatedAt: now
})
.returning({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.get();
return user;
} catch (error) {
throw new ConflictException("Email already exists");
}
}
async updateUser(id: number, input: UpdateUserDto) {
const values: Partial<typeof users.$inferInsert> = { updatedAt: this.database.now() };
if (input.email) values.email = input.email.toLowerCase();
if (input.name !== undefined) values.name = input.name;
if (input.role) values.role = input.role;
if (input.password) values.passwordHash = await argon2.hash(input.password);
return this.database.db
.update(users)
.set(values)
.where(eq(users.id, id))
.returning({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.get();
}
deleteUser(id: number): void {
this.database.db.delete(users).where(eq(users.id, id)).run();
}
private async signSession(user: CurrentUser): Promise<string> {
return new SignJWT({ email: user.email, role: user.role })
.setProtectedHeader({ alg: "HS256" })
.setSubject(String(user.id))
.setIssuedAt()
.setExpirationTime("7d")
.sign(this.secret);
}
}

View File

@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export type CurrentUser = {
id: number;
email: string;
role: "admin" | "user";
};
export const CurrentUserParam = createParamDecorator((_data: unknown, ctx: ExecutionContext): CurrentUser => {
const request = ctx.switchToHttp().getRequest();
return request.user;
});

View File

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const ROLES_KEY = "roles";
export const Roles = (...roles: Array<"admin" | "user">) => SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "./roles.decorator.js";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<Array<"admin" | "user">>(ROLES_KEY, [
context.getHandler(),
context.getClass()
]);
if (!roles?.length) {
return true;
}
const user = context.switchToHttp().getRequest().user;
if (user && roles.includes(user.role)) {
return true;
}
throw new ForbiddenException("Insufficient role");
}
}