diff --git a/Justfile b/Justfile index fe4ea0f..ad2eb32 100644 --- a/Justfile +++ b/Justfile @@ -11,10 +11,6 @@ set dotenv-load @user $FASTIFY_LOG_LEVEL="info" $FASTIFY_PRETTY_LOGS="true": fastify start src/api/user/default.js -# For launching the images api -@images $FASTIFY_LOG_LEVEL="info" $FASTIFY_PRETTY_LOGS="true": - fastify start src/api/images/default.js - @scoreStore $FASTIFY_LOG_LEVEL="info" $FASTIFY_PRETTY_LOGS="true": fastify start src/api/scoreStore/default.js diff --git a/doc/user/avatar.md b/doc/user/avatar.md new file mode 100644 index 0000000..d3232e4 --- /dev/null +++ b/doc/user/avatar.md @@ -0,0 +1,120 @@ +# Avatar + +Available endpoints: +- POST `/users/:userId/avatar` +- GET `/users/:userId/avatar` +- PATCH `/users/:userId/avatar` +- DELETE `/users/:userId/avatar` + +Common return: +- 500 with response +```json +{ + "error": "Internal server error" +} +``` + +## POST /users/:userId/avatar + +Used to upload an avatar + +Input needed : +```json +{ + +} +``` + +Can return: +- 200 with response +```json +{ + "msg": "Avatar uploaded successfully" +} +``` +- 400 with response (if the file is too large, or file is missing, or it is missing a file name, or it is missing a mime type) +```json +{ + "error": "" +} +``` +- 404 with response (if the user does not exist) +```json +{ + "error": "" +} +``` + +## GET /users/:userId/avatar + +Used to download an avatar + +Input needed : +```json +{ + +} +``` + +Can return: +- 200 with response +```json +{ + "msg": "Avatar uploaded successfully" +} +``` +- 404 with response (if the user does not exist, or the user does not have an assigned avatar, or the image does not exist) +```json +{ + "error": "" +} +``` + +## PATCH /users/:userId/avatar + +Used to modify an avatar + +Input needed : +```json +{ + +} +``` + +Can return: +- 200 with response +```json +{ + "msg": "Avatar modified successfully" +} +``` +- 400 with response (if the file is too large, or file is missing, or it is missing a file name, or it is missing a mime type) +```json +{ + "error": "" +} +``` +- 404 with response (if the user does not exist) +```json +{ + "error": "" +} +``` + +## DELETE /users/:userId/avatar + +Used to delete an avatar + +Can return: +- 200 with response +```json +{ + "msg": "Avatar deleted successfully" +} +``` +- 404 with response (if the user does not exist, or the user does not have an assigned avatar) +```json +{ + "error": "" +} +``` diff --git a/src/api/images/dImage.js b/src/api/images/dImage.js deleted file mode 100644 index 5b18db7..0000000 --- a/src/api/images/dImage.js +++ /dev/null @@ -1,10 +0,0 @@ -export async function dImage(request, reply, fastify, deleteImage) { - try { - const imageId = Number(request.params.imageId); - deleteImage.run(imageId); - return reply.code(200).send({ msg: "Image deleted successfully" }); - } catch (err) { - fastify.log.error(err); - return reply.code(500).send({ error: "Internal server error" }); - } -} diff --git a/src/api/images/default.js b/src/api/images/default.js deleted file mode 100644 index 569be87..0000000 --- a/src/api/images/default.js +++ /dev/null @@ -1,86 +0,0 @@ -import fastifyJWT from '@fastify/jwt'; -import fastifyCookie from '@fastify/cookie'; -import Database from 'better-sqlite3'; -import multipart from '@fastify/multipart'; - -import { gImage } from './gImage.js'; -import { pImage } from './pImage.js'; -import { dImage } from './dImage.js'; - -const env = process.env.NODE_ENV || 'development'; - -let database; -if (!env || env === 'development') { - database = new Database(':memory:', { verbose: console.log }); -} else { - const dbPath = process.env.DB_PATH || '/db/db.sqlite' - database = new Database(dbPath); -} - -function prepareDB() { - database.exec(` - CREATE TABLE IF NOT EXISTS images ( - imageId INTEGER PRIMARY KEY AUTOINCREMENT, - fileName TEXT, - mimeType TEXT, - data BLOB - ) STRICT - `); -} - -prepareDB(); - -// POST -const postImage = database.prepare('INSERT INTO images (fileName, mimeType, data) VALUES (?, ?, ?);'); - -// GET -const getImage = database.prepare('SELECT fileName, mimeType, data FROM images WHERE imageId = ?;'); - -// DELETE -const deleteImage = database.prepare('DELETE FROM images WHERE imageId = ?;'); - -export default async function(fastify, options) { - fastify.register(fastifyJWT, { - secret: process.env.JWT_SECRET || '123456789101112131415161718192021', - cookie: { - cookieName: 'token', - }, - }); - fastify.register(fastifyCookie); - fastify.register(multipart, { limits: { fileSize: 2 * 1024 * 1024 } }); - - fastify.decorate('authenticate', async function(request, reply) { - try { - const jwt = await request.jwtVerify(); - request.user = jwt.user; - } catch (err) { - reply.code(401).send({ error: 'Unauthorized' }); - } - }); - - fastify.decorate('authenticateAdmin', async function(request, reply) { - try { - const jwt = await request.jwtVerify(); - if (jwt.user !== 'admin') { - throw ('You lack administrator privileges'); - } - } catch (err) { - reply.code(401).send({ error: 'Unauthorized' }); - } - }); - - // GET - fastify.get('/images/:imageId', { preHandler: [fastify.authenticate] }, async (request, reply) => { - return gImage(request, reply, fastify, getImage); - }); - - // POST - fastify.post('/images', { preHandler: [fastify.authenticate] }, async (request, reply) => { - return pImage(request, reply, fastify, postImage); - }); - - // DELETE - fastify.delete('/images/:imageId', { preHandler: [fastify.authenticate] }, async (request, reply) => { - return dImage(request, reply, fastify, deleteImage); - }); -} diff --git a/src/api/images/gImage.js b/src/api/images/gImage.js deleted file mode 100644 index abbaa42..0000000 --- a/src/api/images/gImage.js +++ /dev/null @@ -1,13 +0,0 @@ -export async function gImage(request, reply, fastify, getImage) { - try { - const imageId = Number(request.params.imageId); - const image = getImage.get(imageId); - if (!image) { - return reply.code(404).send({ error: "Image does not exist" }); - } - return reply.code(200).type(image.mimeType).header('Content-Disposition', `inline; filename="${image.fileName}"`).send(image.data); - } catch (err) { - fastify.log.error(err); - return reply.code(500).send({ error: "Internal server error" }); - } -} diff --git a/src/api/images/pImage.js b/src/api/images/pImage.js deleted file mode 100644 index d3b930b..0000000 --- a/src/api/images/pImage.js +++ /dev/null @@ -1,33 +0,0 @@ -export async function pImage(request, reply, fastify, postImage) { - try { - const parts = request.parts(); - for await (const part of parts) { - if (part.file) { - const chunks = []; - for await (const chunk of part.file) { - chunks.push(chunk); - } - const buffer = Buffer.concat(chunks); - if (!part.filename || part.filename.trim() === '') { - return reply.code(400).send({ error: "Missing filename" }); - } - if (!part.mimetype || part.mimetype.trim() === '') { - return reply.code(400).send({ error: "Missing mimetype" }); - } - const ext = part.filename.toLowerCase().substring(part.filename.lastIndexOf('.')); - if (ext !== 'webp') { - return reply.code(400).send({ error: "Wrong file extension" }); - } - // check size max here ? - // convert image to webp using sharp - //sharp(buffer, ).toFile(); - const id = postImage.run(part.filename, part.mimetype, buffer); - return reply.code(200).send({ msg: "Image uploaded successfully", imageId: id.lastInsertRowid }); - } - } - return reply.code(400).send({ error: "No file uploaded" }); - } catch (err) { - fastify.log.error(err); - return reply.code(500).send({ error: "Internal server error" }); - } -} diff --git a/src/api/user/dAvatar.js b/src/api/user/dAvatar.js index 56f9db4..dbd634a 100644 --- a/src/api/user/dAvatar.js +++ b/src/api/user/dAvatar.js @@ -1,9 +1,14 @@ -export async function dAvatar(request, reply, fastify, getUserInfo, deleteAvatarId) { +export async function dAvatar(request, reply, fastify, getUserInfo, getAvatarId, deleteAvatarId, deleteImage) { try { const userId = request.params.userId; if (!getUserInfo.get(userId)) { return reply.cose(404).send({ error: "User does not exist" }); } + const imageId = getAvatarId.get(userId); + if (imageId.avatarId === -1) { + return reply.code(404).send({ error: "User does not have an avatar" }); + } + deleteImage.run(imageId.avatarId); deleteAvatarId.run(userId); return reply.code(200).send({ msg: "Avatar deleted successfully" }); } catch (err) { diff --git a/src/api/user/default.js b/src/api/user/default.js index 204e2c7..bbce5ab 100644 --- a/src/api/user/default.js +++ b/src/api/user/default.js @@ -1,6 +1,7 @@ import fastifyJWT from '@fastify/jwt'; import fastifyCookie from '@fastify/cookie'; import Database from 'better-sqlite3'; +import multipart from '@fastify/multipart'; import { gUsers } from './gUsers.js'; import { gUser } from './gUser.js'; @@ -20,6 +21,7 @@ import { dFriend } from './dFriend.js'; import { dMatchHistory } from './dMatchHistory.js'; import { pAvatar } from './pAvatar.js'; import { gAvatar } from './gAvatar.js'; +import { uAvatar } from './uAvatar.js'; import { dAvatar } from './dAvatar.js'; import { pPing } from './pPing.js'; import { gPing } from './gPing.js'; @@ -80,6 +82,14 @@ function prepareDB() { time TEXT ) STRICT `); + database.exec(` + CREATE TABLE IF NOT EXISTS images ( + imageId INTEGER PRIMARY KEY AUTOINCREMENT, + fileName TEXT, + mimeType TEXT, + data BLOB + ) STRICT + `); } prepareDB(); @@ -93,6 +103,7 @@ const incLossesPong = database.prepare('UPDATE userData SET pongLosses = pongLos const incWinsTetris = database.prepare('UPDATE userData SET tetrisWins = tetrisWins + 1 WHERE username = ?;'); const incLossesTetris = database.prepare('UPDATE userData SET tetrisLosses = tetrisLosses + 1 WHERE username = ?'); const setAvatarId = database.prepare('UPDATE userData SET avatarId = ? WHERE username = ?;'); +const postImage = database.prepare('INSERT INTO images (fileName, mimeType, data) VALUES (?, ?, ?);'); const setActivityTime = database.prepare(` INSERT INTO activityTime (username, time) VALUES (?, ?) @@ -113,6 +124,7 @@ const getNumberUsers = database.prepare('SELECT COUNT (DISTINCT username) AS n_u const getNumberFriends = database.prepare('SELECT COUNT (DISTINCT friendName) AS n_friends FROM friends WHERE username = ?;'); const getNumberMatches = database.prepare('SELECT COUNT (DISTINCT id) AS n_matches FROM matchHistory WHERE game = ? AND ? IN (player1, player2);'); const getAvatarId = database.prepare('SELECT avatarId FROM userData WHERE username = ?;'); +const getImage = database.prepare('SELECT fileName, mimeType, data FROM images WHERE imageId = ?;'); const getActivityTime = database.prepare('SELECT time FROM activityTime WHERE username = ?;') // DELETE @@ -123,6 +135,7 @@ const deleteMatchHistory = database.prepare('DELETE FROM matchHistory WHERE game const deleteStatsPong = database.prepare('UPDATE userData SET pongWins = 0, pongLosses = 0 WHERE username = ?;'); const deleteStatsTetris = database.prepare('UPDATE userData SET tetrisWins = 0, tetrisLosses = 0 WHERE username = ?;'); const deleteAvatarId = database.prepare('UPDATE userData SET avatarId = -1 WHERE username = ?;'); +const deleteImage = database.prepare('DELETE FROM images WHERE imageId = ?;'); const querySchema = { type: 'object', required: ['iStart', 'iEnd'], properties: { iStart: { type: 'integer', minimum: 0 }, iEnd: { type: 'integer', minimum: 0 } } } const querySchemaMatchHistory = { type: 'object', required: ['game', 'iStart', 'iEnd'], properties: { game: { type: 'string' }, iStart: { type: 'integer', minimum: 0 }, iEnd: { type: 'integer', minimum: 0 } } } @@ -137,6 +150,7 @@ export default async function(fastify, options) { }, }); fastify.register(fastifyCookie); + fastify.register(multipart, { limits: { fileSize: 2 * 1024 * 1024 + 1 } }); fastify.decorate('authenticate', async function(request, reply) { try { @@ -181,7 +195,7 @@ export default async function(fastify, options) { return gNumberMatches(request, reply, fastify, getUserInfo, getNumberMatches); }); fastify.get('/users/:userId/avatar', { preHandler: [fastify.authenticate] }, async (request, reply) => { - return gAvatar(request, reply, fastify, getUserInfo, getAvatarId); + return gAvatar(request, reply, fastify, getUserInfo, getAvatarId, getImage); }); fastify.get('/ping/:userId', { preHandler: [fastify.authenticate] }, async (request, reply) => { return gPing(request, reply, fastify, getActivityTime); @@ -197,14 +211,17 @@ export default async function(fastify, options) { fastify.post('/users/:userId/matchHistory', { preHandler: [fastify.authenticate], schema: { body: bodySchemaMatchHistory } }, async (request, reply) => { return pMatchHistory(request, reply, fastify, getUserInfo, addMatch, incWinsPong, incLossesPong, incWinsTetris, incLossesTetris); }); - fastify.post('/users/:userId/avatar', { preHandler: [fastify.authenticate] }, async (request, reply) => { - return pAvatar(request, reply, fastify, getUserInfo, setAvatarId); + fastify.post('/users/:userId/avatar',/* { preHandler: [fastify.authenticate] },*/ async (request, reply) => { + return pAvatar(request, reply, fastify, getUserInfo, setAvatarId, postImage); }); fastify.post('/ping', { preHandler: [fastify.authenticate] }, async (request, reply) => { return pPing(request, reply, fastify, setActivityTime); }) // PATCH + fastify.patch('/users/:userId/avatar', { preHandler: [fastify.authenticate] }, async (request, reply) => { + return uAvatar(request, reply, fastify, getUserInfo, setAvatarId, getAvatarId, deleteAvatarId, postImage, deleteImage); + }); fastify.patch('/users/:userId/:member', { preHandler: [fastify.authenticate] }, async (request, reply) => { return uMember(request, reply, fastify, getUserInfo, changeDisplayName, changeAvatarId); }); @@ -226,6 +243,6 @@ export default async function(fastify, options) { return dMatchHistory(request, reply, fastify, getUserInfo, deleteMatchHistory, deleteStatsPong, deleteStatsTetris); }); fastify.delete('/users/:userId/avatar', { preHandler: [fastify.authenticate] }, async (request, reply) => { - return dAvatar(request, reply, fastify, getUserInfo, deleteAvatarId); + return dAvatar(request, reply, fastify, getUserInfo, getAvatarId, deleteAvatarId, deleteImage); }); } diff --git a/src/api/user/gAvatar.js b/src/api/user/gAvatar.js index 362aa2f..6c0d3f6 100644 --- a/src/api/user/gAvatar.js +++ b/src/api/user/gAvatar.js @@ -1,27 +1,18 @@ -export async function gAvatar(request, reply, fastify, getUserInfo, getAvatarId) { +export async function gAvatar(request, reply, fastify, getUserInfo, getAvatarId, getImage) { try { const userId = request.params.userId; if (!getUserInfo.get(userId)) { return reply.code(404).send({ error: "User does not exist" }); } - const imageId = 1;//getAvatarId.get(userId); - if (imageId === -1) { - ;// return random kanel image + const imageId = getAvatarId.get(userId); + if (imageId.avatarId === -1) { + return reply.code(404).send({ error: "User does not have an avatar" }); } - const res = await fetch(`http://localhost:3004/images/${imageId}`, { method: "GET" }); - if (!res.ok) { - console.log("====================================\nAn error on the image API has occured"); - return reply.code(500).send({ error: "Internal server error" }); + const image = getImage.get(imageId.avatarId); + if (!image) { + return reply.code(404).send({ error: "Avatar does not exist" }); } - for (const [key, value] of res.headers) { - reply.header(key, value); - } - if (res.body) { - reply.code(res.statusCode).send(res.body); - } else { - reply.code(res.statusCode).send(); - } - //return reply.code(200).type(res.header).send(res.body); + return reply.code(200).type(image.mimeType).send(image.data); } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: "Internal server error" }); diff --git a/src/api/user/pAvatar.js b/src/api/user/pAvatar.js index 71fd6ca..a49ef02 100644 --- a/src/api/user/pAvatar.js +++ b/src/api/user/pAvatar.js @@ -1,17 +1,37 @@ -export async function pAvatar(request, reply, fastify, getUserInfo, setAvatarId) { +import sharp from 'sharp'; + +export async function pAvatar(request, reply, fastify, getUserInfo, setAvatarId, postImage) { try { const userId = request.params.userId; if (!getUserInfo.get(userId)) { return reply.cose(404).send({ error: "User does not exist" }); } - console.log("====================================\n", request.headers);//========== - const res = await fetch('http://localhost:3004/images', { method: "POST", headers: { "Content-Type": "image/webp" }, body: request.body ? JSON.stringify(request.body) : undefined }); - if (!res.ok) { - return reply.code(500).send({ error: "Internal server error" }); + const parts = request.parts(); + for await (const part of parts) { + if (part.file) { + let size = 0; + const chunks = []; + for await (const chunk of part.file) { + size += chunk.length; + chunks.push(chunk); + } + if (size === 2 * 1024 * 1024 + 1) { + return reply.code(400).send({ error: "File too large" }); + } + const buffer = Buffer.concat(chunks); + if (!part.filename || part.filename.trim() === '') { + return reply.code(400).send({ error: "Missing filename" }); + } + if (!part.mimetype || part.mimetype.trim() === '') { + return reply.code(400).send({ error: "Missing mimetype" }); + } + const webpBuffer = await sharp(buffer).toFormat('webp').toBuffer(); + const imageId = postImage.run(part.filename, part.mimetype, webpBuffer); + setAvatarId.run(imageId.lastInsertRowid, userId); + return reply.code(200).send({ msg: "Avatar uploaded successfully" }); + } } - const data = await res.json(); - setAvatarId.run(data.imageId, userId); - return reply.code(200).send({ msg: "Avatar uploaded successfully" }); + return reply.code(400).send({ error: "No avatar uploaded" }); } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: "Internal server error" }); diff --git a/src/api/user/uAvatar.js b/src/api/user/uAvatar.js new file mode 100644 index 0000000..b5b8b72 --- /dev/null +++ b/src/api/user/uAvatar.js @@ -0,0 +1,45 @@ +import sharp from 'sharp'; + +export async function uAvatar(request, reply, fastify, getUserInfo, setAvatarId, getAvatarId, deleteAvatarId, postImage, deleteImage) { + try { + const userId = request.params.userId; + if (!getUserInfo.get(userId)) { + return reply.cose(404).send({ error: "User does not exist" }); + } + deleteAvatarId.run(userId); + const parts = request.parts(); + for await (const part of parts) { + if (part.file) { + let size = 0; + const chunks = []; + for await (const chunk of part.file) { + size += chunk.length; + chunks.push(chunk); + } + if (size === 2 * 1024 * 1024 + 1) { + return reply.code(400).send({ error: "File too large" }); + } + const buffer = Buffer.concat(chunks); + if (!part.filename || part.filename.trim() === '') { + return reply.code(400).send({ error: "Missing filename" }); + } + if (!part.mimetype || part.mimetype.trim() === '') { + return reply.code(400).send({ error: "Missing mimetype" }); + } + const webpBuffer = await sharp(buffer).toFormat('webp').toBuffer(); + const imageId = postImage.run(part.filename, part.mimetype, webpBuffer); + const oldImageId = getAvatarId.get(userId); + if (oldImageId.avatarId !== -1) { + deleteImage.run(oldImageId.avatarId); + deleteAvatarId.run(userId); + } + setAvatarId.run(imageId.lastInsertRowid, userId); + return reply.code(200).send({ msg: "Avatar modified successfully" }); + } + } + return reply.code(400).send({ error: "No avatar modified" }); + } catch (err) { + fastify.log.error(err); + return reply.code(500).send({ error: "Internal server error" }); + } +} diff --git a/src/start.js b/src/start.js index c99534d..df7bc0e 100644 --- a/src/start.js +++ b/src/start.js @@ -1,7 +1,6 @@ import Fastify from 'fastify'; import authApi from './api/auth/default.js'; import userApi from './api/user/default.js'; -import imagesApi from './api/images/default.js'; import scoreApi from './api/scoreStore/default.js'; import fs from 'fs'; import path from 'path'; @@ -69,16 +68,6 @@ async function start() { servers.push(score); } - if (target === 'images' || target === 'all') { - const images = Fastify({ logger: loggerOption('images') }); - images.register(imagesApi); - const port = target === 'all' ? 3004 : 3000; - const host = target === 'all' ? '127.0.0.1' : '0.0.0.0'; - await images.listen({ port, host }); - console.log(`Images API listening on http://${host}:${port}`); - servers.push(images); - } - // Graceful shutdown on SIGINT process.on('SIGINT', async () => { console.log('SIGINT received, closing servers...');