feat(auth/user): Finished User Rework to handle Guest

- Split userinfo APIs to their own service (`user`)
- Added user service to nginx and docker-compose
- Cleaned up package.json across the project to remove useless
  depedencies
- Added word list for Guest username generation (source in file itself)
- Reworked internal of `user` DB to not have a difference between "raw"
  id and normal ID (UUID)
This commit is contained in:
Maieul BOYER 2025-10-06 16:59:18 +02:00 committed by Maix0
parent 7d0f5c11d6
commit 1cbd778131
24 changed files with 4273 additions and 46 deletions

View file

@ -100,7 +100,7 @@ export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
JSON.stringify(makeResponse('notLoggedIn', 'auth.invalidKind')),
);
}
const user = this.db.getUserFromName(tok.who);
const user = this.db.getUser(tok.who);
if (isNullish(user)) {
return res
.clearCookie('token')

View file

@ -8,8 +8,9 @@ import * as bcrypt from 'bcrypt';
export interface IUserDb extends Database {
getUser(id: UserId): User | undefined,
getUserFromName(name: string): User | undefined,
getUserFromRawId(id: number): User | undefined,
getUser(id: string): User | undefined,
getUserOtpSecret(id: UserId): string | undefined,
createUser(name: string, password: string | undefined, guest: boolean): Promise<User | undefined>,
createUser(name: string, password: string | undefined): Promise<User | undefined>,
setUserPassword(id: UserId, password: string | undefined): Promise<User | undefined>,
ensureUserOtpSecret(id: UserId): string | undefined,
@ -17,17 +18,6 @@ export interface IUserDb extends Database {
};
export const UserImpl: Omit<IUserDb, keyof Database> = {
/**
* Get a user from an [UserId]
*
* @param id the userid to fetch
*
* @returns The user if it exists, undefined otherwise
*/
getUser(this: IUserDb, id: UserId): User | undefined {
return this.getUserFromRawId(id);
},
/**
* Get a user from a username [string]
*
@ -50,7 +40,7 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
*
* @returns The user if it exists, undefined otherwise
*/
getUserFromRawId(this: IUserDb, id: number): User | undefined {
getUser(this: IUserDb, id: string): User | undefined {
return userFromRow(
this.prepare('SELECT * FROM user WHERE id = @id LIMIT 1').get({
id,
@ -113,7 +103,7 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
},
};
export type UserId = number & { readonly __brand: unique symbol };
export type UserId = UUID;
export type User = {
readonly id: UserId;

View file

@ -61,7 +61,7 @@ bOtpDisable.addEventListener('click', async () => {
bWhoami.addEventListener('click', async () => {
let username = '';
try {
const res = await fetch('/api/auth/whoami');
const res = await fetch('/api/user/info/me');
const json = await res.json();
setResponse(json);
if (json?.kind === 'success') {username = json?.payload?.name;}

View file

@ -25,8 +25,7 @@
"@sinclair/typebox": "^0.34.40",
"fastify": "^5.0.0",
"fastify-cli": "^7.4.0",
"fastify-plugin": "^5.0.0",
"sharp": "^0.34.2"
"fastify-plugin": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.1.0",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
// Why does this file exists ?
// We want to make random-ish username for the guest, but still reconizable usernames
// So we do `${adjective}_${nouns}`
// there is around 30k combinaison, so we should be fine :)
import fp from 'fastify-plugin';
// @ts-expect-error: Ts can't load raw txt files - vite does it
import _adjectives from './files/adjectives.txt?raw';
// @ts-expect-error: Ts can't load raw txt files - vite does it
import _nouns from './files/nouns.txt?raw';
type WordsCategory = 'adjectives' | 'nouns';
type Words = { [k in WordsCategory]: string[] };
function toTitleCase(str: string) {
return str.replace(
/\w\S*/g,
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
}
// strong typing those import :)
const RAW_WORDS: { [k in WordsCategory]: string } = { adjectives: _adjectives, nouns: _nouns };
const WORDS: Words = Object.fromEntries(Object.entries(RAW_WORDS).map(([k, v]) => {
const words = v.split('\n').map(s => s.trim()).filter(s => !(s.startsWith('#') || s.length === 0)).map(toTitleCase);
return [k, words];
})) as Words;
export default fp<object>(async (fastify) => {
fastify.decorate('words', WORDS);
});
// When using .decorate you have to specify added properties for Typescript
declare module 'fastify' {
export interface FastifyInstance {
words: Words;
}
}

View file

@ -0,0 +1,54 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { typeResponse, makeResponse, isNullish } from '@shared/utils';
export const GuestLoginRes = Type.Union([
typeResponse('failed', 'login.failed.generic'),
typeResponse('success', 'login.success', {
token: Type.String({
description: 'JWT that represent a logged in user',
}),
}),
]);
export type GuestLoginRes = Static<typeof GuestLoginRes>;
const getRandomFromList = (list: string[]): string => {
return list[Math.floor(Math.random() * list.length)];
};
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post(
'/api/auth/guest',
{ schema: { response: { '2xx': GuestLoginRes } } },
async function(req, res) {
void req;
void res;
try {
const adjective = getRandomFromList(fastify.words.adjectives);
const noun = getRandomFromList(fastify.words.nouns);
const user = await this.db.createUser(
`${adjective} ${noun}`,
// no password
undefined,
// is a guest
true,
);
if (isNullish(user)) {
return makeResponse('failed', 'login.failed.generic');
}
return makeResponse('success', 'login.success', {
token: this.signJwt('auth', user.id),
});
}
catch {
return makeResponse('failed', 'login.failed.generic');
}
},
);
};
export default route;

View file

@ -44,7 +44,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
}
// get the Otp sercret from the db
const user = this.db.getUserFromName(dJwt.who);
const user = this.db.getUser(dJwt.who);
if (isNullish(user?.otp)) {
// oops, either no user, or user without otpSecret
// fuck off

View file

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

View file

@ -6,6 +6,7 @@
"workspaces": [
"./@shared",
"./icons",
"./user",
"./auth"
],
"lint-staged": {

2
src/user/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
/dist
/node_modules

8
src/user/entrypoint.sh Normal file
View file

@ -0,0 +1,8 @@
#!/bin/sh
set -e
set -x
# do anything here
# run the CMD [ ... ] from the dockerfile
exec "$@"

0
src/user/extra/.gitkeep Normal file
View file

36
src/user/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"type": "module",
"private": false,
"name": "user",
"version": "1.0.0",
"description": "This project was bootstrapped with Fastify-CLI.",
"main": "app.ts",
"directories": {
"test": "test"
},
"scripts": {
"start": "npm run build && node dist/run.js",
"build": "vite build",
"build:prod": "vite build --outDir=/dist --minify=true --sourcemap=false"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/autoload": "^6.3.1",
"@fastify/formbody": "^8.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/sensible": "^6.0.0",
"@fastify/static": "^8.2.0",
"@sinclair/typebox": "^0.34.40",
"fastify": "^5.0.0",
"fastify-cli": "^7.4.0",
"fastify-plugin": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.1.0",
"rollup-plugin-node-externals": "^8.0.1",
"vite": "^7.0.6",
"vite-tsconfig-paths": "^5.1.4"
}
}

38
src/user/src/app.ts Normal file
View file

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

View file

@ -0,0 +1,16 @@
# Plugins Folder
Plugins define behavior that is common to all the routes in your
application. Authentication, caching, templates, and all the other cross
cutting concerns should be handled by plugins placed in this folder.
Files in this folder are typically defined through the
[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module,
making them non-encapsulated. They can define decorators and set hooks
that will then be used in the rest of your application.
Check out:
* [The hitchhiker's guide to plugins](https://fastify.dev/docs/latest/Guides/Plugins-Guide/)
* [Fastify decorators](https://fastify.dev/docs/latest/Reference/Decorators/).
* [Fastify lifecycle](https://fastify.dev/docs/latest/Reference/Lifecycle/).

View file

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

View file

@ -0,0 +1,56 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { isNullish, makeResponse, typeResponse } from '@shared/utils';
export const UserInfoRes = Type.Union([
typeResponse('success', 'userinfo.success', { name: Type.String(), id: Type.String(), guest: Type.Boolean() }),
typeResponse('failure', ['userinfo.failure.generic', 'userinfo.failure.unknownUser', 'userinfo.failure.notLoggedIn']),
]);
export type UserInfoRes = Static<typeof UserInfoRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get<{ Params: { user: string } }>(
'/api/user/info/:user',
{ schema: { response: { '2xx': UserInfoRes } }, config: { requireAuth: true } },
async function(req, _res) {
void _res;
if (isNullish(req.authUser)) { return makeResponse('failure', 'userinfo.failure.notLoggedIn'); }
if (isNullish(req.params.user) || req.params.user.length === 0) {
return makeResponse('failure', 'userinfo.failure.unknownUser');
}
// if the param is the special value `me`, then just get the id from the currently auth'ed user
if (req.params.user === 'me') {
req.params.user = req.authUser.id;
}
const user = this.db.getUser(req.params.user);
if (isNullish(user)) {
return makeResponse('failure', 'userinfo.failure.unknownUser');
}
const payload = {
name: user.name,
id: user.id,
// the !! converts a value from <something> to either `true` or `false`
// it uses the same convention from using <something> in a if, meaning that
// ```
// let val;
// if (something) { val = true; }
// else { val = false; }
// ```
// is the same as `val = !!something`
guest: !!user.guest,
};
return makeResponse('success', 'userinfo.success', payload);
},
);
};
export default route;

35
src/user/src/run.ts Normal file
View file

@ -0,0 +1,35 @@
// this sould only be used by the docker file !
import fastify, { FastifyInstance } from 'fastify';
import app from './app';
const start = async () => {
const envToLogger = {
development: {
transport: {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
},
production: true,
test: false,
};
const f: FastifyInstance = fastify({ logger: envToLogger.development });
process.on('SIGTERM', () => {
f.log.info('Requested to shutdown');
process.exit(134);
});
try {
await f.register(app, {});
await f.listen({ port: 80, host: '0.0.0.0' });
}
catch (err) {
f.log.error(err);
process.exit(1);
}
};
start();

5
src/user/tsconfig.json Normal file
View file

@ -0,0 +1,5 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {},
"include": ["src/**/*.ts"]
}

51
src/user/vite.config.js Normal file
View file

@ -0,0 +1,51 @@
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import nodeExternals from 'rollup-plugin-node-externals';
import path from 'node:path';
import fs from 'node:fs';
function collectDeps(...pkgJsonPaths) {
const allDeps = new Set();
for (const pkgPath of pkgJsonPaths) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
for (const dep of Object.keys(pkg.dependencies || {})) {
allDeps.add(dep);
}
for (const peer of Object.keys(pkg.peerDependencies || {})) {
allDeps.add(peer);
}
}
return Array.from(allDeps);
}
const externals = collectDeps(
'./package.json',
'../@shared/package.json',
);
export default defineConfig({
root: __dirname,
// service root
plugins: [tsconfigPaths(), nodeExternals()],
build: {
ssr: true,
outDir: 'dist',
emptyOutDir: true,
lib: {
entry: path.resolve(__dirname, 'src/run.ts'),
// adjust main entry
formats: ['cjs'],
// CommonJS for Node.js
fileName: () => 'index.js',
},
rollupOptions: {
external: externals,
},
target: 'node22',
// or whatever Node version you use
sourcemap: true,
minify: false,
// for easier debugging
},
});