feat(users): Adding the profile page and the TOTP connection

Added user profile page (/profile) with display name, password, and TOTP management
Implemented TOTP authentication flow in the login process
Added backend APIs for changing display name and password
Made user display names unique in the database
Removed the entire icons service (server, routes, and Docker configuration)
Added collision-avoidance logic for duplicate display names during user creation
This commit is contained in:
Raphaël 2025-12-10 18:09:53 +01:00 committed by GitHub
commit 492647b817
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 3555 additions and 1497 deletions

View file

@ -25,6 +25,6 @@
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1"
"@types/node": "^22.19.2"
}
}

View file

@ -14,6 +14,7 @@ const kRouteAuthDone = Symbol('shared-route-auth-done');
type AuthedUser = {
id: UserId;
name: string;
guest: boolean;
};
declare module 'fastify' {
@ -118,7 +119,7 @@ export const authPlugin = fp<{ onlySchema?: boolean }>(async (fastify, { onlySch
.clearCookie('token', { path: '/' })
.makeResponse(401, 'notLoggedIn', 'auth.noUser');
}
req.authUser = { id: user.id, name: user.display_name };
req.authUser = { id: user.id, name: user.name, guest: user.guest };
}
catch {
return res

View file

@ -18,7 +18,7 @@ Project Transcendance {
Table user {
id text [PK, not null]
login text [unique]
name text [not null]
name text [not null, unique]
password text [null, Note: "If password is NULL, this means that the user is created through OAUTH2 or guest login"]
otp text [null, Note: "If otp is NULL, then the user didn't configure 2FA"]
guest integer [not null, default: 0]

View file

@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY NOT NULL,
login TEXT UNIQUE,
name TEXT NOT NULL,
name TEXT NOT NULL UNIQUE,
password TEXT,
otp TEXT,
guest INTEGER NOT NULL DEFAULT 0,

View file

@ -3,6 +3,7 @@ import { Otp } from '@shared/auth';
import { isNullish } from '@shared/utils';
import * as bcrypt from 'bcrypt';
import { UUID, newUUID } from '@shared/utils/uuid';
import { SqliteError } from 'better-sqlite3';
// never use this directly
@ -20,6 +21,10 @@ export interface IUserDb extends Database {
getAllUserFromProvider(provider: string): User[] | undefined,
getAllUsers(this: IUserDb): User[] | undefined,
updateDisplayName(id: UserId, new_name: string): boolean,
getUserFromDisplayName(name: string): User | undefined,
};
export const UserImpl: Omit<IUserDb, keyof Database> = {
@ -159,6 +164,24 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
const req = this.prepare('SELECT * FROM user WHERE oauth2 = @oauth2').get({ oauth2: `${provider}:${unique}` }) as Partial<User> | undefined;
return userFromRow(req);
},
updateDisplayName(this: IUserDb, id: UserId, new_name: string): boolean {
try {
this.prepare('UPDATE OR FAIL user SET name = @new_name WHERE id = @id').run({ id, new_name });
return true;
}
catch (e) {
if (e instanceof SqliteError) {
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return false;
}
throw e;
}
},
getUserFromDisplayName(this: IUserDb, name: string) {
const res = this.prepare('SELECT * FROM user WHERE name = @name LIMIT 1').get({ name }) as User | undefined;
return userFromRow(res);
},
};
export type UserId = UUID;
@ -170,7 +193,7 @@ export type User = {
readonly password?: string;
readonly otp?: string;
readonly guest: boolean;
// will be split/merged from the `provider` column
// will be split/merged from the `oauth2` column
readonly provider_name?: string;
readonly provider_unique?: string;
};
@ -207,7 +230,7 @@ async function hashPassword(
*
* @returns The user if it exists, undefined otherwise
*/
export function userFromRow(row?: Partial<Omit<User, 'provider_name' | 'provider_unique'> & { provider?: string }>): User | undefined {
export function userFromRow(row?: Partial<Omit<User, 'provider_name' | 'provider_unique'> & { oauth2?: string }>): User | undefined {
if (isNullish(row)) return undefined;
if (isNullish(row.id)) return undefined;
if (isNullish(row.name)) return undefined;
@ -216,9 +239,9 @@ export function userFromRow(row?: Partial<Omit<User, 'provider_name' | 'provider
let provider_name = undefined;
let provider_unique = undefined;
if (row.provider) {
const splitted = row.provider.split(':', 1);
if (splitted.length != 2) { return undefined; }
if (row.oauth2) {
const splitted = row.oauth2.split(/:(.*)/);
if (splitted.length != 3) { return undefined; }
provider_name = splitted[0];
provider_unique = splitted[1];
}

View file

@ -8,6 +8,140 @@
"schemas": {}
},
"paths": {
"/api/auth/changePassword": {
"post": {
"operationId": "changePassword",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"new_password"
],
"properties": {
"new_password": {
"type": "string"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"changePassword.success"
]
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"changePassword.failed.toolong",
"changePassword.failed.tooshort",
"changePassword.failed.invalid"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"changePassword.failed.generic"
]
}
}
}
}
}
}
}
}
},
"/api/auth/disableOtp": {
"put": {
"operationId": "disableOtp",
@ -38,6 +172,32 @@
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"disableOtp.failure.guest"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
@ -139,6 +299,32 @@
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"enableOtp.failure.guest"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
@ -278,6 +464,20 @@
"/api/auth/guest": {
"post": {
"operationId": "guestLogin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Default Response",
@ -318,6 +518,32 @@
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"guestLogin.failed.invalid"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
@ -846,12 +1072,12 @@
"payload": {
"type": "object",
"required": [
"url"
"secret"
],
"properties": {
"url": {
"secret": {
"type": "string",
"description": "The otp url to feed into a 2fa app"
"description": "The otp secret"
}
}
}

View file

@ -30,9 +30,9 @@
"typebox": "^1.0.61"
},
"devDependencies": {
"@types/node": "^22.19.1",
"@types/node": "^22.19.2",
"rollup-plugin-node-externals": "^8.1.2",
"vite": "^7.2.6",
"vite": "^7.2.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

View file

@ -0,0 +1,44 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox';
import { typeResponse, MakeStaticResponse } from '@shared/utils';
const ChangePasswordReq = Type.Object({
new_password: Type.String(),
});
type ChangePasswordReq = Static<typeof ChangePasswordReq>;
const ChangePasswordRes = {
'500': typeResponse('failed',
'changePassword.failed.generic'),
'400': typeResponse('failed', [
'changePassword.failed.toolong',
'changePassword.failed.tooshort',
'changePassword.failed.invalid',
]),
'200': typeResponse('success', 'changePassword.success'),
};
type ChangePasswordRes = MakeStaticResponse<typeof ChangePasswordRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: ChangePasswordReq }>(
'/api/auth/changePassword',
{ schema: { body: ChangePasswordReq, response: ChangePasswordRes, operationId: 'changePassword' }, config: { requireAuth: true } },
async function(req, res) {
const password = req.body.new_password;
if (password.length < 8) { return res.makeResponse(400, 'failed', 'changePassword.failed.tooshort'); }
if (password.length > 64) { return res.makeResponse(400, 'failed', 'changePassword.failed.toolong'); }
// password is good too !
await this.db.setUserPassword(req.authUser!.id, password);
return res.makeResponse(200, 'success', 'changePassword.success');
},
);
};
export default route;

View file

@ -7,6 +7,7 @@ import { typeResponse, isNullish } from '@shared/utils';
export const DisableOtpRes = {
'200': typeResponse('success', 'disableOtp.success'),
'500': typeResponse('failure', 'disableOtp.failure.generic'),
'400': typeResponse('failure', 'disableOtp.failure.guest'),
};
@ -18,6 +19,13 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
async function(req, res) {
void res;
if (isNullish(req.authUser)) { return res.makeResponse(500, 'failure', 'disableOtp.failure.generic'); }
if (req.authUser.guest) {
return res.makeResponse(
400,
'failure',
'disableOtp.failure.guest',
);
}
this.db.deleteUserOtpSecret(req.authUser.id);
return res.makeResponse(200, 'success', 'disableOtp.success');
},

View file

@ -10,6 +10,7 @@ export const EnableOtpRes = {
url: Type.String({ description: 'The otp url to feed into a 2fa app' }),
}),
'401': typeResponse('failure', ['enableOtp.failure.noUser', 'enableOtp.failure.noSecret']),
'400': typeResponse('failure', ['enableOtp.failure.guest']),
};
export type EnableOtpRes = MakeStaticResponse<typeof EnableOtpRes>;
@ -21,6 +22,13 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
{ schema: { response: EnableOtpRes, operationId: 'enableOtp' }, config: { requireAuth: true } },
async function(req, res) {
if (isNullish(req.authUser)) { return res.makeResponse(403, 'failure', 'enableOtp.failure.noUser'); }
if (req.authUser.guest) {
return res.makeResponse(
400,
'failure',
'enableOtp.failure.guest',
);
}
const otpSecret = this.db.ensureUserOtpSecret(req.authUser!.id);
if (isNullish(otpSecret)) { return res.makeResponse(403, 'failure', 'enableOtp.failure.noSecret'); }

View file

@ -1,39 +1,95 @@
import { FastifyPluginAsync } from 'fastify';
import { Type } from 'typebox';
import { Static, Type } from 'typebox';
import { typeResponse, isNullish, MakeStaticResponse } from '@shared/utils';
export const GuestLoginRes = {
'500': typeResponse('failed', ['guestLogin.failed.generic.unknown', 'guestLogin.failed.generic.error']),
'500': typeResponse('failed', [
'guestLogin.failed.generic.unknown',
'guestLogin.failed.generic.error',
]),
'200': typeResponse('success', 'guestLogin.success', {
token: Type.String({
description: 'JWT that represent a logged in user',
}),
}),
'400': typeResponse('failed', 'guestLogin.failed.invalid'),
};
export type GuestLoginRes = MakeStaticResponse<typeof GuestLoginRes>;
export const GuestLoginReq = Type.Object({
name: Type.Optional(Type.String()),
});
export type GuestLoginReq = Static<typeof GuestLoginReq>;
const getRandomFromList = (list: string[]): string => {
return list[Math.floor(Math.random() * list.length)];
};
const USERNAME_CHECK: RegExp = /^[a-zA-Z_0-9]+$/;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: null, Reply: GuestLoginRes }>(
fastify.post<{ Body: GuestLoginReq; Reply: GuestLoginRes }>(
'/api/auth/guest',
{ schema: { response: GuestLoginRes, operationId: 'guestLogin' } },
{
schema: {
body: GuestLoginReq,
response: GuestLoginRes,
operationId: 'guestLogin',
},
},
async function(req, res) {
void req;
void res;
try {
console.log('DEBUG ----- guest login backend');
const adjective = getRandomFromList(fastify.words.adjectives);
const noun = getRandomFromList(fastify.words.nouns);
let user_name: string | undefined = req.body?.name;
if (isNullish(user_name)) {
const adjective = getRandomFromList(
fastify.words.adjectives,
);
const noun = getRandomFromList(fastify.words.nouns);
user_name = `${adjective}${noun}`;
}
else {
if (user_name.length < 4 || user_name.length > 26) {
return res.makeResponse(
400,
'failed',
'guestLogin.failed.invalid',
);
}
if (!USERNAME_CHECK.test(user_name)) {
return res.makeResponse(
400,
'failed',
'guestLogin.failed.invalid',
);
}
user_name = `g_${user_name}`;
}
const user = await this.db.createGuestUser(`${adjective} ${noun}`);
const orig = user_name;
let i = 0;
while (
this.db.getUserFromDisplayName(user_name) !== undefined &&
i++ < 5
) {
user_name = `${orig}${Date.now() % 1000}`;
}
if (this.db.getUserFromDisplayName(user_name) !== undefined) {
user_name = `${orig}${Date.now()}`;
}
const user = await this.db.createGuestUser(user_name);
if (isNullish(user)) {
return res.makeResponse(500, 'failed', 'guestLogin.failed.generic.unknown');
return res.makeResponse(
500,
'failed',
'guestLogin.failed.generic.unknown',
);
}
return res.makeResponse(200, 'success', 'guestLogin.success', {
token: this.signJwt('auth', user.id.toString()),
@ -41,7 +97,11 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
}
catch (e: unknown) {
fastify.log.error(e);
return res.makeResponse(500, 'failed', 'guestLogin.failed.generic.error');
return res.makeResponse(
500,
'failed',
'guestLogin.failed.generic.error',
);
}
},
);

View file

@ -30,9 +30,23 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
const result = await creq.getCode();
const userinfo = await provider.getUserInfo(result);
let u = this.db.getOauth2User(provider.display_name, userinfo.unique_id);
if (isNullish(u)) {
u = await this.db.createOauth2User(userinfo.name, provider.display_name, userinfo.unique_id);
let user_name = userinfo.name;
const orig = user_name;
let i = 0;
while (
this.db.getUserFromDisplayName(user_name) !== undefined &&
i++ < 100
) {
user_name = `${orig}${Date.now() % 1000}`;
}
if (this.db.getUserFromDisplayName(user_name) !== undefined) {
user_name = `${orig}${Date.now()}`;
}
u = await this.db.createOauth2User(user_name, provider.display_name, userinfo.unique_id);
}
if (isNullish(u)) {
return res.code(500).send('failed to fetch or create user...');

View file

@ -47,7 +47,19 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
// password is good too !
if (this.db.getUserFromLoginName(name) !== undefined) { return res.makeResponse(400, 'failed', 'signin.failed.username.existing'); }
const u = await this.db.createUser(name, name, password);
let user_name = name;
const orig = user_name;
let i = 0;
while (
this.db.getUserFromDisplayName(user_name) !== undefined &&
i++ < 100
) {
user_name = `${orig}${Date.now() % 1000}`;
}
if (this.db.getUserFromDisplayName(user_name) !== undefined) {
user_name = `${orig}${Date.now()}`;
}
const u = await this.db.createUser(name, user_name, password);
if (isNullish(u)) { return res.makeResponse(500, 'failed', 'signin.failed.generic'); }
// every check has been passed, they are now logged in, using this token to say who they are...

View file

@ -2,12 +2,12 @@ import { FastifyPluginAsync } from 'fastify';
import { Type } from 'typebox';
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
import { Otp } from '@shared/auth';
export const StatusOtpRes = {
200: 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', {
secret: Type.String({ description: 'The otp secret' }),
}),
typeResponse('success', 'statusOtp.success.disabled'),
]),
500: typeResponse('failure', 'statusOtp.failure.generic'),
@ -19,13 +19,32 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get(
'/api/auth/statusOtp',
{ schema: { response: StatusOtpRes, operationId: 'statusOtp' }, config: { requireAuth: true } },
{
schema: { response: StatusOtpRes, operationId: 'statusOtp' },
config: { requireAuth: true },
},
async function(req, res) {
if (isNullish(req.authUser)) { return res.makeResponse(500, 'failure', 'statusOtp.failure.generic'); }
if (isNullish(req.authUser)) {
return res.makeResponse(
500,
'failure',
'statusOtp.failure.generic',
);
}
const otpSecret = this.db.getUserOtpSecret(req.authUser.id);
if (isNullish(otpSecret)) { return res.makeResponse(200, 'success', 'statusOtp.success.disabled'); }
const otp = new Otp({ secret: otpSecret });
return res.makeResponse(200, 'success', 'statusOtp.success.enabled', { url: otp.totpURL });
if (isNullish(otpSecret)) {
return res.makeResponse(
200,
'success',
'statusOtp.success.disabled',
);
}
return res.makeResponse(
200,
'success',
'statusOtp.success.enabled',
{ secret: otpSecret },
);
},
);
};

View file

@ -30,9 +30,9 @@
"typebox": "^1.0.61"
},
"devDependencies": {
"@types/node": "^22.19.1",
"@types/node": "^22.19.2",
"rollup-plugin-node-externals": "^8.1.2",
"vite": "^7.2.6",
"vite": "^7.2.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

View file

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

View file

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

View file

@ -1,37 +0,0 @@
{
"type": "module",
"private": false,
"name": "icons",
"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.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^8.3.0",
"fastify": "^5.6.2",
"fastify-cli": "^7.4.1",
"fastify-plugin": "^5.1.0",
"raw-body": "^3.0.2",
"sharp": "^0.34.5"
},
"devDependencies": {
"@types/node": "^22.19.1",
"rollup-plugin-node-externals": "^8.1.2",
"vite": "^7.2.6",
"vite-tsconfig-paths": "^5.1.4"
}
}

View file

@ -1,54 +0,0 @@
import { FastifyPluginAsync } from 'fastify';
import fastifyFormBody from '@fastify/formbody';
import fastifyMultipart from '@fastify/multipart';
import { mkdir } from 'node:fs/promises';
import fp from 'fastify-plugin';
import * as db from '@shared/database';
import * as utils from '@shared/utils';
import { authPlugin, jwtPlugin } 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;
// 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, {});
}
await fastify.register(utils.useMonitoring);
await fastify.register(db.useDatabase as FastifyPluginAsync, {});
await fastify.register(authPlugin as FastifyPluginAsync, {});
await fastify.register(jwtPlugin as FastifyPluginAsync, {});
void fastify.register(fastifyFormBody, {});
void fastify.register(fastifyMultipart, {});
// The use of fastify-plugin is required to be able
// to export the decorators to the outer scope
void fastify.register(fp(async (fastify2) => {
const image_store = process.env.USER_ICONS_STORE ?? '/tmp/icons';
fastify2.decorate('image_store', image_store);
await mkdir(fastify2.image_store, { recursive: true });
}));
};
export default app;
export { app };

View file

@ -1,16 +0,0 @@
# 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

@ -1,11 +0,0 @@
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

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

View file

@ -1,35 +0,0 @@
// 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();

View file

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

View file

@ -1,51 +0,0 @@
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: false,
minify: true,
// for easier debugging
},
});

View file

@ -21,6 +21,143 @@
}
],
"paths": {
"/api/auth/changePassword": {
"post": {
"operationId": "changePassword",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"new_password"
],
"properties": {
"new_password": {
"type": "string"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"changePassword.success"
]
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"changePassword.failed.toolong",
"changePassword.failed.tooshort",
"changePassword.failed.invalid"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"changePassword.failed.generic"
]
}
}
}
}
}
}
},
"tags": [
"openapi_other"
]
}
},
"/api/auth/disableOtp": {
"put": {
"operationId": "disableOtp",
@ -51,6 +188,32 @@
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"disableOtp.failure.guest"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
@ -155,6 +318,32 @@
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"enableOtp.failure.guest"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
@ -300,6 +489,20 @@
"/api/auth/guest": {
"post": {
"operationId": "guestLogin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Default Response",
@ -340,6 +543,32 @@
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"guestLogin.failed.invalid"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
@ -883,12 +1112,12 @@
"payload": {
"type": "object",
"required": [
"url"
"secret"
],
"properties": {
"url": {
"secret": {
"type": "string",
"description": "The otp url to feed into a 2fa app"
"description": "The otp secret"
}
}
}
@ -1005,6 +1234,117 @@
]
}
},
"/api/user/changeDisplayName": {
"put": {
"operationId": "changeDisplayName",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "New Display Name"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"changeDisplayName.success"
]
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"changeDisplayName.alreadyExist",
"changeDisplayName.invalid"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
}
}
}
},
"tags": [
"openapi_other"
]
}
},
"/api/user/info/{user}": {
"get": {
"operationId": "getUser",
@ -1069,6 +1409,20 @@
},
"guest": {
"type": "boolean"
},
"selfInfo": {
"type": "object",
"properties": {
"login_name": {
"type": "string"
},
"provider_id": {
"type": "string"
},
"provider_user": {
"type": "string"
}
}
}
}
}

View file

@ -23,20 +23,17 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@openapitools/openapi-generator-cli": "^2.25.2",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"eslint": "^9.39.1",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"openapi-generator-cli": "^1.0.0",
"openapi-typescript": "^7.10.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6"
"typescript-eslint": "^8.49.0",
"vite": "^7.2.7"
},
"dependencies": {
"@redocly/cli": "^2.12.3",
"@redocly/cli": "^2.12.5",
"bindings": "^1.5.0"
}
}

1168
src/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,9 @@ packages:
nodeLinker: hoisted
onlyBuiltDependencies:
- better-sqlite3
- esbuild
- sharp
- bcrypt
- better-sqlite3
- core-js
- esbuild
- protobufjs
- sharp

View file

@ -8,6 +8,114 @@
"schemas": {}
},
"paths": {
"/api/user/changeDisplayName": {
"put": {
"operationId": "changeDisplayName",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "New Display Name"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"changeDisplayName.success"
]
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"changeDisplayName.alreadyExist",
"changeDisplayName.invalid"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
}
}
}
}
}
},
"/api/user/info/{user}": {
"get": {
"operationId": "getUser",
@ -72,6 +180,20 @@
},
"guest": {
"type": "boolean"
},
"selfInfo": {
"type": "object",
"properties": {
"login_name": {
"type": "string"
},
"provider_id": {
"type": "string"
},
"provider_user": {
"type": "string"
}
}
}
}
}

View file

@ -29,9 +29,9 @@
"typebox": "^1.0.61"
},
"devDependencies": {
"@types/node": "^22.19.1",
"@types/node": "^22.19.2",
"rollup-plugin-node-externals": "^8.1.2",
"vite": "^7.2.6",
"vite": "^7.2.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

View file

@ -0,0 +1,44 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox';
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
export const ChangeDisplayNameRes = {
'200': typeResponse('success', 'changeDisplayName.success'),
'400': typeResponse('failure', ['changeDisplayName.alreadyExist', 'changeDisplayName.invalid']),
};
export type ChangeDisplayNameRes = MakeStaticResponse<typeof ChangeDisplayNameRes>;
export const ChangeDisplayNameReq = Type.Object({ name: Type.String({ description: 'New Display Name' }) });
type ChangeDisplayNameReq = Static<typeof ChangeDisplayNameReq>;
const USERNAME_CHECK: RegExp = /^[a-zA-Z_0-9]+$/;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.put<{ Body: ChangeDisplayNameReq }>(
'/api/user/changeDisplayName',
{ schema: { body: ChangeDisplayNameReq, response: ChangeDisplayNameRes, operationId: 'changeDisplayName' }, config: { requireAuth: true } },
async function(req, res) {
if (isNullish(req.authUser)) return;
if (isNullish(req.body.name)) {
return res.makeResponse(400, 'failure', 'changeDisplayName.invalid');
}
if (req.body.name.length < 4 || req.body.name.length > 32) {
return res.makeResponse(400, 'failure', 'changeDisplayName.invalid');
}
if (!USERNAME_CHECK.test(req.body.name)) {
return res.makeResponse(400, 'failure', 'changeDisplayName.invalid');
}
if (this.db.updateDisplayName(req.authUser.id, req.body.name)) {
return res.makeResponse(200, 'success', 'changeDisplayName.success');
}
else {
return res.makeResponse(400, 'failure', 'changeDisplayName.alreadyExist');
}
},
);
};
export default route;

View file

@ -7,6 +7,11 @@ import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
export const UserInfoRes = {
'200': typeResponse('success', 'userinfo.success', {
name: Type.String(), id: Type.String(), guest: Type.Boolean(),
selfInfo: Type.Optional(Type.Object({
login_name: Type.Optional(Type.String()),
provider_id: Type.Optional(Type.String()),
provider_user: Type.Optional(Type.String()),
})),
}),
'403': typeResponse('failure', 'userinfo.failure.notLoggedIn'),
'404': typeResponse('failure', 'userinfo.failure.unknownUser'),
@ -38,13 +43,12 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
if (req.params.user === 'me') {
req.params.user = req.authUser.id;
}
const askSelf = req.params.user === req.authUser.id;
const user = this.db.getUser(req.params.user);
if (isNullish(user)) {
return res.makeResponse(404, 'failure', 'userinfo.failure.unknownUser');
}
const payload = {
name: user.name,
id: user.id,
@ -57,6 +61,11 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
// ```
// is the same as `val = !!something`
guest: !!user.guest,
selfInfo: askSelf ? {
login_name: user.login,
provider_id: user.provider_name,
provider_user: user.provider_unique,
} : null,
};
return res.makeResponse(200, 'success', 'userinfo.success', payload);