friends-backend

This commit is contained in:
Maieul BOYER 2026-01-16 16:18:54 +01:00 committed by Nigel
parent df79bc5a80
commit 590604b385
22 changed files with 1826 additions and 29 deletions

View file

@ -4,17 +4,19 @@ import { FastifyInstance, FastifyPluginAsync } from 'fastify';
import { Database as DbImpl } from './mixin/_base';
import { IUserDb, UserImpl } from './mixin/user';
import { IBlockedDb, BlockedImpl } from './mixin/blocked';
import { IFriendsDb, FriendsImpl } from './mixin/friends';
import { ITicTacToeDb, TicTacToeImpl } from './mixin/tictactoe';
import { IPongDb, PongImpl } from './mixin/pong';
import { ITournamentDb, TournamentImpl } from './mixin/tournament';
Object.assign(DbImpl.prototype, UserImpl);
Object.assign(DbImpl.prototype, BlockedImpl);
Object.assign(DbImpl.prototype, FriendsImpl);
Object.assign(DbImpl.prototype, TicTacToeImpl);
Object.assign(DbImpl.prototype, PongImpl);
Object.assign(DbImpl.prototype, TournamentImpl);
export interface Database extends DbImpl, IUserDb, IBlockedDb, ITicTacToeDb, IPongDb, ITournamentDb { }
export interface Database extends DbImpl, IUserDb, IBlockedDb, ITicTacToeDb, IPongDb, ITournamentDb, IFriendsDb { }
// When using .decorate you have to specify added properties for Typescript
declare module 'fastify' {

View file

@ -27,6 +27,15 @@ CREATE TABLE IF NOT EXISTS blocked (
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_user_pair ON blocked (user, blocked);
CREATE TABLE IF NOT EXISTS friends (
id INTEGER PRIMARY KEY NOT NULL,
user TEXT NOT NULL,
friend TEXT NOT NULL,
FOREIGN KEY (user) REFERENCES user (id) FOREIGN KEY (friend) REFERENCES user (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_friends_user_pair ON friends (user, friend);
----------------
-- TICTACTOE --
----------------

View file

@ -0,0 +1,72 @@
import { isNullish } from '@shared/utils';
import type { Database } from './_base';
import { UserId } from './user';
// describe every function in the object
export interface IFriendsDb extends Database {
getFriendsUserFor(id: UserId): FriendsData[],
addFriendsUserFor(id: UserId, friend: UserId): void,
removeFriendsUserFor(id: UserId, friend: UserId): void,
removeAllFriendUserFor(id: UserId): void,
getAllFriendsUsers(this: IFriendsDb): FriendsData[] | undefined,
};
export const FriendsImpl: Omit<IFriendsDb, keyof Database> = {
getFriendsUserFor(this: IFriendsDb, id: UserId): FriendsData[] {
const query = this.prepare('SELECT * FROM friends WHERE user = @id');
const data = query.all({ id }) as Partial<FriendsData>[];
return data.map(friendsFromRow).filter(b => !isNullish(b));
},
removeAllFriendUserFor(this: IFriendsDb, id: UserId): void {
this.prepare('DELETE FROM friends WHERE user = @id').run({ id });
},
addFriendsUserFor(this: IFriendsDb, id: UserId, friend: UserId): void {
this.prepare('INSERT OR IGNORE INTO friends (user, friend) VALUES (@id, @friend)').run({ id, friend });
},
removeFriendsUserFor(this: IFriendsDb, id: UserId, friend: UserId): void {
this.prepare('DELETE FROM friends WHERE user = @id AND friend = @friend').run({ id, friend });
},
/**
* Get all friends user
*
* @param
*
* @returns The list of users if it exists, undefined otherwise
*/
getAllFriendsUsers(this: IFriendsDb): FriendsData[] {
const rows = this.prepare('SELECT * FROM friends').all() as Partial<FriendsData>[];
return rows
.map(row => friendsFromRow(row))
.filter((u): u is FriendsData => u !== undefined);
},
};
export type FriendsId = number & { readonly __brand: unique symbol };
export type FriendsData = {
readonly id: FriendsId;
readonly user: UserId;
readonly friend: UserId;
};
/**
* Get a friends from a row
*
* @param row The data from sqlite
*
* @returns The friends if it exists, undefined otherwise
*/
export function friendsFromRow(row?: Partial<FriendsData>): FriendsData | undefined {
if (isNullish(row)) return undefined;
if (isNullish(row.id)) return undefined;
if (isNullish(row.user)) return undefined;
if (isNullish(row.friend)) return undefined;
return row as FriendsData;
}

21
src/icons/openapi.json Normal file
View file

@ -0,0 +1,21 @@
{
"openapi": "3.1.0",
"info": {
"version": "9.6.1",
"title": "@fastify/swagger"
},
"components": {
"schemas": {}
},
"paths": {},
"servers": [
{
"url": "https://local.maix.me:8888",
"description": "direct from docker"
},
{
"url": "https://local.maix.me:8000",
"description": "using fnginx"
}
]
}

View file

@ -6,18 +6,17 @@ import sharp from 'sharp';
import path from 'path';
import fs from 'node:fs/promises';
export const IconSetRes = {
'200': typeResponse('success', 'iconset.success'),
'400': typeResponse('success', ['iconset.failure.invalidFile', 'iconset.failure.noFile']),
'400': typeResponse('success', [
'iconset.failure.invalidFile',
'iconset.failure.noFile',
]),
};
export type IconSetRes = MakeStaticResponse<typeof IconSetRes>;
const validMimeTypes = new Set([
'image/jpeg',
'image/png',
]);
const validMimeTypes = new Set(['image/jpeg', 'image/png']);
async function resizeAndSaveImage(
imageBuffer: Buffer,
@ -42,20 +41,42 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
await fastify.register(multipart);
fastify.post(
'/api/icons/set',
{ schema: { response: IconSetRes, operationId: 'setIcons' }, config: { requireAuth: true } },
{
schema: {
response: IconSetRes,
hide: true,
},
config: { requireAuth: true },
},
async function(req, res) {
// req.authUser is always set, since this is gated
const userid = req.authUser!.id;
const file = await req.file();
if (!file) {
return res.makeResponse(400, 'failure', 'iconset.failure.noFile');
return res.makeResponse(
400,
'failure',
'iconset.failure.noFile',
);
}
if (!validMimeTypes.has(file.mimetype)) {
return res.makeResponse(400, 'failure', 'iconset.failure.invalidFile');
return res.makeResponse(
400,
'failure',
'iconset.failure.invalidFile',
);
}
const buf = await file.toBuffer();
if (!validMimeTypes.has((await fileTypeFromBuffer(buf))?.mime ?? 'unknown')) {
return res.makeResponse(400, 'failure', 'iconset.failure.invalidFile');
if (
!validMimeTypes.has(
(await fileTypeFromBuffer(buf))?.mime ?? 'unknown',
)
) {
return res.makeResponse(
400,
'failure',
'iconset.failure.invalidFile',
);
}
try {
resizeAndSaveImage(buf, userid);
@ -63,7 +84,11 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
}
catch (e: unknown) {
this.log.warn(e);
return res.makeResponse(400, 'failure', 'iconset.failure.invalidFile');
return res.makeResponse(
400,
'failure',
'iconset.failure.invalidFile',
);
}
},
);

View file

@ -1714,6 +1714,326 @@
]
}
},
"/api/user/friend/add/{user}": {
"put": {
"operationId": "addFriend",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "user",
"required": true
}
],
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"addFriend.success"
]
}
}
}
}
}
},
"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"
]
}
}
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"addFriend.failure.unknownUser"
]
}
}
}
}
}
}
},
"tags": [
"openapi_other"
]
}
},
"/api/user/friend/list": {
"get": {
"operationId": "listFriend",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"listFriend.success"
]
},
"payload": {
"type": "object",
"required": [
"friends"
],
"properties": {
"friends": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
}
},
"tags": [
"openapi_other"
]
}
},
"/api/user/friend/remove/{user}": {
"put": {
"operationId": "removeFriend",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "user",
"required": true
}
],
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"removeFriend.success"
]
}
}
}
}
}
},
"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"
]
}
}
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"removeFriend.failure.unknownUser"
]
}
}
}
}
}
}
},
"tags": [
"openapi_other"
]
}
},
"/api/user/info/{user}": {
"get": {
"operationId": "getUser",

View file

@ -476,6 +476,317 @@
}
}
},
"/api/user/friend/add/{user}": {
"put": {
"operationId": "addFriend",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "user",
"required": true
}
],
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"addFriend.success"
]
}
}
}
}
}
},
"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"
]
}
}
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"addFriend.failure.unknownUser"
]
}
}
}
}
}
}
}
}
},
"/api/user/friend/list": {
"get": {
"operationId": "listFriend",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"listFriend.success"
]
},
"payload": {
"type": "object",
"required": [
"friends"
],
"properties": {
"friends": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
}
}
}
},
"/api/user/friend/remove/{user}": {
"put": {
"operationId": "removeFriend",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "user",
"required": true
}
],
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"removeFriend.success"
]
}
}
}
}
}
},
"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"
]
}
}
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"removeFriend.failure.unknownUser"
]
}
}
}
}
}
}
}
}
},
"/api/user/info/{user}": {
"get": {
"operationId": "getUser",

View file

@ -0,0 +1,44 @@
import { FastifyPluginAsync } from 'fastify';
import { MakeStaticResponse, typeResponse } from '@shared/utils';
import Type, { Static } from 'typebox';
export const AddFriendRes = {
'200': typeResponse('success', 'addFriend.success'),
'404': typeResponse('failure', 'addFriend.failure.unknownUser'),
};
export type AddFriendRes = MakeStaticResponse<typeof AddFriendRes>;
const AddFriendParams = Type.Object({
user: Type.String(),
});
export type AddFriendParams = Static<typeof AddFriendParams>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.put<{ Params: AddFriendParams }>(
'/api/user/friend/add/:user',
{
schema: {
params: AddFriendParams,
response: AddFriendRes,
operationId: 'addFriend',
},
config: { requireAuth: true },
},
async function(req, res) {
const friend = this.db.getUser(req.params.user);
if (!friend) {
return res.makeResponse(
404,
'failure',
'addFriend.failure.unknownUser',
);
}
this.db.addFriendsUserFor(req.authUser!.id, friend.id);
return res.makeResponse(200, 'success', 'addFriend.success');
},
);
};
export default route;

View file

@ -0,0 +1,40 @@
import { FastifyPluginAsync } from 'fastify';
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
import Type, { Static } from 'typebox';
export const ListFriendRes = {
'200': typeResponse('success', 'listFriend.success', {
friends: Type.Array(Type.Object({
id: Type.String(),
name: Type.String(),
})),
}),
};
export type ListFriendRes = MakeStaticResponse<typeof ListFriendRes>;
const RemoveFriendParams = Type.Object({
user: Type.String(),
});
export type RemoveFriendParams = Static<typeof RemoveFriendParams>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get(
'/api/user/friend/list',
{
schema: {
response: ListFriendRes,
operationId: 'listFriend',
},
config: { requireAuth: true },
},
async function(req, res) {
void req;
const friends: ListFriendRes['200']['payload']['friends'] = this.db.getFriendsUserFor(req.authUser!.id).map(v => this.db.getUser(v.friend)).filter(v => !isNullish(v)).map(v => ({ id: v.id, name: v.name }));
return res.makeResponse(200, 'success', 'listFriend.success', { friends });
},
);
};
export default route;

View file

@ -0,0 +1,44 @@
import { FastifyPluginAsync } from 'fastify';
import { MakeStaticResponse, typeResponse } from '@shared/utils';
import Type, { Static } from 'typebox';
export const RemoveFriendRes = {
'200': typeResponse('success', 'removeFriend.success'),
'404': typeResponse('failure', 'removeFriend.failure.unknownUser'),
};
export type RemoveFriendRes = MakeStaticResponse<typeof RemoveFriendRes>;
const RemoveFriendParams = Type.Object({
user: Type.String(),
});
export type RemoveFriendParams = Static<typeof RemoveFriendParams>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.put<{ Params: RemoveFriendParams }>(
'/api/user/friend/remove/:user',
{
schema: {
params: RemoveFriendParams,
response: RemoveFriendRes,
operationId: 'removeFriend',
},
config: { requireAuth: true },
},
async function(req, res) {
const friend = this.db.getUser(req.params.user);
if (!friend) {
return res.makeResponse(
404,
'failure',
'removeFriend.failure.unknownUser',
);
}
this.db.removeFriendsUserFor(req.authUser!.id, friend.id);
return res.makeResponse(200, 'success', 'removeFriend.success');
},
);
};
export default route;