core: adding a linter to the project

This update focuses on improving **code consistency, readability, and maintainability** across the project.  
No functional changes have been introduced.

---

##  New
- Added a dedicated **`eslint.config.js`** configuration file.  
  - Integrates recommended JavaScript and TypeScript ESLint rules.  
  - Enforces consistent use of single quotes, semicolons, spacing, and other style standards.

---

## 🔧 Improvements
- Applied unified formatting to the following directories and files:
  - `src/@shared/src/auth/`  
  - `src/@shared/src/database/`  
  - `src/@shared/src/utils/`  
  - `src/auth/extra/login_demo.js`
- Replaced `let` with `const` where applicable to improve scoping and code safety.
- Standardized error handling and minor syntax refinements for greater clarity.

---

## 📌 Impact
- No changes to application logic or functionality.  
- The codebase is now **easier to read, maintain, and extend**, reducing the risk of style-related issues in the future.
This commit is contained in:
Raphaël 2025-10-03 14:26:07 +02:00 committed by GitHub
commit ff0e218803
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2081 additions and 455 deletions

40
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Build & Linter
on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
permissions:
contents: read
jobs:
build:
name: Build & Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Install dependencies with pnpm
working-directory: ./src
run: pnpm install
- name: Check linting
working-directory: ./src
run: pnpm exec eslint . --max-warnings=0
- name: Build
working-directory: ./src
run: pnpm run build

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env sh
pnpm --prefix=./src/ lint-staged

View file

@ -19,6 +19,24 @@
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
system: let system: let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
tmux-setup = pkgs.writeShellScriptBin "tmux-setup" ''
#!/usr/bin/env sh
SESSION="transcendance"
DIR=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
if ! tmux has-session -t $SESSION 2>/dev/null; then
tmux new-session -d -s $SESSION -c "$DIR" -n dev
tmux send-keys -t $SESSION:0 'vim' C-m
tmux split-window -h -p 30 -t $SESSION:0 -c "$DIR"
tmux send-keys -t $SESSION:0.1 'exec zsh' C-m
tmux split-window -v -p 30 -t $SESSION:0.1 -c "$DIR"
tmux send-keys -t $SESSION:0.2 'watch -n0.5 npx --prefix=./src/ eslint .' C-m
tmux new-window -t $SESSION:1 -n git -c "$DIR"
tmux send-keys -t $SESSION:1 'lazygit' C-m
fi
tmux select-window -t $SESSION:0
tmux select-pane -t $SESSION:0.0
tmux attach -t $SESSION
'';
in { in {
devShell = pkgs.mkShellNoCC { devShell = pkgs.mkShellNoCC {
packages = with pkgs; [ packages = with pkgs; [
@ -31,6 +49,7 @@
dbmlSQLite.packages.${system}.default dbmlSQLite.packages.${system}.default
sqlite-interactive sqlite-interactive
clang clang
tmux-setup
]; ];
shellHook = '' shellHook = ''
export PODMAN_COMPOSE_WARNING_LOGS="false"; export PODMAN_COMPOSE_WARNING_LOGS="false";

2
src/.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env sh
pnpm lint-staged

View file

@ -1,23 +1,23 @@
import OTP from "otp"; import OTP from 'otp';
import cookie from "@fastify/cookie"; import cookie from '@fastify/cookie';
import fastifyJwt from "@fastify/jwt"; import fastifyJwt from '@fastify/jwt';
import fp from "fastify-plugin"; import fp from 'fastify-plugin';
import { FastifyPluginAsync, preValidationAsyncHookHandler } from "fastify"; import { FastifyPluginAsync, preValidationAsyncHookHandler } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { UserId } from "@shared/database/mixin/user"; import { UserId } from '@shared/database/mixin/user';
import { useDatabase } from "@shared/database"; import { useDatabase } from '@shared/database';
import { isNullish, makeResponse } from "@shared/utils"; import { isNullish, makeResponse } from '@shared/utils';
const kRouteAuthDone = Symbol("shared-route-auth-done"); const kRouteAuthDone = Symbol('shared-route-auth-done');
type AuthedUser = { type AuthedUser = {
id: UserId; id: UserId;
name: string; name: string;
}; };
declare module "fastify" { declare module 'fastify' {
export interface FastifyInstance { export interface FastifyInstance {
signJwt: (kind: "auth" | "otp", who: string) => string; signJwt: (kind: 'auth' | 'otp', who: string) => string;
[s: symbol]: boolean; [s: symbol]: boolean;
} }
export interface FastifyRequest { export interface FastifyRequest {
@ -26,17 +26,22 @@ declare module "fastify" {
export interface FastifyContextConfig { export interface FastifyContextConfig {
requireAuth?: boolean; requireAuth?: boolean;
} }
export interface RouteOptions {
[kRouteAuthDone]: boolean;
}
} }
export const Otp = OTP; export const Otp = OTP;
let jwtAdded = false; let jwtAdded = false;
export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => { export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
void _opts;
if (jwtAdded) return; if (jwtAdded) return;
jwtAdded = true; jwtAdded = true;
let env = process.env.JWT_SECRET; const env = process.env.JWT_SECRET;
if (isNullish(env)) throw "JWT_SECRET is not defined"; if (isNullish(env)) throw 'JWT_SECRET is not defined';
if (!fastify.hasDecorator("signJwt")) { if (!fastify.hasDecorator('signJwt')) {
void fastify.decorate("signJwt", (kind, who) => void fastify.decorate('signJwt', (kind, who) =>
fastify.jwt.sign({ kind, who, createdAt: Date.now() }), fastify.jwt.sign({ kind, who, createdAt: Date.now() }),
); );
void fastify.register(fastifyJwt, { void fastify.register(fastifyJwt, {
@ -48,16 +53,16 @@ export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
export const JwtType = Type.Object({ export const JwtType = Type.Object({
kind: Type.Union([ kind: Type.Union([
Type.Const("otp", { Type.Const('otp', {
description: "the token is only valid for otp call", description: 'the token is only valid for otp call',
}), }),
Type.Const("auth", { Type.Const('auth', {
description: "the token is valid for authentication", description: 'the token is valid for authentication',
}), }),
]), ]),
who: Type.String({ description: "the login of the user" }), who: Type.String({ description: 'the login of the user' }),
createdAt: Type.Integer({ createdAt: Type.Integer({
description: "Unix timestamp of when the token as been created at", description: 'Unix timestamp of when the token as been created at',
}), }),
}); });
@ -65,56 +70,63 @@ export type JwtType = Static<typeof JwtType>;
let authAdded = false; let authAdded = false;
export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => { export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
if (authAdded) return void console.log("skipping"); void _opts;
if (authAdded) return void console.log('skipping');
authAdded = true; authAdded = true;
await fastify.register(useDatabase as any, {}); await fastify.register(useDatabase as FastifyPluginAsync, {});
await fastify.register(jwtPlugin as any, {}); await fastify.register(jwtPlugin as FastifyPluginAsync, {});
await fastify.register(cookie); await fastify.register(cookie);
if (!fastify.hasRequestDecorator("authUser")) if (!fastify.hasRequestDecorator('authUser')) { fastify.decorateRequest('authUser', undefined); }
fastify.decorateRequest("authUser", undefined); fastify.addHook('onRoute', (routeOpts) => {
fastify.addHook("onRoute", (routeOpts) => {
if ( if (
routeOpts.config?.requireAuth && routeOpts.config?.requireAuth &&
!(routeOpts as any)[kRouteAuthDone] !routeOpts[kRouteAuthDone]
) { ) {
let f: preValidationAsyncHookHandler = async function(req, res) { const f: preValidationAsyncHookHandler = async function(req, res) {
try { try {
if (isNullish(req.cookies.token)) if (isNullish(req.cookies.token)) {
return res return res
.clearCookie("token") .clearCookie('token')
.send( .send(
JSON.stringify(makeResponse("notLoggedIn", "auth.noCookie")), JSON.stringify(makeResponse('notLoggedIn', 'auth.noCookie')),
); );
let tok = this.jwt.verify<JwtType>(req.cookies.token); }
if (tok.kind != "auth") const tok = this.jwt.verify<JwtType>(req.cookies.token);
if (tok.kind != 'auth') {
return res return res
.clearCookie("token") .clearCookie('token')
.send( .send(
JSON.stringify(makeResponse("notLoggedIn", "auth.invalidKind")), JSON.stringify(makeResponse('notLoggedIn', 'auth.invalidKind')),
); );
let user = this.db.getUserFromName(tok.who); }
if (isNullish(user)) const user = this.db.getUserFromName(tok.who);
if (isNullish(user)) {
return res return res
.clearCookie("token") .clearCookie('token')
.send( .send(
JSON.stringify(makeResponse("notLoggedIn", "auth.noUser")), JSON.stringify(makeResponse('notLoggedIn', 'auth.noUser')),
); );
}
req.authUser = { id: user.id, name: tok.who }; req.authUser = { id: user.id, name: tok.who };
} catch { }
catch {
return res return res
.clearCookie("token") .clearCookie('token')
.send(JSON.stringify(makeResponse("notLoggedIn", "auth.invalid"))); .send(JSON.stringify(makeResponse('notLoggedIn', 'auth.invalid')));
} }
}; };
if (!routeOpts.preValidation) { if (!routeOpts.preValidation) {
routeOpts.preValidation = [f]; routeOpts.preValidation = [f];
} else if (Array.isArray(routeOpts.preValidation)) { }
else if (Array.isArray(routeOpts.preValidation)) {
routeOpts.preValidation.push(f); routeOpts.preValidation.push(f);
} else { }
else {
routeOpts.preValidation = [routeOpts.preValidation, f]; routeOpts.preValidation = [routeOpts.preValidation, f];
} }
(routeOpts as any)[kRouteAuthDone] = true; routeOpts[kRouteAuthDone] = true;
} }
}); });
}); });

View file

@ -1,8 +1,8 @@
import fp from 'fastify-plugin' import fp from 'fastify-plugin';
import { FastifyInstance, FastifyPluginAsync } from 'fastify' import { FastifyInstance, FastifyPluginAsync } from 'fastify';
import { Database as DbImpl } from "./mixin/_base"; import { Database as DbImpl } from './mixin/_base';
import { UserImpl, IUserDb } from "./mixin/user"; import { UserImpl, IUserDb } from './mixin/user';
import { isNullish } from '@shared/utils'; import { isNullish } from '@shared/utils';
@ -21,17 +21,15 @@ let dbAdded = false;
export const useDatabase = fp<FastifyPluginAsync>(async function( export const useDatabase = fp<FastifyPluginAsync>(async function(
f: FastifyInstance, f: FastifyInstance,
_options: {}) { _options: object) {
if (dbAdded) void _options;
return; if (dbAdded) { return; }
dbAdded = true; dbAdded = true;
let path = process.env.DATABASE_DIR; const path = process.env.DATABASE_DIR;
if (isNullish(path)) if (isNullish(path)) { throw 'env `DATABASE_DIR` not defined'; }
throw "env `DATABASE_DIR` not defined"; f.log.info(`Opening database with path: ${path}/database.db`);
f.log.info(`Opening database with path: ${path}/database.db`) const db: Database = new DbImpl(`${path}/database.db`) as Database;
let db: Database = new DbImpl(`${path}/database.db`) as Database; if (!f.hasDecorator('db')) { f.decorate('db', db); }
if (!f.hasDecorator("db"))
f.decorate('db', db);
}); });
export default useDatabase; export default useDatabase;

View file

@ -1,7 +1,7 @@
import sqlite from "better-sqlite3"; import sqlite from 'better-sqlite3';
// @ts-ignore: this file is included using vite, typescript doesn't know how to include this... // @ts-expect-error: this file is included using vite, typescript doesn't know how to include this...
import initSql from "../init.sql?raw" import initSql from '../init.sql?raw';
export type SqliteReturn = object | undefined; export type SqliteReturn = object | undefined;
@ -12,31 +12,31 @@ export type SqliteReturn = object | undefined;
// To create a new query function, go open another file, create a class that inherit from this class // To create a new query function, go open another file, create a class that inherit from this class
// in the `index.ts` file, import the new class, and make the `Database` class inherit it // in the `index.ts` file, import the new class, and make the `Database` class inherit it
export class Database { export class Database {
private db: sqlite.Database; private db: sqlite.Database;
private st: Map<string, sqlite.Statement> = new Map(); private st: Map<string, sqlite.Statement> = new Map();
/** /**
* Create a new instance of the database, and init it to a known state * Create a new instance of the database, and init it to a known state
* the file ./init.sql will be ran onto the database, creating any table that might be missing * the file ./init.sql will be ran onto the database, creating any table that might be missing
*/ */
constructor(db_path: string) { constructor(db_path: string) {
this.db = sqlite(db_path, {}); this.db = sqlite(db_path, {});
this.db.pragma('journal_mode = WAL'); this.db.pragma('journal_mode = WAL');
this.db.transaction(() => this.db.exec(initSql))(); this.db.transaction(() => this.db.exec(initSql))();
} }
/** /**
* close the database * close the database
*/ */
public destroy(): void { public destroy(): void {
// remove any statement from the cache // remove any statement from the cache
this.st.clear(); this.st.clear();
// close the database // close the database
this.db?.close(); this.db?.close();
} }
/** /**
* use this to create queries. This will create statements (kinda expensive) and cache them * use this to create queries. This will create statements (kinda expensive) and cache them
* since they will be cached, this means that they are only created once, * since they will be cached, this means that they are only created once,
* otherwise they'll be just spat out from the cache * otherwise they'll be just spat out from the cache
@ -46,12 +46,12 @@ export class Database {
* @example this.prepare('SELECT * FROM users WHERE id = ?') * @example this.prepare('SELECT * FROM users WHERE id = ?')
* @example this.prepare('SELECT * FROM users LIMIT 100 OFFSET ?') * @example this.prepare('SELECT * FROM users LIMIT 100 OFFSET ?')
*/ */
protected prepare(query: string): sqlite.Statement { protected prepare(query: string): sqlite.Statement {
let st = this.st.get(query); let st = this.st.get(query);
if (st !== undefined) return st; if (st !== undefined) return st;
st = this.db.prepare(query); st = this.db.prepare(query);
this.st.set(query, st); this.st.set(query, st);
return st; return st;
} }
} }

View file

@ -1,4 +1,4 @@
import type { Database } from "./_base"; import type { Database } from './_base';
// never use this directly // never use this directly
@ -9,28 +9,28 @@ export interface ITemplateDb extends Database {
}; };
export const UserImpl: Omit<ITemplateDb, keyof Database> = { export const UserImpl: Omit<ITemplateDb, keyof Database> = {
/** /**
* whole function description * whole function description
* *
* @param id the argument description * @param id the argument description
* *
* @returns what does the function return ? * @returns what does the function return ?
*/ */
normalFunction(this: ITemplateDb, id: TemplateId): TemplateData | undefined { normalFunction(this: ITemplateDb, id: TemplateId): TemplateData | undefined {
void id; void id;
return undefined; return undefined;
}, },
/** /**
* whole function description * whole function description
* *
* @param id the argument description * @param id the argument description
* *
* @returns what does the function return ? * @returns what does the function return ?
*/ */
async asyncFunction(this: ITemplateDb, id: TemplateId): Promise<TemplateData | undefined> { async asyncFunction(this: ITemplateDb, id: TemplateId): Promise<TemplateData | undefined> {
void id; void id;
return undefined; return undefined;
}, },
}; };
export type TemplateId = number & { readonly __brand: unique symbol }; export type TemplateId = number & { readonly __brand: unique symbol };
@ -43,13 +43,13 @@ export type TemplateData = {
// this function will be able to be called from everywhere // this function will be able to be called from everywhere
export async function freeFloatingExportedFunction(): Promise<boolean> { export async function freeFloatingExportedFunction(): Promise<boolean> {
return false; return false;
} }
// this function will never be able to be called outside of this module // this function will never be able to be called outside of this module
async function privateFunction(): Promise<string | undefined> { async function privateFunction(): Promise<string | undefined> {
return undefined return undefined;
} }
//silence warnings // silence warnings
void privateFunction; void privateFunction;

View file

@ -1,7 +1,7 @@
import type { Database, SqliteReturn } from "./_base"; import type { Database, SqliteReturn } from './_base';
import { Otp } from "@shared/auth"; import { Otp } from '@shared/auth';
import { isNullish } from "@shared/utils"; import { isNullish } from '@shared/utils';
import * as bcrypt from "bcrypt"; import * as bcrypt from 'bcrypt';
// never use this directly // never use this directly
@ -38,8 +38,8 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
getUserFromName(this: IUserDb, name: string): User | undefined { getUserFromName(this: IUserDb, name: string): User | undefined {
return userFromRow( return userFromRow(
this.prepare( this.prepare(
"SELECT * FROM user WHERE name = @name LIMIT 1", 'SELECT * FROM user WHERE name = @name LIMIT 1',
).get({ name }), ).get({ name }) as (Partial<User> | undefined),
); );
}, },
@ -52,9 +52,9 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
*/ */
getUserFromRawId(this: IUserDb, id: number): User | undefined { getUserFromRawId(this: IUserDb, id: number): User | undefined {
return userFromRow( return userFromRow(
this.prepare("SELECT * FROM user WHERE id = @id LIMIT 1").get({ this.prepare('SELECT * FROM user WHERE id = @id LIMIT 1').get({
id, id,
}) as SqliteReturn, }) as (Partial<User> | undefined),
); );
}, },
@ -70,8 +70,8 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
password = await hashPassword(password); password = await hashPassword(password);
return userFromRow( return userFromRow(
this.prepare( this.prepare(
"INSERT OR FAIL INTO user (name, password) VALUES (@name, @password) RETURNING *", 'INSERT OR FAIL INTO user (name, password) VALUES (@name, @password) RETURNING *',
).get({ name, password }), ).get({ name, password }) as (Partial<User> | undefined),
); );
}, },
@ -88,30 +88,29 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
password = await hashPassword(password); password = await hashPassword(password);
return userFromRow( return userFromRow(
this.prepare( this.prepare(
"UPDATE OR FAIL user SET password = @password WHERE id = @id RETURNING *", 'UPDATE OR FAIL user SET password = @password WHERE id = @id RETURNING *',
).get({ password, id }) as SqliteReturn, ).get({ password, id }) as SqliteReturn,
); );
}, },
getUserOtpSecret(this: IUserDb, id: UserId): string | undefined { getUserOtpSecret(this: IUserDb, id: UserId): string | undefined {
let otp: any = this.prepare("SELECT otp FROM user WHERE id = @id LIMIT 1").get({ id }) as SqliteReturn; const otp = this.prepare('SELECT otp FROM user WHERE id = @id LIMIT 1').get({ id }) as ({ otp: string } | null | undefined);
if (isNullish(otp?.otp)) return undefined; if (isNullish(otp?.otp)) return undefined;
return otp.otp; return otp.otp;
}, },
ensureUserOtpSecret(this: IUserDb, id: UserId): string | undefined { ensureUserOtpSecret(this: IUserDb, id: UserId): string | undefined {
let otp = this.getUserOtpSecret(id); const otp = this.getUserOtpSecret(id);
if (!isNullish(otp)) if (!isNullish(otp)) { return otp; }
return otp; const otpGen = new Otp();
let otpGen = new Otp(); const res = this.prepare('UPDATE OR IGNORE user SET otp = @otp WHERE id = @id RETURNING otp')
const res: any = this.prepare("UPDATE OR IGNORE user SET otp = @otp WHERE id = @id RETURNING otp") .get({ id, otp: otpGen.secret }) as ({ otp: string } | null | undefined);
.get({ id, otp: otpGen.secret });
return res?.otp; return res?.otp;
}, },
deleteUserOtpSecret(this: IUserDb, id: UserId): void { deleteUserOtpSecret(this: IUserDb, id: UserId): void {
this.prepare("UPDATE OR IGNORE user SET otp = NULL WHERE id = @id").run({ id }); this.prepare('UPDATE OR IGNORE user SET otp = NULL WHERE id = @id').run({ id });
} },
}; };
export type UserId = number & { readonly __brand: unique symbol }; export type UserId = number & { readonly __brand: unique symbol };
@ -155,12 +154,14 @@ async function hashPassword(
* *
* @returns The user if it exists, undefined otherwise * @returns The user if it exists, undefined otherwise
*/ */
function userFromRow(row: any): User | undefined { function userFromRow(row?: Partial<User>): User | undefined {
if (isNullish(row)) return undefined; if (isNullish(row)) return undefined;
if (isNullish(row.id)) return undefined;
if (isNullish(row.name)) return undefined;
return { return {
id: row.id as UserId, id: row.id,
name: row.name || undefined, name: row.name,
password: row.password || undefined, password: row.password ?? undefined,
otp: row.otp || undefined, otp: row.otp ?? undefined,
}; };
} }

View file

@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox"; import { TObject, TProperties, Type } from '@sinclair/typebox';
/** /**
* @description Represent a message key * @description Represent a message key
@ -12,7 +12,7 @@ import { Type } from "@sinclair/typebox";
* @example `pong.you.lost` * @example `pong.you.lost`
*/ */
export type MessageKey = string; export type MessageKey = string;
export type ResponseBase<T = {}> = { export type ResponseBase<T = object> = {
kind: string, kind: string,
msg: MessageKey, msg: MessageKey,
payload?: T, payload?: T,
@ -26,9 +26,9 @@ export type ResponseBase<T = {}> = {
* @example makeResponse("failure", "login.failure.invalid") * @example makeResponse("failure", "login.failure.invalid")
* @example makeResponse("success", "login.success", { token: "supersecrettoken" }) * @example makeResponse("success", "login.success", { token: "supersecrettoken" })
*/ */
export function makeResponse<T = {}>(kind: string, key: MessageKey, payload?: T): ResponseBase<T> { export function makeResponse<T = object>(kind: string, key: MessageKey, payload?: T): ResponseBase<T> {
console.log(`making response {kind: ${JSON.stringify(kind)}; key: ${JSON.stringify(key)}}`) console.log(`making response {kind: ${JSON.stringify(kind)}; key: ${JSON.stringify(key)}}`);
return { kind, msg: key, payload } return { kind, msg: key, payload };
} }
@ -39,11 +39,12 @@ export function makeResponse<T = {}>(kind: string, key: MessageKey, payload?: T)
* @example typeResponse("otpRequired", "login.otpRequired", { token: Type.String() }) * @example typeResponse("otpRequired", "login.otpRequired", { token: Type.String() })
* @example typeResponse("success", "login.success", { token: Type.String() }) * @example typeResponse("success", "login.success", { token: Type.String() })
*/ */
export function typeResponse(kind: string, key: MessageKey | MessageKey[], payload?: any): any { export function typeResponse(kind: string, key: MessageKey | MessageKey[], payload?: TProperties): TObject<TProperties> {
let tKey; let tKey;
if (key instanceof Array) { if (key instanceof Array) {
tKey = Type.Union(key.map(l => Type.Const(l))); tKey = Type.Union(key.map(l => Type.Const(l)));
} else { }
else {
tKey = Type.Const(key); tKey = Type.Const(key);
} }
@ -51,10 +52,9 @@ export function typeResponse(kind: string, key: MessageKey | MessageKey[], paylo
kind: Type.Const(kind), kind: Type.Const(kind),
msg: tKey, msg: tKey,
}; };
if (payload !== undefined) if (payload !== undefined) {Object.assign(Ty, { payload: Type.Object(payload) });}
Object.assign(Ty, { payload: Type.Object(payload) })
return Type.Object(Ty) return Type.Object(Ty);
} }
/** /**
@ -69,5 +69,5 @@ export function typeResponse(kind: string, key: MessageKey | MessageKey[], paylo
* @example assert_equal(isNullish(false), false); * @example assert_equal(isNullish(false), false);
*/ */
export function isNullish<T>(v: T | undefined | null): v is (null | undefined) { export function isNullish<T>(v: T | undefined | null): v is (null | undefined) {
return v === null || v === undefined return v === null || v === undefined;
} }

View file

@ -1,107 +1,104 @@
const headers = { const headers = {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}; };
const tUsername = document.querySelector("#t-username") const tUsername = document.querySelector('#t-username');
const iUsername = document.querySelector("#i-username"); const iUsername = document.querySelector('#i-username');
const iPassword = document.querySelector("#i-password"); const iPassword = document.querySelector('#i-password');
const iOtp = document.querySelector("#i-otp"); const iOtp = document.querySelector('#i-otp');
const bOtpSend = document.querySelector("#b-otpSend"); const bOtpSend = document.querySelector('#b-otpSend');
const bLogin = document.querySelector("#b-login"); const bLogin = document.querySelector('#b-login');
const bLogout = document.querySelector("#b-logout"); const bLogout = document.querySelector('#b-logout');
const bSignin = document.querySelector("#b-signin"); const bSignin = document.querySelector('#b-signin');
const bWhoami = document.querySelector("#b-whoami"); const bWhoami = document.querySelector('#b-whoami');
const bOtpStatus = document.querySelector("#b-otpStatus"); const bOtpStatus = document.querySelector('#b-otpStatus');
const bOtpEnable = document.querySelector("#b-otpEnable"); const bOtpEnable = document.querySelector('#b-otpEnable');
const bOtpDisable = document.querySelector("#b-otpDisable"); const bOtpDisable = document.querySelector('#b-otpDisable');
const dResponse = document.querySelector("#d-response"); const dResponse = document.querySelector('#d-response');
function setResponse(obj) { function setResponse(obj) {
let obj_str = JSON.stringify(obj, null, 4); const obj_str = JSON.stringify(obj, null, 4);
dResponse.innerText = obj_str; dResponse.innerText = obj_str;
} }
let otpToken = null; let otpToken = null;
bOtpSend.addEventListener("click", async () => { bOtpSend.addEventListener('click', async () => {
let res = await fetch("/api/auth/otp", { method: "POST", body: JSON.stringify({ code: iOtp.value, token: otpToken }), headers }); const res = await fetch('/api/auth/otp', { method: 'POST', body: JSON.stringify({ code: iOtp.value, token: otpToken }), headers });
const json = await res.json(); const json = await res.json();
setResponse(json); setResponse(json);
if (json.kind === "success") { if (json.kind === 'success') {
if (json?.payload?.token) if (json?.payload?.token) {document.cookie = `token=${json?.payload?.token}`;}
document.cookie = `token=${json?.payload?.token}`;
} }
}); });
bOtpStatus.addEventListener("click", async () => { bOtpStatus.addEventListener('click', async () => {
let res = await fetch("/api/auth/statusOtp"); const res = await fetch('/api/auth/statusOtp');
const json = await res.json(); const json = await res.json();
setResponse(json); setResponse(json);
}); });
bOtpEnable.addEventListener("click", async () => { bOtpEnable.addEventListener('click', async () => {
let res = await fetch("/api/auth/enableOtp", { method: "PUT" }); const res = await fetch('/api/auth/enableOtp', { method: 'PUT' });
const json = await res.json(); const json = await res.json();
setResponse(json); setResponse(json);
}); });
bOtpDisable.addEventListener("click", async () => { bOtpDisable.addEventListener('click', async () => {
let res = await fetch("/api/auth/disableOtp", { method: "PUT" }); const res = await fetch('/api/auth/disableOtp', { method: 'PUT' });
const json = await res.json(); const json = await res.json();
setResponse(json); setResponse(json);
}); });
bWhoami.addEventListener("click", async () => { bWhoami.addEventListener('click', async () => {
let username = ""; let username = '';
try { try {
let res = await fetch("/api/auth/whoami"); const res = await fetch('/api/auth/whoami');
const json = await res.json(); const json = await res.json();
setResponse(json); setResponse(json);
if (json?.kind === "success") if (json?.kind === 'success') {username = json?.payload?.name;}
username = json?.payload?.name; else {username = `<not logged in:${json.msg}>`;}
else }
username = `<not logged in:${json.msg}>` catch {
} catch { username = '<not logged in: threw>';
username = `<not logged in: threw>`
} }
tUsername.innerText = username; tUsername.innerText = username;
}); });
bLogin.addEventListener("click", async () => { bLogin.addEventListener('click', async () => {
const name = iUsername.value; const name = iUsername.value;
const password = iPassword.value; const password = iPassword.value;
let res = await fetch("/api/auth/login", { method: "POST", body: JSON.stringify({ name, password }), headers }); const res = await fetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ name, password }), headers });
let json = await res.json(); const json = await res.json();
if (json?.kind === "otpRequired") { if (json?.kind === 'otpRequired') {
otpToken = json?.payload?.token; otpToken = json?.payload?.token;
} else if (json?.kind === "success") { }
if (json?.payload?.token) else if (json?.kind === 'success') {
document.cookie = `token=${json?.payload?.token}`; if (json?.payload?.token) {document.cookie = `token=${json?.payload?.token}`;}
} }
setResponse(json); setResponse(json);
}) });
bLogout.addEventListener("click", async () => { bLogout.addEventListener('click', async () => {
let res = await fetch("/api/auth/logout", { method: "POST" }); const res = await fetch('/api/auth/logout', { method: 'POST' });
setResponse(await res.json()); setResponse(await res.json());
}) });
bSignin.addEventListener("click", async () => { bSignin.addEventListener('click', async () => {
const name = iUsername.value; const name = iUsername.value;
const password = iPassword.value; const password = iPassword.value;
let res = await fetch("/api/auth/signin", { method: "POST", body: JSON.stringify({ name, password }), headers }); const res = await fetch('/api/auth/signin', { method: 'POST', body: JSON.stringify({ name, password }), headers });
let json = await res.json(); const json = await res.json();
if (json?.payload?.token) if (json?.payload?.token) {document.cookie = `token=${json?.payload?.token};`;}
document.cookie = `token=${json?.payload?.token};`;
setResponse(json); setResponse(json);
}) });

View file

@ -1,17 +1,14 @@
import { FastifyPluginAsync } from 'fastify' import { FastifyPluginAsync } from 'fastify';
import fastifyFormBody from '@fastify/formbody' import fastifyFormBody from '@fastify/formbody';
import fastifyMultipart from '@fastify/multipart' import fastifyMultipart from '@fastify/multipart';
import { mkdir } from 'node:fs/promises' import * as db from '@shared/database';
import fp from 'fastify-plugin' import * as auth from '@shared/auth';
import * as db from '@shared/database'
import * as auth from '@shared/auth'
// @ts-ignore: import.meta.glob is a vite thing. Typescript doesn't know this... // @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
const plugins = import.meta.glob('./plugins/**/*.ts', { eager: true }); const plugins = import.meta.glob('./plugins/**/*.ts', { eager: true });
// @ts-ignore: import.meta.glob is a vite thing. Typescript doesn't know this... // @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
const routes = import.meta.glob('./routes/**/*.ts', { eager: true }); const routes = import.meta.glob('./routes/**/*.ts', { eager: true });
// When using .decorate you have to specify added properties for Typescript // When using .decorate you have to specify added properties for Typescript
declare module 'fastify' { declare module 'fastify' {
export interface FastifyInstance { export interface FastifyInstance {
@ -19,25 +16,23 @@ declare module 'fastify' {
} }
} }
const app: FastifyPluginAsync = async ( const app: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify, void opts;
opts await fastify.register(db.useDatabase as FastifyPluginAsync, {});
): Promise<void> => { await fastify.register(auth.jwtPlugin as FastifyPluginAsync, {});
await fastify.register(db.useDatabase as any, {}) await fastify.register(auth.authPlugin as FastifyPluginAsync, {});
await fastify.register(auth.jwtPlugin as any, {})
await fastify.register(auth.authPlugin as any, {})
// Place here your custom code! // Place here your custom code!
for (const plugin of Object.values(plugins)) { for (const plugin of Object.values(plugins)) {
void fastify.register(plugin as any, {}); void fastify.register(plugin as FastifyPluginAsync, {});
} }
for (const route of Object.values(routes)) { for (const route of Object.values(routes)) {
void fastify.register(route as any, {}); void fastify.register(route as FastifyPluginAsync, {});
} }
void fastify.register(fastifyFormBody, {}) void fastify.register(fastifyFormBody, {});
void fastify.register(fastifyMultipart, {}) void fastify.register(fastifyMultipart, {});
} };
export default app export default app;
export { app } export { app };

View file

@ -1,5 +1,5 @@
import fp from 'fastify-plugin' import fp from 'fastify-plugin';
import sensible, { FastifySensibleOptions } from '@fastify/sensible' import sensible, { FastifySensibleOptions } from '@fastify/sensible';
/** /**
* This plugins adds some utilities to handle http errors * This plugins adds some utilities to handle http errors
@ -7,5 +7,5 @@ import sensible, { FastifySensibleOptions } from '@fastify/sensible'
* @see https://github.com/fastify/fastify-sensible * @see https://github.com/fastify/fastify-sensible
*/ */
export default fp<FastifySensibleOptions>(async (fastify) => { export default fp<FastifySensibleOptions>(async (fastify) => {
fastify.register(sensible) fastify.register(sensible);
}) });

View file

@ -1,25 +1,26 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { makeResponse, typeResponse, isNullish } from "@shared/utils" import { makeResponse, typeResponse, isNullish } from '@shared/utils';
export const WhoAmIRes = Type.Union([ export const WhoAmIRes = Type.Union([
typeResponse("success", "disableOtp.success"), typeResponse('success', 'disableOtp.success'),
typeResponse("failure", "disableOtp.failure.generic") typeResponse('failure', 'disableOtp.failure.generic'),
]); ]);
export type WhoAmIRes = Static<typeof WhoAmIRes>; export type WhoAmIRes = Static<typeof WhoAmIRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.put( fastify.put(
"/api/auth/disableOtp", '/api/auth/disableOtp',
{ schema: { response: { "2xx": WhoAmIRes } }, config: { requireAuth: true } }, { schema: { response: { '2xx': WhoAmIRes } }, config: { requireAuth: true } },
async function(req, _res) { async function(req, _res) {
if (isNullish(req.authUser)) void _res;
return makeResponse("failure", "disableOtp.failure.generic"); if (isNullish(req.authUser)) {return makeResponse('failure', 'disableOtp.failure.generic');}
this.db.deleteUserOtpSecret(req.authUser.id); this.db.deleteUserOtpSecret(req.authUser.id);
return makeResponse("success", "disableOtp.success"); return makeResponse('success', 'disableOtp.success');
}, },
); );
}; };

View file

@ -1,29 +1,29 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { isNullish, makeResponse, typeResponse } from "@shared/utils" import { isNullish, makeResponse, typeResponse } from '@shared/utils';
import { Otp } from "@shared/auth"; import { Otp } from '@shared/auth';
export const WhoAmIRes = Type.Union([ export const WhoAmIRes = Type.Union([
typeResponse("success", "enableOtp.success", { url: Type.String({ description: "The otp url to feed into a 2fa app" }) }), typeResponse('success', 'enableOtp.success', { url: Type.String({ description: 'The otp url to feed into a 2fa app' }) }),
typeResponse("failure", ["enableOtp.failure.noUser", "enableOtp.failure.noSecret"]) typeResponse('failure', ['enableOtp.failure.noUser', 'enableOtp.failure.noSecret']),
]); ]);
export type WhoAmIRes = Static<typeof WhoAmIRes>; export type WhoAmIRes = Static<typeof WhoAmIRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.put( fastify.put(
"/api/auth/enableOtp", '/api/auth/enableOtp',
{ schema: { response: { "2xx": WhoAmIRes } }, config: { requireAuth: true } }, { schema: { response: { '2xx': WhoAmIRes } }, config: { requireAuth: true } },
async function(req, _res) { async function(req, _res) {
if (isNullish(req.authUser)) void _res;
return makeResponse("failure", "enableOtp.failure.noUser"); if (isNullish(req.authUser)) {return makeResponse('failure', 'enableOtp.failure.noUser');}
let otpSecret = this.db.ensureUserOtpSecret(req.authUser!.id); const otpSecret = this.db.ensureUserOtpSecret(req.authUser!.id);
if (isNullish(otpSecret)) if (isNullish(otpSecret)) {return makeResponse('failure', 'enableOtp.failure.noSecret');}
return makeResponse("failure", "enableOtp.failure.noSecret"); const otp = new Otp({ secret: otpSecret });
let otp = new Otp({ secret: otpSecret }); return makeResponse('success', 'enableOtp.success', { url: otp.totpURL });
return makeResponse("success", "enableOtp.success", { url: otp.totpURL });
}, },
); );
}; };

View file

@ -1,8 +1,8 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { typeResponse, makeResponse, isNullish } from "@shared/utils" import { typeResponse, makeResponse, isNullish } from '@shared/utils';
import { verifyUserPassword } from "@shared/database/mixin/user"; import { verifyUserPassword } from '@shared/database/mixin/user';
export const LoginReq = Type.Object({ export const LoginReq = Type.Object({
name: Type.String(), name: Type.String(),
@ -12,44 +12,44 @@ export const LoginReq = Type.Object({
export type LoginReq = Static<typeof LoginReq>; export type LoginReq = Static<typeof LoginReq>;
export const LoginRes = Type.Union([ export const LoginRes = Type.Union([
typeResponse("failed", ["login.failed.generic", "login.failed.invalid"]), typeResponse('failed', ['login.failed.generic', 'login.failed.invalid']),
typeResponse("otpRequired", "login.otpRequired", { token: Type.String({ description: "JWT to send with the OTP to finish login" }) }), typeResponse('otpRequired', 'login.otpRequired', { token: Type.String({ description: 'JWT to send with the OTP to finish login' }) }),
typeResponse("success", "login.success", { token: Type.String({ description: "JWT that represent a logged in user" }) }), typeResponse('success', 'login.success', { token: Type.String({ description: 'JWT that represent a logged in user' }) }),
]); ]);
export type LoginRes = Static<typeof LoginRes>; export type LoginRes = Static<typeof LoginRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: LoginReq; Response: LoginRes }>( fastify.post<{ Body: LoginReq; Response: LoginRes }>(
"/api/auth/login", '/api/auth/login',
{ schema: { body: LoginReq, response: { "2xx": LoginRes } }, }, { schema: { body: LoginReq, response: { '2xx': LoginRes } } },
async function(req, _res) { async function(req, _res) {
void _res;
try { try {
let { name, password } = req.body; const { name, password } = req.body;
let user = this.db.getUserFromName(name); const user = this.db.getUserFromName(name);
// does the user exist // does the user exist
// does it have a password setup ? // does it have a password setup ?
if (isNullish(user?.password)) if (isNullish(user?.password)) {return makeResponse('failed', 'login.failed.invalid');}
return makeResponse("failed", "login.failed.invalid");
// does the password he provided match the one we have // does the password he provided match the one we have
if (!(await verifyUserPassword(user, password))) if (!(await verifyUserPassword(user, password))) {return makeResponse('failed', 'login.failed.invalid');}
return makeResponse("failed", "login.failed.invalid");
// does the user has 2FA up ? // does the user has 2FA up ?
if (!isNullish(user.otp)) { if (!isNullish(user.otp)) {
// yes -> we ask them to fill it, // yes -> we ask them to fill it,
// send them somehting to verify that they indeed passed throught the user+password phase // send them somehting to verify that they indeed passed throught the user+password phase
return makeResponse("otpRequired", "login.otpRequired", { token: this.signJwt("otp", user.name) }); return makeResponse('otpRequired', 'login.otpRequired', { token: this.signJwt('otp', user.name) });
} }
// every check has been passed, they are now logged in, using this token to say who they are... // every check has been passed, they are now logged in, using this token to say who they are...
return makeResponse("success", "login.success", { token: this.signJwt("auth", user.name) }); return makeResponse('success', 'login.success', { token: this.signJwt('auth', user.name) });
} }
catch { catch {
return makeResponse("failed", "login.failed.generic"); return makeResponse('failed', 'login.failed.generic');
} }
}, },
); );

View file

@ -1,10 +1,12 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post( fastify.post(
"/api/auth/logout", '/api/auth/logout',
async function(_req, res) { async function(_req, res) {
return res.clearCookie("token").send("{}") void _req;
return res.clearCookie('token').send('{}');
}, },
); );
}; };

View file

@ -1,19 +1,19 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { JwtType, Otp } from "@shared/auth"; import { JwtType, Otp } from '@shared/auth';
import { typeResponse, makeResponse, isNullish } from "@shared/utils"; import { typeResponse, makeResponse, isNullish } from '@shared/utils';
const OtpReq = Type.Object({ const OtpReq = Type.Object({
token: Type.String({ description: "The token given at the login phase" }), token: Type.String({ description: 'The token given at the login phase' }),
code: Type.String({ description: "The OTP given by the user" }), code: Type.String({ description: 'The OTP given by the user' }),
}); });
type OtpReq = Static<typeof OtpReq>; type OtpReq = Static<typeof OtpReq>;
const OtpRes = Type.Union([ const OtpRes = Type.Union([
typeResponse("failed", ["otp.failed.generic", "otp.failed.invalid", "otp.failed.timeout", "otp.failed.noSecret"]), typeResponse('failed', ['otp.failed.generic', 'otp.failed.invalid', 'otp.failed.timeout', 'otp.failed.noSecret']),
typeResponse("success", "otp.success", { token: Type.String({ description: "the JWT Token" }) }), typeResponse('success', 'otp.success', { token: Type.String({ description: 'the JWT Token' }) }),
]); ]);
type OtpRes = Static<typeof OtpRes>; type OtpRes = Static<typeof OtpRes>;
@ -21,35 +21,40 @@ type OtpRes = Static<typeof OtpRes>;
const OTP_TOKEN_TIMEOUT_SEC = 120; const OTP_TOKEN_TIMEOUT_SEC = 120;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: OtpReq }>( fastify.post<{ Body: OtpReq }>(
"/api/auth/otp", '/api/auth/otp',
{ schema: { body: OtpReq, response: { "2xx": OtpRes } } }, { schema: { body: OtpReq, response: { '2xx': OtpRes } } },
async function(req, _res) { async function(req, _res) {
void _res;
try { try {
const { token, code } = req.body; const { token, code } = req.body;
// lets try to decode+verify the jwt // lets try to decode+verify the jwt
let dJwt = this.jwt.verify<JwtType>(token); const dJwt = this.jwt.verify<JwtType>(token);
// is the jwt a valid `otp` jwt ? // is the jwt a valid `otp` jwt ?
if (dJwt.kind != "otp") if (dJwt.kind != 'otp') {
// no ? fuck off then // no ? fuck off then
return makeResponse("failed", "otp.failed.invalid"); return makeResponse('failed', 'otp.failed.invalid');
}
// is it too old ? // is it too old ?
if (dJwt.createdAt + OTP_TOKEN_TIMEOUT_SEC * 1000 < Date.now()) if (dJwt.createdAt + OTP_TOKEN_TIMEOUT_SEC * 1000 < Date.now()) {
// yes ? fuck off then, redo the password // yes ? fuck off then, redo the password
return makeResponse("failed", "otp.failed.timeout"); return makeResponse('failed', 'otp.failed.timeout');
}
// get the Otp sercret from the db // get the Otp sercret from the db
let user = this.db.getUserFromName(dJwt.who); const user = this.db.getUserFromName(dJwt.who);
if (isNullish(user?.otp)) if (isNullish(user?.otp)) {
// oops, either no user, or user without otpSecret // oops, either no user, or user without otpSecret
// fuck off // fuck off
return makeResponse("failed", "otp.failed.noSecret"); return makeResponse('failed', 'otp.failed.noSecret');
}
// good lets now verify the token you gave us is the correct one... // good lets now verify the token you gave us is the correct one...
let otpHandle = new Otp({ secret: user.otp }); const otpHandle = new Otp({ secret: user.otp });
let now = Date.now(); const now = Date.now();
const tokens = [ const tokens = [
// we also get the last code, to mitiage the delay between client<->server roundtrip... // we also get the last code, to mitiage the delay between client<->server roundtrip...
otpHandle.totp(now - 30 * 1000), otpHandle.totp(now - 30 * 1000),
@ -58,14 +63,16 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
]; ];
// checking if any of the array match // checking if any of the array match
if (tokens.some((c) => c === code)) if (tokens.some((c) => c === code)) {
// they do ! // they do !
// gg you are now logged in ! // gg you are now logged in !
return makeResponse("success", "otp.success", { token: this.signJwt("auth", dJwt.who) }); return makeResponse('success', 'otp.success', { token: this.signJwt('auth', dJwt.who) });
} catch { }
return makeResponse("failed", "otp.failed.generic");
} }
return makeResponse("failed", "otp.failed.generic"); catch {
return makeResponse('failed', 'otp.failed.generic');
}
return makeResponse('failed', 'otp.failed.generic');
}, },
); );
}; };

View file

@ -1,9 +1,9 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { typeResponse, makeResponse, isNullish } from "@shared/utils"; import { typeResponse, makeResponse, isNullish } from '@shared/utils';
const USERNAME_CHECK: RegExp = /^[a-zA-Z\_0-9]+$/; const USERNAME_CHECK: RegExp = /^[a-zA-Z_0-9]+$/;
const SignInReq = Type.Object({ const SignInReq = Type.Object({
name: Type.String(), name: Type.String(),
@ -13,51 +13,46 @@ const SignInReq = Type.Object({
type SignInReq = Static<typeof SignInReq>; type SignInReq = Static<typeof SignInReq>;
const SignInRes = Type.Union([ const SignInRes = Type.Union([
typeResponse("failed", [ typeResponse('failed', [
"signin.failed.generic", 'signin.failed.generic',
"signin.failed.username.existing", 'signin.failed.username.existing',
"signin.failed.username.toolong", 'signin.failed.username.toolong',
"signin.failed.username.tooshort", 'signin.failed.username.tooshort',
"signin.failed.username.invalid", 'signin.failed.username.invalid',
"signin.failed.password.toolong", 'signin.failed.password.toolong',
"signin.failed.password.tooshort", 'signin.failed.password.tooshort',
"signin.failed.password.invalid", 'signin.failed.password.invalid',
]), ]),
typeResponse("success", "signin.success", { token: Type.String({ description: "the JWT token" }) }), typeResponse('success', 'signin.success', { token: Type.String({ description: 'the JWT token' }) }),
]) ]);
type SignInRes = Static<typeof SignInRes>; type SignInRes = Static<typeof SignInRes>;
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: SignInReq }>( fastify.post<{ Body: SignInReq }>(
"/api/auth/signin", '/api/auth/signin',
{ schema: { body: SignInReq, response: { "200": SignInRes, "5xx": Type.Object({}) } }, }, { schema: { body: SignInReq, response: { '200': SignInRes, '5xx': Type.Object({}) } } },
async function(req, res) { async function(req, _res) {
void _res;
const { name, password } = req.body; const { name, password } = req.body;
if (name.length < 4) if (name.length < 4) {return makeResponse('failed', 'signin.failed.username.tooshort');}
return makeResponse("failed", "signin.failed.username.tooshort"); if (name.length > 32) {return makeResponse('failed', 'signin.failed.username.toolong');}
if (name.length > 32) if (!USERNAME_CHECK.test(name)) {return makeResponse('failed', 'signin.failed.username.invalid');}
return makeResponse("failed", "signin.failed.username.toolong");
if (!USERNAME_CHECK.test(name))
return makeResponse("failed", "signin.failed.username.invalid");
// username if good now :) // username if good now :)
if (password.length < 8) if (password.length < 8) {return makeResponse('failed', 'signin.failed.password.tooshort');}
return makeResponse("failed", "signin.failed.password.tooshort"); if (password.length > 64) {return makeResponse('failed', 'signin.failed.password.toolong');}
if (password.length > 64)
return makeResponse("failed", "signin.failed.password.toolong");
// password is good too ! // password is good too !
if (this.db.getUserFromName(name) !== undefined) if (this.db.getUserFromName(name) !== undefined) {return makeResponse('failed', 'signin.failed.username.existing');}
return makeResponse("failed", "signin.failed.username.existing"); const u = await this.db.createUser(name, password);
let u = await this.db.createUser(name, password); if (isNullish(u)) {return makeResponse('failed', 'signin.failed.generic');}
if (isNullish(u))
return makeResponse("failed", "signin.failed.generic");
// every check has been passed, they are now logged in, using this token to say who they are... // every check has been passed, they are now logged in, using this token to say who they are...
let userToken = this.signJwt('auth', u.name); const userToken = this.signJwt('auth', u.name);
return makeResponse("success", "signin.success", { token: userToken }); return makeResponse('success', 'signin.success', { token: userToken });
}, },
); );
}; };

View file

@ -1,30 +1,30 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { isNullish, makeResponse, typeResponse } from "@shared/utils" import { isNullish, makeResponse, typeResponse } from '@shared/utils';
import { Otp } from "@shared/auth"; import { Otp } from '@shared/auth';
export const StatusOtpRes = Type.Union([ export const StatusOtpRes = Type.Union([
typeResponse("success", "statusOtp.success.enabled", { url: Type.String({ description: "The otp url to feed into a 2fa app" }) }), typeResponse('success', 'statusOtp.success.enabled', { url: Type.String({ description: 'The otp url to feed into a 2fa app' }) }),
typeResponse("success", "statusOtp.success.disabled"), typeResponse('success', 'statusOtp.success.disabled'),
typeResponse("failure", "statusOtp.failure.generic") typeResponse('failure', 'statusOtp.failure.generic'),
]); ]);
export type StatusOtpRes = Static<typeof StatusOtpRes>; export type StatusOtpRes = Static<typeof StatusOtpRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get( fastify.get(
"/api/auth/statusOtp", '/api/auth/statusOtp',
{ schema: { response: { "2xx": StatusOtpRes } }, config: { requireAuth: true } }, { schema: { response: { '2xx': StatusOtpRes } }, config: { requireAuth: true } },
async function(req, _res) { async function(req, _res) {
if (isNullish(req.authUser)) void _res;
return makeResponse("failure", "statusOtp.failure.generic"); if (isNullish(req.authUser)) {return makeResponse('failure', 'statusOtp.failure.generic');}
let otpSecret = this.db.getUserOtpSecret(req.authUser.id); const otpSecret = this.db.getUserOtpSecret(req.authUser.id);
if (isNullish(otpSecret)) if (isNullish(otpSecret)) {return makeResponse('success', 'statusOtp.success.disabled');}
return makeResponse("success", "statusOtp.success.disabled"); const otp = new Otp({ secret: otpSecret });
let otp = new Otp({ secret: otpSecret }) return makeResponse('success', 'statusOtp.success.enabled', { url: otp.totpURL });
return makeResponse("success", "statusOtp.success.enabled", { url: otp.totpURL });
}, },
); );
}; };

View file

@ -1,24 +1,25 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from "@sinclair/typebox"; import { Static, Type } from '@sinclair/typebox';
import { isNullish, makeResponse, typeResponse } from "@shared/utils" import { isNullish, makeResponse, typeResponse } from '@shared/utils';
export const WhoAmIRes = Type.Union([ export const WhoAmIRes = Type.Union([
typeResponse("success", "whoami.success", { name: Type.String() }), typeResponse('success', 'whoami.success', { name: Type.String() }),
typeResponse("failure", "whoami.failure.generic") typeResponse('failure', 'whoami.failure.generic'),
]); ]);
export type WhoAmIRes = Static<typeof WhoAmIRes>; export type WhoAmIRes = Static<typeof WhoAmIRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get( fastify.get(
"/api/auth/whoami", '/api/auth/whoami',
{ schema: { response: { "2xx": WhoAmIRes } }, config: { requireAuth: true } }, { schema: { response: { '2xx': WhoAmIRes } }, config: { requireAuth: true } },
async function(req, _res) { async function(req, _res) {
if (isNullish(req.authUser)) void _res;
return makeResponse("failure", "whoami.failure.generic") if (isNullish(req.authUser)) {return makeResponse('failure', 'whoami.failure.generic');}
return makeResponse("success", "whoami.success", { name: req.authUser.name }) return makeResponse('success', 'whoami.success', { name: req.authUser.name });
}, },
); );
}; };

View file

@ -1,7 +1,7 @@
// this sould only be used by the docker file ! // this sould only be used by the docker file !
import fastify, { FastifyInstance } from "fastify"; import fastify, { FastifyInstance } from 'fastify';
import app from "./app" import app from './app';
const start = async () => { const start = async () => {
const envToLogger = { const envToLogger = {
@ -16,15 +16,16 @@ const start = async () => {
}, },
production: true, production: true,
test: false, test: false,
} };
const f: FastifyInstance = fastify({ logger: envToLogger.development }); const f: FastifyInstance = fastify({ logger: envToLogger.development });
try { try {
await f.register(app, {}); await f.register(app, {});
await f.listen({ port: 80, host: '0.0.0.0' }) await f.listen({ port: 80, host: '0.0.0.0' });
} catch (err) {
f.log.error(err)
process.exit(1)
} }
} catch (err) {
start() f.log.error(err);
process.exit(1);
}
};
start();

View file

@ -1,8 +1,8 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths' import tsconfigPaths from 'vite-tsconfig-paths';
import nodeExternals from 'rollup-plugin-node-externals' import nodeExternals from 'rollup-plugin-node-externals';
import path from 'node:path' import path from 'node:path';
import fs from 'node:fs' import fs from 'node:fs';
function collectDeps(...pkgJsonPaths) { function collectDeps(...pkgJsonPaths) {
const allDeps = new Set(); const allDeps = new Set();
@ -20,27 +20,32 @@ function collectDeps(...pkgJsonPaths) {
const externals = collectDeps( const externals = collectDeps(
'./package.json', './package.json',
'../@shared/package.json' '../@shared/package.json',
); );
export default defineConfig({ export default defineConfig({
root: __dirname, // service root root: __dirname,
// service root
plugins: [tsconfigPaths(), nodeExternals()], plugins: [tsconfigPaths(), nodeExternals()],
build: { build: {
ssr: true, ssr: true,
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
lib: { lib: {
entry: path.resolve(__dirname, 'src/run.ts'), // adjust main entry entry: path.resolve(__dirname, 'src/run.ts'),
formats: ['cjs'], // CommonJS for Node.js // adjust main entry
formats: ['cjs'],
// CommonJS for Node.js
fileName: () => 'index.js', fileName: () => 'index.js',
}, },
rollupOptions: { rollupOptions: {
external: externals, external: externals,
}, },
target: 'node22', // or whatever Node version you use target: 'node22',
// or whatever Node version you use
sourcemap: true, sourcemap: true,
minify: false, // for easier debugging minify: false,
} // for easier debugging
}) },
});

55
src/eslint.config.js Normal file
View file

@ -0,0 +1,55 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
export default [
js.configs.recommended,
...ts.configs.recommended,
{
languageOptions: {
ecmaVersion: 'latest',
},
rules: {
'arrow-spacing': ['warn', { before: true, after: true }],
'brace-style': ['error', 'stroustrup', { allowSingleLine: true }],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': 'error',
'comma-style': 'error',
curly: ['error', 'multi-line', 'consistent'],
'dot-location': ['error', 'property'],
'handle-callback-err': 'off',
indent: ['error', 'tab'],
'keyword-spacing': 'error',
'max-nested-callbacks': ['error', { max: 4 }],
'max-statements-per-line': ['error', { max: 2 }],
'no-console': 'off',
'no-empty-function': 'error',
'no-floating-decimal': 'error',
'no-inline-comments': 'error',
'no-lonely-if': 'error',
'no-multi-spaces': 'error',
'no-multiple-empty-lines': ['error', { max: 2, maxEOF: 1, maxBOF: 0 }],
'no-shadow': ['error', { allow: ['err', 'resolve', 'reject'] }],
'no-trailing-spaces': ['error'],
'no-var': 'error',
'no-undef': 'off',
'object-curly-spacing': ['error', 'always'],
'prefer-const': 'error',
quotes: ['error', 'single'],
semi: ['error', 'always'],
'space-before-blocks': 'error',
'space-before-function-paren': [
'error',
{
anonymous: 'never',
named: 'never',
asyncArrow: 'always',
},
],
'space-in-parens': 'error',
'space-infix-ops': 'error',
'space-unary-ops': 'error',
'spaced-comment': 'error',
yoda: 'error',
},
},
];

View file

@ -1,13 +1,14 @@
import { FastifyPluginAsync } from 'fastify' import { FastifyPluginAsync } from 'fastify';
import fastifyFormBody from '@fastify/formbody' import fastifyFormBody from '@fastify/formbody';
import fastifyMultipart from '@fastify/multipart' import fastifyMultipart from '@fastify/multipart';
import { mkdir } from 'node:fs/promises' import { mkdir } from 'node:fs/promises';
import fp from 'fastify-plugin' import fp from 'fastify-plugin';
import * as db from '@shared/database' import * as db from '@shared/database';
import { authPlugin, jwtPlugin } from '@shared/auth';
// @ts-ignore: import.meta.glob is a vite thing. Typescript doesn't know this... // @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
const plugins = import.meta.glob('./plugins/**/*.ts', { eager: true }); const plugins = import.meta.glob('./plugins/**/*.ts', { eager: true });
// @ts-ignore: import.meta.glob is a vite thing. Typescript doesn't know this... // @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
const routes = import.meta.glob('./routes/**/*.ts', { eager: true }); const routes = import.meta.glob('./routes/**/*.ts', { eager: true });
@ -20,30 +21,33 @@ declare module 'fastify' {
const app: FastifyPluginAsync = async ( const app: FastifyPluginAsync = async (
fastify, fastify,
opts _opts,
): Promise<void> => { ): Promise<void> => {
void _opts;
// Place here your custom code! // Place here your custom code!
for (const plugin of Object.values(plugins)) { for (const plugin of Object.values(plugins)) {
void fastify.register(plugin as any, {}); void fastify.register(plugin as FastifyPluginAsync, {});
} }
for (const route of Object.values(routes)) { for (const route of Object.values(routes)) {
void fastify.register(route as any, {}); void fastify.register(route as FastifyPluginAsync, {});
} }
await fastify.register(db.useDatabase as any, {}) await fastify.register(db.useDatabase as FastifyPluginAsync, {});
void fastify.register(fastifyFormBody, {}) await fastify.register(authPlugin as FastifyPluginAsync, {});
void fastify.register(fastifyMultipart, {}) await fastify.register(jwtPlugin as FastifyPluginAsync, {});
console.log(fastify.db.getUser(1));
void fastify.register(fastifyFormBody, {});
void fastify.register(fastifyMultipart, {});
// The use of fastify-plugin is required to be able // The use of fastify-plugin is required to be able
// to export the decorators to the outer scope // to export the decorators to the outer scope
void fastify.register(fp(async (fastify) => { void fastify.register(fp(async (fastify2) => {
const image_store = process.env.USER_ICONS_STORE ?? "/tmp/icons"; const image_store = process.env.USER_ICONS_STORE ?? '/tmp/icons';
fastify.decorate('image_store', image_store) fastify2.decorate('image_store', image_store);
await mkdir(fastify.image_store, { recursive: true }) await mkdir(fastify2.image_store, { recursive: true });
})) }));
} };
export default app export default app;
export { app } export { app };

View file

@ -1,5 +1,5 @@
import fp from 'fastify-plugin' import fp from 'fastify-plugin';
import sensible, { FastifySensibleOptions } from '@fastify/sensible' import sensible, { FastifySensibleOptions } from '@fastify/sensible';
/** /**
* This plugins adds some utilities to handle http errors * This plugins adds some utilities to handle http errors
@ -7,5 +7,5 @@ import sensible, { FastifySensibleOptions } from '@fastify/sensible'
* @see https://github.com/fastify/fastify-sensible * @see https://github.com/fastify/fastify-sensible
*/ */
export default fp<FastifySensibleOptions>(async (fastify) => { export default fp<FastifySensibleOptions>(async (fastify) => {
fastify.register(sensible) fastify.register(sensible);
}) });

View file

@ -1,49 +1,51 @@
import { FastifyPluginAsync } from 'fastify' import { FastifyPluginAsync } from 'fastify';
import { join } from 'node:path' import { join } from 'node:path';
import { open } from 'node:fs/promises' import { open } from 'node:fs/promises';
import sharp from 'sharp' import sharp from 'sharp';
import rawBody from 'raw-body' import rawBody from 'raw-body';
import { isNullish } from '@shared/utils' import { isNullish } from '@shared/utils';
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => { const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
// await fastify.register(authMethod, {}); // await fastify.register(authMethod, {});
// here we register plugins that will be active for the current fastify instance (aka everything in this function) // here we register plugins that will be active for the current fastify instance (aka everything in this function)
// we register a route handler for: `/<USERID_HERE>` // we register a route handler for: `/<USERID_HERE>`
// it sets some configuration options, and set the actual function that will handle the request // it sets some configuration options, and set the actual function that will handle the request
fastify.addContentTypeParser('*', function(request, payload, done: any) { fastify.addContentTypeParser('*', function(request, payload, done) {
done() done(null);
}); });
fastify.post('/:userid', async function(request, reply) { fastify.post<{ Params: { userid: string } }>('/:userid', async function(request, reply) {
let buffer = await rawBody(request.raw); const buffer = await rawBody(request.raw);
// this is how we get the `:userid` part of things // this is how we get the `:userid` part of things
const userid: string | undefined = (request.params as any)['userid']; const userid: string | undefined = (request.params)['userid'];
if (isNullish(userid)) { if (isNullish(userid)) {
return await reply.code(403); return await reply.code(403);
} }
const image_store: string = fastify.getDecorator('image_store') const image_store: string = fastify.getDecorator('image_store');
const image_path = join(image_store, userid) const image_path = join(image_store, userid);
try { try {
let img = sharp(buffer); const img = sharp(buffer);
img.resize({ img.resize({
height: 128, height: 128,
width: 128, width: 128,
fit: 'fill', fit: 'fill',
}) });
const data = await img.png({ compressionLevel: 6 }).toBuffer() const data = await img.png({ compressionLevel: 6 }).toBuffer();
let image_file = await open(image_path, "w", 0o666) const image_file = await open(image_path, 'w', 0o666);
await image_file.write(data); await image_file.write(data);
await image_file.close() await image_file.close();
} catch (e: any) { }
catch (e) {
fastify.log.error(`Error: ${e}`); fastify.log.error(`Error: ${e}`);
reply.code(400); reply.code(400);
return { status: "error", message: e.toString() }; return { status: 'error' };
} }
}) });
} };
export default route export default route;

View file

@ -1,7 +1,7 @@
// this sould only be used by the docker file ! // this sould only be used by the docker file !
import fastify, { FastifyInstance } from "fastify"; import fastify, { FastifyInstance } from 'fastify';
import app from "./app" import app from './app';
const start = async () => { const start = async () => {
const envToLogger = { const envToLogger = {
@ -16,15 +16,16 @@ const start = async () => {
}, },
production: true, production: true,
test: false, test: false,
} };
const f: FastifyInstance = fastify({ logger: envToLogger.development }); const f: FastifyInstance = fastify({ logger: envToLogger.development });
try { try {
await f.register(app, {}); await f.register(app, {});
await f.listen({ port: 80, host: '0.0.0.0' }) await f.listen({ port: 80, host: '0.0.0.0' });
} catch (err) {
f.log.error(err)
process.exit(1)
} }
} catch (err) {
start() f.log.error(err);
process.exit(1);
}
};
start();

View file

@ -1,8 +1,8 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths' import tsconfigPaths from 'vite-tsconfig-paths';
import nodeExternals from 'rollup-plugin-node-externals' import nodeExternals from 'rollup-plugin-node-externals';
import path from 'node:path' import path from 'node:path';
import fs from 'node:fs' import fs from 'node:fs';
function collectDeps(...pkgJsonPaths) { function collectDeps(...pkgJsonPaths) {
const allDeps = new Set(); const allDeps = new Set();
@ -20,27 +20,32 @@ function collectDeps(...pkgJsonPaths) {
const externals = collectDeps( const externals = collectDeps(
'./package.json', './package.json',
'../@shared/package.json' '../@shared/package.json',
); );
export default defineConfig({ export default defineConfig({
root: __dirname, // service root root: __dirname,
// service root
plugins: [tsconfigPaths(), nodeExternals()], plugins: [tsconfigPaths(), nodeExternals()],
build: { build: {
ssr: true, ssr: true,
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
lib: { lib: {
entry: path.resolve(__dirname, 'src/run.ts'), // adjust main entry entry: path.resolve(__dirname, 'src/run.ts'),
formats: ['cjs'], // CommonJS for Node.js // adjust main entry
formats: ['cjs'],
// CommonJS for Node.js
fileName: () => 'index.js', fileName: () => 'index.js',
}, },
rollupOptions: { rollupOptions: {
external: externals, external: externals,
}, },
target: 'node22', // or whatever Node version you use target: 'node22',
// or whatever Node version you use
sourcemap: false, sourcemap: false,
minify: true, // for easier debugging minify: true,
} // for easier debugging
}) },
});

1469
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
{ {
"name": "workspace", "name": "workspace",
"type": "module",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [
@ -7,14 +8,27 @@
"./icons", "./icons",
"./auth" "./auth"
], ],
"lint-staged": {
"*": [
"eslint --fix"
]
},
"scripts": { "scripts": {
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"fclean": "rimraf \"**/dist\"", "fclean": "rimraf \"**/dist\"",
"clean": "rimraf \"**/node_modules\"", "clean": "rimraf \"**/node_modules\"",
"install-all": "npm install" "install-all": "npm install",
"dev:prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"rimraf": "^5.0.1" "rimraf": "^5.0.1",
"@eslint/js": "^9.36.0",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"eslint": "^9.36.0",
"lint-staged": "^16.1.5",
"husky": "^9.1.7",
"typescript-eslint": "^8.44.1"
}, },
"dependencies": { "dependencies": {
"bindings": "^1.5.0" "bindings": "^1.5.0"