From 81b0aa9657db3627aa9cce3d4923d19cc043e08c Mon Sep 17 00:00:00 2001 From: trivernis Date: Sat, 18 Jan 2020 13:14:21 +0100 Subject: [PATCH] Add Group Picture upload - Add field picture to Group type - Add config option for image format - Add groupPicture file handling at /upload --- config/default.toml | 3 + package.json | 2 + src/graphql/schema.graphql | 3 + src/lib/models/Group.ts | 8 +++ src/routes/UploadRoute.ts | 128 +++++++++++++++++++++++++++++++------ yarn.lock | 4 +- 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/config/default.toml b/config/default.toml index 8b38170..8f581fc 100644 --- a/config/default.toml +++ b/config/default.toml @@ -68,6 +68,9 @@ graphiql = true # The maximum complexity of queries maxQueryComplexity = 5000 +# sets the image format for the site. Values: png or webp +imageFormat = "png" + # Configuration for the api rate limit [api.rateLimit] diff --git a/package.json b/package.json index 33424ce..d68e268 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "typescript": "^3.7.2" }, "dependencies": { + "@types/body-parser": "^1.17.1", + "body-parser": "^1.19.0", "compression": "^1.7.4", "config": "^3.2.4", "connect-session-sequelize": "^6.0.0", diff --git a/src/graphql/schema.graphql b/src/graphql/schema.graphql index adec8b8..7bbd02e 100644 --- a/src/graphql/schema.graphql +++ b/src/graphql/schema.graphql @@ -380,6 +380,9 @@ type Group { "name of the group" name: String! + "the groups icon" + picture: String + "the creator of the group" creator: User! diff --git a/src/lib/models/Group.ts b/src/lib/models/Group.ts index eb445e1..bd0e86d 100644 --- a/src/lib/models/Group.ts +++ b/src/lib/models/Group.ts @@ -1,3 +1,4 @@ +import * as sqz from "sequelize"; import { BelongsTo, BelongsToMany, @@ -29,6 +30,13 @@ export class Group extends Model { @Column({allowNull: false, unique: true}) public name: string; + + /** + * The url of the groups avatar picture + */ + @Column({type: sqz.STRING(512)}) + public picture: string; + /** * The id of the user who created the group */ diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index 1beab93..420a473 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -1,13 +1,16 @@ +import * as bodyParser from "body-parser"; +import * as config from "config"; import * as crypto from "crypto"; import {Router} from "express"; import * as fileUpload from "express-fileupload"; import {UploadedFile} from "express-fileupload"; import * as fsx from "fs-extra"; +import {IncomingMessage} from "http"; import * as status from "http-status"; import * as path from "path"; import * as sharp from "sharp"; import globals from "../lib/globals"; -import {User} from "../lib/models"; +import {Group, User} from "../lib/models"; import Route from "../lib/Route"; const dataDirName = "data"; @@ -29,6 +32,8 @@ interface IUploadConfirmation { success: boolean; } +type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside"; + /** * Represents an upload handler. */ @@ -60,12 +65,15 @@ export class UploadRoute extends Route { public async init() { await fsx.ensureDir(this.dataDir); this.router.use(fileUpload()); + this.router.use(bodyParser()); // Uploads a file to the data directory and returns the filename this.router.use(async (req, res) => { let uploadConfirmation: IUploadConfirmation; if (req.session.userId) { if (req.files.profilePicture) { uploadConfirmation = await this.uploadProfilePicture(req); + } else if (req.files.groupPicture) { + uploadConfirmation = await this.uploadGroupPicture(req); } else { res.status(status.BAD_REQUEST); uploadConfirmation = { @@ -103,26 +111,18 @@ export class UploadRoute extends Route { let fileName: string; const profilePic = request.files.profilePicture as UploadedFile; try { - const fileBasename = UploadRoute.getFileName() + ".webp"; - const filePath = path.join(this.dataDir, fileBasename); - await sharp(profilePic.data) - .resize(512, 512) - .normalise() - .webp({smartSubsample: true, reductionEffort: 6}) - .toFile(filePath); - fileName = `/${dataDirName}/${fileBasename}`; const user = await User.findByPk(request.session.userId); - if (user.profilePicture) { - const oldProfilePicture = path.join(this.dataDir, path.basename(user.profilePicture)); - if (await fsx.pathExists(oldProfilePicture)) { - await fsx.unlink(oldProfilePicture); - } else { - globals.logger.warn(`Could not delete ${oldProfilePicture}: Not found!`); + if (user) { + fileName = await this.processAndStoreImage(profilePic.data); + if (user.profilePicture) { + await this.deleteWebImage(user.profilePicture); } + user.profilePicture = fileName; + await user.save(); + success = true; + } else { + error = "User not found"; } - user.profilePicture = fileName; - await user.save(); - success = true; } catch (err) { globals.logger.error(err.message); globals.logger.debug(err.stack); @@ -134,4 +134,96 @@ export class UploadRoute extends Route { success, }; } + + /** + * Uploads an avatar image for a group. + * @param request + */ + private async uploadGroupPicture(request: any): Promise { + let success = false; + let error: string; + let fileName: string; + const groupPicture = request.files.groupPicture as UploadedFile; + if (request.body.groupId) { + try { + const user = await User.findByPk(request.session.userId); + const group = await Group.findByPk(request.body.groupId); + if (!group) { + error = `No group with the id '${request.body.groupId}' found.`; + return { + error, + success, + }; + } + const isAdmin = await group.$has("rAdmins", user); + if (isAdmin) { + fileName = await this.processAndStoreImage(groupPicture.data); + if (group.picture) { + await this.deleteWebImage(group.picture); + } + group.picture = fileName; + await group.save(); + success = true; + } else { + error = "You are not a group admin."; + } + } catch (err) { + globals.logger.error(err.message); + globals.logger.debug(err.stack); + error = err.message; + } + } else { + error = "No groupId provided! (the request body must contain a groupId)"; + } + return { + error, + fileName, + success, + }; + } + + /** + * Converts a file to the webp format and stores it with a uuid filename. + * The web path for the image is returned. + * @param data + * @param width + * @param height + * @param fit + */ + private async processAndStoreImage(data: Buffer, width = 512, height = 512, + fit: ImageFit = "cover"): Promise { + const fileBasename = UploadRoute.getFileName() + "." + config.get("api.imageFormat"); + const filePath = path.join(this.dataDir, fileBasename); + let image = await sharp(data) + .resize(width, height, { + fit, + }) + .normalise(); + if (config.get("api.imageFormat") === "webp") { + image = await image.webp({ + reductionEffort: 6, + smartSubsample: true, + }); + } else { + image = await image.png({ + adaptiveFiltering: true, + colors: 128, + }); + } + await image.toFile(filePath); + return `/${dataDirName}/${fileBasename}`; + } + + /** + * Deletes an image for a provided web path. + * @param webPath + */ + private async deleteWebImage(webPath: string) { + const realPath = path.join(this.dataDir, path.basename(webPath)); + if (await fsx.pathExists(realPath)) { + await fsx.unlink(realPath); + } else { + globals.logger.warn(`Could not delete web image ${realPath}: Not found!`); + } + } } diff --git a/yarn.lock b/yarn.lock index 55999f6..e877cd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35,7 +35,7 @@ resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.29.tgz#7cd933c902c4fc83046517a1bef973886d00bdb6" integrity sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw== -"@types/body-parser@*": +"@types/body-parser@*", "@types/body-parser@^1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897" integrity sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w== @@ -749,7 +749,7 @@ bluebird@^3.5.0: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de" integrity sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg== -body-parser@1.19.0: +body-parser@1.19.0, body-parser@^1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==