("#friendList");
+ if (!friendsBox) return;
+ friendsElem.forEach(c => friendsBox.appendChild(c));
+ }
+ };
+}
+
+addRoute('/friends', friends);
diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts
index cd6c41d..aef2743 100644
--- a/frontend/src/pages/index.ts
+++ b/frontend/src/pages/index.ts
@@ -1,6 +1,5 @@
import { setTitle, handleRoute } from '@app/routing';
import './root/root.ts'
-import '../chat/chat.ts'
import './pong/pong.ts'
import './login/login.ts'
import './signin/signin.ts'
@@ -10,6 +9,7 @@ import './logout/logout.ts'
import './pongHistory/pongHistory.ts'
import './tttHistory/tttHistory.ts'
import './tourHistory/tourHistory.ts'
+import './friendList/friendList.ts'
// ---- Initial load ----
setTitle("");
diff --git a/frontend/src/pages/pong/pong.ts b/frontend/src/pages/pong/pong.ts
index 3dd40ed..d8c09e3 100644
--- a/frontend/src/pages/pong/pong.ts
+++ b/frontend/src/pages/pong/pong.ts
@@ -107,7 +107,9 @@ function tourinfoButtons(tourInfo : HTMLButtonElement, tourScoreScreen : HTMLDiv
});
}
-function gameJoinButtons(socket : CSocket, inTournament : boolean, currentGame : currentGameInfo | null,
+let inTournament: boolean = false;
+
+function gameJoinButtons(socket : CSocket, currentGame : currentGameInfo | null,
tournament : HTMLButtonElement, queue : HTMLButtonElement, localGame : HTMLButtonElement, ready : HTMLButtonElement)
{
tournament.addEventListener("click", () => {
@@ -150,6 +152,10 @@ function gameJoinButtons(socket : CSocket, inTournament : boolean, currentGame :
}
});
localGame.addEventListener("click", () => {
+ if (inTournament) {
+ showError("You can't queue up currently !");
+ return;
+ }
if (
queue.innerText !== QueueState.Iddle ||
currentGame !== null ||
@@ -274,7 +280,7 @@ function pongClient(
setTitle("Pong Game");
const urlParams = new URLSearchParams(window.location.search);
let game_req_join = urlParams.get("game");
- let inTournament = false;
+ inTournament = false;
return {
html: authHtml,
@@ -548,7 +554,7 @@ function pongClient(
setInterval(() => {keys_listen_setup(currentGame, socket, keys, playHow, playHow_b, tourScoreScreen, queue)}, 1000 / 60);
- gameJoinButtons(socket, inTournament, currentGame, tournament, queue, localGame, ready);
+ gameJoinButtons(socket, currentGame, tournament, queue, localGame, ready);
playhowButtons(playHow_b, playHow);
tourinfoButtons(tourInfo, tourScoreScreen);
diff --git a/frontend/src/pages/profile/profile.html b/frontend/src/pages/profile/profile.html
index 5dfa4f2..b24ab6d 100644
--- a/frontend/src/pages/profile/profile.html
+++ b/frontend/src/pages/profile/profile.html
@@ -31,6 +31,19 @@
+
+
+
![]()
+
+
+
+
+
+
@@ -52,8 +65,7 @@
-
+
diff --git a/frontend/src/pages/profile/profile.ts b/frontend/src/pages/profile/profile.ts
index c1e59fd..54f348b 100644
--- a/frontend/src/pages/profile/profile.ts
+++ b/frontend/src/pages/profile/profile.ts
@@ -40,6 +40,45 @@ function removeBgColor(...elem: HTMLElement[]) {
}
}
+async function setup_profile_image(container: HTMLDivElement, url: string) {
+ let imgNode = container.querySelector("img");
+ let formNode = container.querySelector("form");
+ if (!imgNode || !formNode) return;
+ imgNode.src = url;
+ container.classList.remove("hidden");
+ formNode.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ let form = e.target;
+ if (!form) return;
+ let data = new FormData(form as HTMLFormElement);
+ let req = await fetch("/api/icons/set", {
+ body: data,
+ method: "POST",
+ });
+ if (req.status === 200 || req.status === 400) {
+ let json = await req.json();
+ if (!("kind" in json) || !("msg" in json))
+ return showError("Unknown Error");
+ if (typeof json.kind !== "string" || typeof json.msg !== "string")
+ return showError("Unknown Error");
+ const pjson: { kind: string; msg: string } = json;
+ if (pjson.kind === "success") {
+ showSuccess("Updated image !");
+ return handleRoute();
+ } else {
+ console.log(`Failed to upload image: ${pjson.msg}`);
+ showError("Failed to change image");
+ }
+ } if (req.status === 413)
+ {
+ showError("Image too big");
+ }
+ else {
+ showError("Unknown Error");
+ }
+ });
+}
+
async function route(url: string, _args: { [k: string]: string }) {
setTitle("Edit Profile");
return {
@@ -99,8 +138,7 @@ async function route(url: string, _args: { [k: string]: string }) {
let descWrapper =
app.querySelector("#descWrapper")!;
- let descBox =
- app.querySelector("#descBox")!;
+ let descBox = app.querySelector("#descBox")!;
let descButton =
app.querySelector("#descButton")!;
@@ -126,6 +164,8 @@ async function route(url: string, _args: { [k: string]: string }) {
let totpWrapper =
app.querySelector("#totpWrapper")!;
+ let imgBox = app.querySelector("#iconBox")!;
+
descBox.value = user.desc;
if (user.guest) {
@@ -138,10 +178,7 @@ async function route(url: string, _args: { [k: string]: string }) {
descButton,
);
- descButton.classList.add(
- "bg-gray-700",
- "hover:bg-gray-700",
- );
+ descButton.classList.add("bg-gray-700", "hover:bg-gray-700");
descButton.disabled = true;
descBox.disabled = true;
@@ -174,6 +211,7 @@ async function route(url: string, _args: { [k: string]: string }) {
passwordWrapper.hidden = false;
accountTypeBox.innerText = "Normal";
+ setup_profile_image(imgBox, `/icons/${user.id}`);
} else if (
!isNullish(user.selfInfo?.providerId) &&
!isNullish(user.selfInfo?.providerUser)
@@ -195,6 +233,7 @@ async function route(url: string, _args: { [k: string]: string }) {
totpWrapper.hidden = true;
accountTypeBox.innerText = "Provider";
+ setup_profile_image(imgBox, `/icons/${user.id}`);
}
// ---- Update UI ----
@@ -269,12 +308,13 @@ async function route(url: string, _args: { [k: string]: string }) {
}
};
descButton.onclick = async () => {
- let req = await client.changeDesc({ changeDescRequest: { desc: descBox.value } });
+ let req = await client.changeDesc({
+ changeDescRequest: { desc: descBox.value },
+ });
if (req.kind === "success") {
showSuccess("Successfully changed description");
handleRoute();
- }
- else {
+ } else {
showError(`Failed to update`);
}
};
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
index 80a3888..6229799 100644
--- a/frontend/src/utils.ts
+++ b/frontend/src/utils.ts
@@ -1,9 +1,11 @@
+import client from "./api";
+
export function escapeHTML(str: string): string {
const p = document.createElement("p");
p.appendChild(document.createTextNode(str));
return p.innerHTML;
}
-export function isNullish(v: T | undefined | null): v is (null | undefined) {
+export function isNullish(v: T | undefined | null): v is null | undefined {
return v === null || v === undefined;
}
@@ -11,3 +13,22 @@ export function isNullish(v: T | undefined | null): v is (null | undefined) {
export function ensureWindowState() {
window.__state = window.__state ?? {};
}
+
+export async function updateFriendsList() {
+ window.__state = window.__state ?? {};
+ window.__state.friendList ??= [];
+
+ try {
+ let req = await client.listFriend();
+ if (req.kind === "success") {
+ window.__state.friendList = req.payload.friends;
+ }
+ } catch (e: unknown) { }
+}
+
+export function getFriendList() {
+ ensureWindowState();
+ window.__state.friendList ??= [];
+
+ return window.__state.friendList;
+}
diff --git a/nginx/conf/locations/icons.conf b/nginx/conf/locations/icons.conf
new file mode 100644
index 0000000..3bd0e6a
--- /dev/null
+++ b/nginx/conf/locations/icons.conf
@@ -0,0 +1,10 @@
+#forward the post request to the microservice
+location /api/icons/ {
+ proxy_pass http://app-icons;
+}
+
+location /icons/ {
+ root /volumes/;
+ default_type image/png;
+ add_header Cache-Control "max-age=30";
+}
diff --git a/src/@dockerfiles/deps.Dockerfile b/src/@dockerfiles/deps.Dockerfile
index df81482..8409f9b 100644
--- a/src/@dockerfiles/deps.Dockerfile
+++ b/src/@dockerfiles/deps.Dockerfile
@@ -10,5 +10,6 @@ COPY auth/package.json /build/auth/package.json
COPY chat/package.json /build/chat/package.json
COPY tic-tac-toe/package.json /build/tic-tac-toe/package.json
COPY user/package.json /build/user/package.json
+COPY icons/package.json /build/icons/package.json
RUN pnpm install -q --frozen-lockfile;
diff --git a/src/@shared/src/database/index.ts b/src/@shared/src/database/index.ts
index 2d30508..dcea4d9 100644
--- a/src/@shared/src/database/index.ts
+++ b/src/@shared/src/database/index.ts
@@ -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' {
diff --git a/src/@shared/src/database/init.sql b/src/@shared/src/database/init.sql
index cb72332..41aae21 100644
--- a/src/@shared/src/database/init.sql
+++ b/src/@shared/src/database/init.sql
@@ -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 --
----------------
diff --git a/src/@shared/src/database/mixin/friends.ts b/src/@shared/src/database/mixin/friends.ts
new file mode 100644
index 0000000..916c99d
--- /dev/null
+++ b/src/@shared/src/database/mixin/friends.ts
@@ -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 = {
+ getFriendsUserFor(this: IFriendsDb, id: UserId): FriendsData[] {
+ const query = this.prepare('SELECT * FROM friends WHERE user = @id');
+ const data = query.all({ id }) as Partial[];
+ 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[];
+
+ 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 | 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;
+}
diff --git a/src/auth/config/default.png b/src/auth/config/default.png
new file mode 100644
index 0000000..bb9af0f
Binary files /dev/null and b/src/auth/config/default.png differ
diff --git a/src/auth/src/routes/guestLogin.ts b/src/auth/src/routes/guestLogin.ts
index ce2626a..8d13aeb 100644
--- a/src/auth/src/routes/guestLogin.ts
+++ b/src/auth/src/routes/guestLogin.ts
@@ -2,6 +2,7 @@ import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox';
import { typeResponse, isNullish, MakeStaticResponse } from '@shared/utils';
+import * as fs from 'node:fs/promises';
export const GuestLoginRes = {
'500': typeResponse('failed', [
@@ -91,6 +92,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise => {
'guestLogin.failed.generic.unknown',
);
}
+ await fs.cp('/config/default.png', `/volumes/icons/${user.id}`);
return res.makeResponse(200, 'success', 'guestLogin.success', {
token: this.signJwt('auth', user.id.toString()),
});
diff --git a/src/auth/src/routes/oauth2/callback.ts b/src/auth/src/routes/oauth2/callback.ts
index 36ec4a3..1a3689a 100644
--- a/src/auth/src/routes/oauth2/callback.ts
+++ b/src/auth/src/routes/oauth2/callback.ts
@@ -3,6 +3,7 @@ import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox';
import { typeResponse, isNullish } from '@shared/utils';
import * as oauth2 from '../../oauth2';
+import * as fs from 'node:fs/promises';
export const WhoAmIRes = Type.Union([
@@ -47,6 +48,9 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise => {
user_name = `${orig}${Date.now()}`;
}
u = await this.db.createOauth2User(user_name, provider.display_name, userinfo.unique_id);
+ if (u) {
+ await fs.cp('/config/default.png', `/volumes/icons/${u.id}`);
+ }
}
if (isNullish(u)) {
return res.code(500).send('failed to fetch or create user...');
diff --git a/src/auth/src/routes/signin.ts b/src/auth/src/routes/signin.ts
index fe09163..094c8f1 100644
--- a/src/auth/src/routes/signin.ts
+++ b/src/auth/src/routes/signin.ts
@@ -2,6 +2,7 @@ import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox';
import { typeResponse, isNullish, MakeStaticResponse } from '@shared/utils';
+import * as fs from 'node:fs/promises';
const USERNAME_CHECK: RegExp = /^[a-zA-Z_0-9]+$/;
@@ -61,6 +62,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise => {
}
const u = await this.db.createUser(name, user_name, password);
if (isNullish(u)) { return res.makeResponse(500, 'failed', 'signin.failed.generic'); }
+ await fs.cp('/config/default.png', `/volumes/icons/${u.id}`);
// every check has been passed, they are now logged in, using this token to say who they are...
const userToken = this.signJwt('auth', u.id);
diff --git a/src/icons/.dockerignore b/src/icons/.dockerignore
new file mode 100644
index 0000000..c925c21
--- /dev/null
+++ b/src/icons/.dockerignore
@@ -0,0 +1,2 @@
+/dist
+/node_modules
diff --git a/src/icons/openapi.json b/src/icons/openapi.json
new file mode 100644
index 0000000..38cd725
--- /dev/null
+++ b/src/icons/openapi.json
@@ -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"
+ }
+ ]
+}
diff --git a/src/icons/package.json b/src/icons/package.json
new file mode 100644
index 0000000..09922ab
--- /dev/null
+++ b/src/icons/package.json
@@ -0,0 +1,35 @@
+{
+ "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",
+ "build:openapi": "VITE_ENTRYPOINT=src/openapi.ts vite build && node dist/openapi.cjs >openapi.json"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "@fastify/formbody": "^8.0.2",
+ "@fastify/multipart": "^9.3.0",
+ "fastify": "^5.6.2",
+ "fastify-plugin": "^5.1.0",
+ "file-type": "^21.3.0",
+ "sharp": "^0.34.5",
+ "typebox": "^1.0.69"
+ },
+ "devDependencies": {
+ "@types/node": "^22.19.3",
+ "rollup-plugin-node-externals": "^8.1.2",
+ "vite": "^7.3.0",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/src/icons/src/app.ts b/src/icons/src/app.ts
new file mode 100644
index 0000000..07d3939
--- /dev/null
+++ b/src/icons/src/app.ts
@@ -0,0 +1,33 @@
+import { FastifyPluginAsync } from 'fastify';
+import * as db from '@shared/database';
+import * as auth from '@shared/auth';
+import * as swagger from '@shared/swagger';
+import * as utils from '@shared/utils';
+
+declare const __SERVICE_NAME: string;
+
+// @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 });
+
+const app: FastifyPluginAsync = async (fastify, opts): Promise => {
+ void opts;
+ await fastify.register(utils.useMakeResponse);
+ await fastify.register(utils.useMonitoring);
+ await fastify.register(swagger.useSwagger, { service: __SERVICE_NAME });
+ await fastify.register(db.useDatabase as FastifyPluginAsync, {});
+ await fastify.register(auth.jwtPlugin as FastifyPluginAsync, {});
+ await fastify.register(auth.authPlugin as FastifyPluginAsync, {});
+
+ // Place here your custom code!
+ for (const plugin of Object.values(plugins)) {
+ void fastify.register(plugin as FastifyPluginAsync, {});
+ }
+ for (const route of Object.values(routes)) {
+ void fastify.register(route as FastifyPluginAsync, {});
+ }
+};
+
+export default app;
+export { app };
diff --git a/src/icons/src/openapi.ts b/src/icons/src/openapi.ts
new file mode 100644
index 0000000..d66d7a7
--- /dev/null
+++ b/src/icons/src/openapi.ts
@@ -0,0 +1,21 @@
+import f, { FastifyPluginAsync } from 'fastify';
+import * as swagger from '@shared/swagger';
+import * as auth from '@shared/auth';
+
+declare const __SERVICE_NAME: string;
+
+// @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
+const routes = import.meta.glob('./routes/**/*.ts', { eager: true });
+
+async function start() {
+ const fastify = f({ logger: false });
+ await fastify.register(auth.authPlugin, { onlySchema: true });
+ await fastify.register(swagger.useSwagger, { service: __SERVICE_NAME });
+
+ for (const route of Object.values(routes)) {
+ await fastify.register(route as FastifyPluginAsync, {});
+ }
+ await fastify.ready();
+ console.log(JSON.stringify(fastify.swagger(), undefined, 4));
+}
+start();
diff --git a/src/icons/src/plugins/README.md b/src/icons/src/plugins/README.md
new file mode 100644
index 0000000..1e61ee5
--- /dev/null
+++ b/src/icons/src/plugins/README.md
@@ -0,0 +1,16 @@
+# Plugins Folder
+
+Plugins define behavior that is common to all the routes in your
+application. Authentication, caching, templates, and all the other cross
+cutting concerns should be handled by plugins placed in this folder.
+
+Files in this folder are typically defined through the
+[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module,
+making them non-encapsulated. They can define decorators and set hooks
+that will then be used in the rest of your application.
+
+Check out:
+
+* [The hitchhiker's guide to plugins](https://fastify.dev/docs/latest/Guides/Plugins-Guide/)
+* [Fastify decorators](https://fastify.dev/docs/latest/Reference/Decorators/).
+* [Fastify lifecycle](https://fastify.dev/docs/latest/Reference/Lifecycle/).
diff --git a/src/icons/src/routes/set.ts b/src/icons/src/routes/set.ts
new file mode 100644
index 0000000..b7aa917
--- /dev/null
+++ b/src/icons/src/routes/set.ts
@@ -0,0 +1,97 @@
+import { FastifyPluginAsync } from 'fastify';
+import multipart from '@fastify/multipart';
+import { MakeStaticResponse, typeResponse } from '@shared/utils';
+import { fileTypeFromBuffer } from 'file-type';
+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',
+ ]),
+};
+
+export type IconSetRes = MakeStaticResponse;
+
+const validMimeTypes = new Set(['image/jpeg', 'image/png']);
+
+async function resizeAndSaveImage(
+ imageBuffer: Buffer,
+ filename: string,
+): Promise {
+ const outputDir = '/volumes/icons/';
+ const outputPath = path.join(outputDir, filename);
+
+ // Ensure the directory exists
+ await fs.mkdir(outputDir, { recursive: true });
+
+ await sharp(imageBuffer)
+ .resize(512, 512, {
+ fit: 'cover',
+ })
+ .png()
+ .toFile(outputPath);
+}
+
+const route: FastifyPluginAsync = async (fastify, _opts): Promise => {
+ void _opts;
+ await fastify.register(multipart);
+ fastify.post(
+ '/api/icons/set',
+ {
+ 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',
+ );
+ }
+ if (!validMimeTypes.has(file.mimetype)) {
+ 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',
+ );
+ }
+ try {
+ resizeAndSaveImage(buf, userid);
+ return res.makeResponse(200, 'success', 'iconset.success');
+ }
+ catch (e: unknown) {
+ this.log.warn(e);
+ return res.makeResponse(
+ 400,
+ 'failure',
+ 'iconset.failure.invalidFile',
+ );
+ }
+ },
+ );
+};
+
+export default route;
diff --git a/src/icons/src/run.ts b/src/icons/src/run.ts
new file mode 100644
index 0000000..3c59d5d
--- /dev/null
+++ b/src/icons/src/run.ts
@@ -0,0 +1,21 @@
+// this sould only be used by the docker file !
+
+import fastify, { FastifyInstance } from 'fastify';
+import app from './app';
+
+const start = async () => {
+ const f: FastifyInstance = fastify({ logger: { level: 'info' } });
+ process.on('SIGTERM', () => {
+ f.log.warn('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();
diff --git a/src/icons/tsconfig.json b/src/icons/tsconfig.json
new file mode 100644
index 0000000..e6d24e2
--- /dev/null
+++ b/src/icons/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {},
+ "include": ["src/**/*.ts"]
+}
diff --git a/src/icons/vite.config.js b/src/icons/vite.config.js
new file mode 100644
index 0000000..aa3ef08
--- /dev/null
+++ b/src/icons/vite.config.js
@@ -0,0 +1,54 @@
+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,
+ define: {
+ __SERVICE_NAME: '"icons"',
+ },
+ // service root
+ plugins: [tsconfigPaths(), nodeExternals()],
+ build: {
+ ssr: true,
+ outDir: 'dist',
+ emptyOutDir: true,
+ lib: {
+ entry: path.resolve(__dirname, process.env.VITE_ENTRYPOINT ?? 'src/run.ts'),
+ // adjust main entry
+ formats: ['cjs'],
+ // CommonJS for Node.js
+ fileName: () => 'index.js',
+ },
+ rollupOptions: {
+ external: externals,
+ },
+ target: 'node22',
+ // or whatever Node version you use
+ sourcemap: true,
+ minify: false,
+ // for easier debugging
+ },
+});
diff --git a/src/openapi.json b/src/openapi.json
index d99dfc7..615cae1 100644
--- a/src/openapi.json
+++ b/src/openapi.json
@@ -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",
diff --git a/src/pnpm-lock.yaml b/src/pnpm-lock.yaml
index 0268c48..9571c8c 100644
--- a/src/pnpm-lock.yaml
+++ b/src/pnpm-lock.yaml
@@ -141,6 +141,43 @@ importers:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@22.19.3)(yaml@2.8.2))
+ icons:
+ dependencies:
+ '@fastify/formbody':
+ specifier: ^8.0.2
+ version: 8.0.2
+ '@fastify/multipart':
+ specifier: ^9.3.0
+ version: 9.3.0
+ fastify:
+ specifier: ^5.6.2
+ version: 5.6.2
+ fastify-plugin:
+ specifier: ^5.1.0
+ version: 5.1.0
+ file-type:
+ specifier: ^21.3.0
+ version: 21.3.0
+ sharp:
+ specifier: ^0.34.5
+ version: 0.34.5
+ typebox:
+ specifier: ^1.0.69
+ version: 1.0.69
+ devDependencies:
+ '@types/node':
+ specifier: ^22.19.3
+ version: 22.19.3
+ rollup-plugin-node-externals:
+ specifier: ^8.1.2
+ version: 8.1.2(rollup@4.54.0)
+ vite:
+ specifier: ^7.3.0
+ version: 7.3.0(@types/node@22.19.3)(yaml@2.8.2)
+ vite-tsconfig-paths:
+ specifier: ^5.1.4
+ version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@22.19.3)(yaml@2.8.2))
+
pong:
dependencies:
fastify:
@@ -224,6 +261,12 @@ importers:
packages:
+ '@borewit/text-codec@0.2.1':
+ resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
+
+ '@emnapi/runtime@1.8.1':
+ resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
+
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
@@ -424,15 +467,24 @@ packages:
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
+ '@fastify/busboy@3.2.0':
+ resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
+
'@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
+ '@fastify/deepmerge@3.1.0':
+ resolution: {integrity: sha512-lCVONBQINyNhM6LLezB6+2afusgEYR4G8xenMsfe+AT+iZ7Ca6upM5Ha8UkZuYSnuMw3GWl/BiPXnLMi/gSxuQ==}
+
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
'@fastify/fast-json-stringify-compiler@5.0.3':
resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
+ '@fastify/formbody@8.0.2':
+ resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==}
+
'@fastify/forwarded@3.0.1':
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
@@ -442,6 +494,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
+ '@fastify/multipart@9.3.0':
+ resolution: {integrity: sha512-NpeKipTOjjL1dA7SSlRMrOWWtrE8/0yKOmeudkdQoEaz4sVDJw5MVdZIahsWhvpc3YTN7f04f9ep/Y65RKoOWA==}
+
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -473,6 +528,143 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@img/colour@1.0.0':
+ resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@@ -605,6 +797,13 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
+ '@tokenizer/inflate@0.4.1':
+ resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
+ engines: {node: '>=18'}
+
+ '@tokenizer/token@0.3.0':
+ resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
@@ -1042,6 +1241,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ file-type@21.3.0:
+ resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
+ engines: {node: '>=20'}
+
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@@ -1497,6 +1700,10 @@ packages:
sha1@1.1.1:
resolution: {integrity: sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA==}
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1587,6 +1794,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ strtok3@10.3.4:
+ resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
+ engines: {node: '>=18'}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1617,6 +1828,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ token-types@6.1.2:
+ resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
+ engines: {node: '>=14.16'}
+
ts-api-utils@2.3.0:
resolution: {integrity: sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==}
engines: {node: '>=18.12'}
@@ -1633,6 +1848,9 @@ packages:
typescript:
optional: true
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
@@ -1655,6 +1873,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ uint8array-extras@1.5.0:
+ resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
+ engines: {node: '>=18'}
+
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -1774,6 +1996,13 @@ packages:
snapshots:
+ '@borewit/text-codec@0.2.1': {}
+
+ '@emnapi/runtime@1.8.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@esbuild/aix-ppc64@0.27.2':
optional: true
@@ -1906,17 +2135,26 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.17.1)
fast-uri: 3.1.0
+ '@fastify/busboy@3.2.0': {}
+
'@fastify/cookie@11.0.2':
dependencies:
cookie: 1.1.1
fastify-plugin: 5.1.0
+ '@fastify/deepmerge@3.1.0': {}
+
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3':
dependencies:
fast-json-stringify: 6.1.1
+ '@fastify/formbody@8.0.2':
+ dependencies:
+ fast-querystring: 1.1.2
+ fastify-plugin: 5.1.0
+
'@fastify/forwarded@3.0.1': {}
'@fastify/jwt@9.1.0':
@@ -1931,6 +2169,14 @@ snapshots:
dependencies:
dequal: 2.0.3
+ '@fastify/multipart@9.3.0':
+ dependencies:
+ '@fastify/busboy': 3.2.0
+ '@fastify/deepmerge': 3.1.0
+ '@fastify/error': 4.2.0
+ fastify-plugin: 5.1.0
+ secure-json-parse: 4.1.0
+
'@fastify/proxy-addr@5.1.0':
dependencies:
'@fastify/forwarded': 3.0.1
@@ -1982,6 +2228,102 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@img/colour@1.0.0': {}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.8.1
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@@ -2069,6 +2411,15 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
+ '@tokenizer/inflate@0.4.1':
+ dependencies:
+ debug: 4.4.3
+ token-types: 6.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@tokenizer/token@0.3.0': {}
+
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 22.19.3
@@ -2584,6 +2935,15 @@ snapshots:
dependencies:
flat-cache: 4.0.1
+ file-type@21.3.0:
+ dependencies:
+ '@tokenizer/inflate': 0.4.1
+ strtok3: 10.3.4
+ token-types: 6.1.2
+ uint8array-extras: 1.5.0
+ transitivePeerDependencies:
+ - supports-color
+
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
@@ -3026,6 +3386,37 @@ snapshots:
charenc: 0.0.2
crypt: 0.0.2
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.0.0
+ detect-libc: 2.1.2
+ semver: 7.7.3
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -3136,6 +3527,10 @@ snapshots:
strip-json-comments@3.1.1: {}
+ strtok3@10.3.4:
+ dependencies:
+ '@tokenizer/token': 0.3.0
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -3172,6 +3567,12 @@ snapshots:
toidentifier@1.0.1: {}
+ token-types@6.1.2:
+ dependencies:
+ '@borewit/text-codec': 0.2.1
+ '@tokenizer/token': 0.3.0
+ ieee754: 1.2.1
+
ts-api-utils@2.3.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -3180,6 +3581,9 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ tslib@2.8.1:
+ optional: true
+
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
@@ -3203,6 +3607,8 @@ snapshots:
typescript@5.9.3: {}
+ uint8array-extras@1.5.0: {}
+
undici-types@6.21.0: {}
undici-types@7.16.0:
diff --git a/src/pnpm-workspace.yaml b/src/pnpm-workspace.yaml
index 311c660..2840480 100644
--- a/src/pnpm-workspace.yaml
+++ b/src/pnpm-workspace.yaml
@@ -9,3 +9,4 @@ onlyBuiltDependencies:
- core-js
- esbuild
- protobufjs
+ - sharp
diff --git a/src/pong/src/state.ts b/src/pong/src/state.ts
index f73353a..e0bd729 100644
--- a/src/pong/src/state.ts
+++ b/src/pong/src/state.ts
@@ -160,8 +160,20 @@ class StateI {
});
return;
}
-
- this.dequeueUser(user.socket);
+ if (user.currentGame !== null) {
+ sock.emit('tournamentRegister', {
+ kind: 'failure',
+ msg: 'You are in game',
+ });
+ return;
+ }
+ if (this.queue.has(user.id)) {
+ sock.emit('tournamentRegister', {
+ kind: 'failure',
+ msg: 'You are in queue',
+ });
+ return;
+ }
this.tournament.addUser(user.id, name ?? udb.name);
sock.emit('tournamentRegister', {
kind: 'success',
@@ -279,6 +291,14 @@ class StateI {
const gameId = newUUID() as unknown as GameId;
this.games.set(gameId, g);
+ setTimeout(() => {
+ if (!g.ready_checks[0] && !g.ready_checks[1]) {
+ this.fastify.log.info(
+ `paused game ${gameId} has been canceled`,
+ );
+ this.cleanupGame(gameId, g);
+ }
+ }, 1000 * 60);
this.fastify.log.info('new paused game \'' + gameId + '\'');
return gameId;
}
@@ -438,13 +458,19 @@ class StateI {
) {
this.fastify.log.warn(
'user trying to connect to a game he\'s not part of: gameId:' +
- g_id + ' userId:' + sock.authUser.id);
+ g_id +
+ ' userId:' +
+ sock.authUser.id,
+ );
return JoinRes.no;
}
if (game.userOnPage[0] === true && game.userOnPage[1] === true) {
this.fastify.log.warn(
'user trying to connect to a game he\'s already joined: gameId:' +
- g_id + ' userId:' + sock.authUser.id);
+ g_id +
+ ' userId:' +
+ sock.authUser.id,
+ );
return JoinRes.no;
}
game.userOnPage[game.userLeft === sock.authUser.id ? 0 : 1] = true;
@@ -573,7 +599,7 @@ class StateI {
game.local,
);
this.fastify.log.info('SetGameOutcome !');
- if (!game.local) {
+ if (!game.local && game.ready_checks[0] && game.ready_checks[1]) {
const payload = { nextGame: chat_text };
try {
const resp = await fetch('http://app-chat/broadcastNextGame', {
diff --git a/src/pong/src/tour.ts b/src/pong/src/tour.ts
index b7df36f..cf82fab 100644
--- a/src/pong/src/tour.ts
+++ b/src/pong/src/tour.ts
@@ -70,6 +70,7 @@ export class Tournament {
const [u1, u2] = matchup;
const gameId = newUUID() as PongGameId;
const game = State.initGame(null, gameId, u1, u2);
+ State.broadcastTourStatus(`A Tournament game between ${this.users.get(u1)?.name ?? 'the left player'} and ${this.users.get(u2)?.name ?? 'the right player'} will start ASAP`);
if (game) {
game.onEnd = () => this.gameEnd();
}
diff --git a/src/user/openapi.json b/src/user/openapi.json
index 8f7df5f..1f34e15 100644
--- a/src/user/openapi.json
+++ b/src/user/openapi.json
@@ -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",
diff --git a/src/user/src/routes/friendAdd.ts b/src/user/src/routes/friendAdd.ts
new file mode 100644
index 0000000..00bebd1
--- /dev/null
+++ b/src/user/src/routes/friendAdd.ts
@@ -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;
+
+const AddFriendParams = Type.Object({
+ user: Type.String(),
+});
+export type AddFriendParams = Static;
+
+const route: FastifyPluginAsync = async (fastify, _opts): Promise => {
+ 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;
diff --git a/src/user/src/routes/friendList.ts b/src/user/src/routes/friendList.ts
new file mode 100644
index 0000000..4ef7f48
--- /dev/null
+++ b/src/user/src/routes/friendList.ts
@@ -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;
+
+const RemoveFriendParams = Type.Object({
+ user: Type.String(),
+});
+export type RemoveFriendParams = Static;
+
+const route: FastifyPluginAsync = async (fastify, _opts): Promise => {
+ 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;
diff --git a/src/user/src/routes/friendRemove.ts b/src/user/src/routes/friendRemove.ts
new file mode 100644
index 0000000..e4fc6f2
--- /dev/null
+++ b/src/user/src/routes/friendRemove.ts
@@ -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;
+
+const RemoveFriendParams = Type.Object({
+ user: Type.String(),
+});
+export type RemoveFriendParams = Static;
+
+const route: FastifyPluginAsync = async (fastify, _opts): Promise => {
+ 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;