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:
commit
492647b817
75 changed files with 3555 additions and 1497 deletions
|
|
@ -25,6 +25,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.19.1"
|
||||
"@types/node": "^22.19.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
src/auth/src/routes/changePassword.ts
Normal file
44
src/auth/src/routes/changePassword.ts
Normal 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;
|
||||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/dist
|
||||
/node_modules
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
set -x
|
||||
# do anything here
|
||||
|
||||
cp -r /extra /files
|
||||
|
||||
# run the CMD [ ... ] from the dockerfile
|
||||
exec "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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/).
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
});
|
||||
360
src/openapi.json
360
src/openapi.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1168
src/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,9 @@ packages:
|
|||
nodeLinker: hoisted
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- sharp
|
||||
- bcrypt
|
||||
- better-sqlite3
|
||||
- core-js
|
||||
- esbuild
|
||||
- protobufjs
|
||||
- sharp
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
src/user/src/routes/changeDisplayName.ts
Normal file
44
src/user/src/routes/changeDisplayName.ts
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue