From b33bbd1c14c0252dc14331db3581d05c5bf29882 Mon Sep 17 00:00:00 2001 From: Trivernis Date: Wed, 22 Jan 2020 17:32:47 +0100 Subject: [PATCH 1/6] Add max file size and video processing - Add processing for videos to UploadRoute --- config/default.toml | 3 ++ package.json | 5 +++ src/routes/UploadRoute.ts | 42 +++++++++++++++++--- yarn.lock | 82 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 126 insertions(+), 6 deletions(-) diff --git a/config/default.toml b/config/default.toml index 5b76c38..62bec3b 100644 --- a/config/default.toml +++ b/config/default.toml @@ -64,6 +64,9 @@ maxQueryComplexity = 5000 # sets the image format for the site. Values: png or webp imageFormat = "png" +# the max file size for uploading in bytes +maxFileSize = 5_242_880 + # Configuration for the api rate limit [api.rateLimit] diff --git a/package.json b/package.json index ffd79fe..575f954 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/express-graphql": "^0.8.0", "@types/express-session": "^1.15.14", "@types/express-socket.io-session": "^1.3.2", + "@types/fluent-ffmpeg": "^2.1.13", "@types/fs-extra": "^8.0.0", "@types/graphql-query-complexity": "^0.2.1", "@types/http-status": "^0.2.30", @@ -45,6 +46,7 @@ "@types/sharp": "^0.23.1", "@types/socket.io": "^2.1.2", "@types/socket.io-redis": "^1.0.25", + "@types/stream-buffers": "^3.0.3", "@types/uuid": "^3.4.6", "@types/validator": "^10.11.3", "chai": "^4.2.0", @@ -59,6 +61,7 @@ "typescript": "^3.7.2" }, "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.0.20", "@types/body-parser": "^1.17.1", "body-parser": "^1.19.0", "compression": "^1.7.4", @@ -72,6 +75,7 @@ "express-limiter": "^1.6.1", "express-session": "^1.16.2", "express-socket.io-session": "^1.3.5", + "fluent-ffmpeg": "^2.1.2", "fs-extra": "^8.1.0", "graphql": "^14.4.2", "graphql-import": "^0.7.1", @@ -91,6 +95,7 @@ "socket.io": "^2.2.0", "socket.io-redis": "^5.2.0", "sqlite3": "^4.1.0", + "stream-buffers": "^3.0.2", "toml": "^3.0.0", "uuid": "^3.3.3", "winston": "^3.2.1", diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index 6f8b10a..66d2c1c 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -4,14 +4,17 @@ import * as crypto from "crypto"; import {Router} from "express"; import * as fileUpload from "express-fileupload"; import {UploadedFile} from "express-fileupload"; +import * as ffmpeg from "fluent-ffmpeg"; import * as fsx from "fs-extra"; import * as status from "http-status"; import * as path from "path"; import * as sharp from "sharp"; +import {ReadableStreamBuffer} from "stream-buffers"; import globals from "../lib/globals"; import {Group, User} from "../lib/models"; import Route from "../lib/Route"; +const ffmpegPath = require("@ffmpeg-installer/ffmpeg").path; const dataDirName = "data"; interface IUploadConfirmation { @@ -56,6 +59,7 @@ export class UploadRoute extends Route { super(); this.router = Router(); this.dataDir = path.join(this.publicPath, dataDirName); + ffmpeg.setFfmpegPath(ffmpegPath); } /** @@ -63,7 +67,9 @@ export class UploadRoute extends Route { */ public async init() { await fsx.ensureDir(this.dataDir); - this.router.use(fileUpload()); + this.router.use(fileUpload({ + limits: config.get("api.maxFileSize"), + })); this.router.use(bodyParser()); // Uploads a file to the data directory and returns the filename this.router.use(async (req, res) => { @@ -114,7 +120,7 @@ export class UploadRoute extends Route { if (user) { fileName = await this.processAndStoreImage(profilePic.data); if (user.profilePicture) { - await this.deleteWebImage(user.profilePicture); + await this.deleteWebFile(user.profilePicture); } user.profilePicture = fileName; await user.save(); @@ -158,7 +164,7 @@ export class UploadRoute extends Route { if (isAdmin) { fileName = await this.processAndStoreImage(groupPicture.data); if (group.picture) { - await this.deleteWebImage(group.picture); + await this.deleteWebFile(group.picture); } group.picture = fileName; await group.save(); @@ -199,7 +205,7 @@ export class UploadRoute extends Route { fit, }) .normalise(); - if (config.get("api.imageFormat") === "webp") { + if (config.get("api.imageFormat") === "webp") { image = await image.webp({ reductionEffort: 6, smartSubsample: true, @@ -214,11 +220,37 @@ export class UploadRoute extends Route { return `/${dataDirName}/${fileBasename}`; } + /** + * Converts a video into a smaller format and .mp4 and returns the web path + * @param data + * @param width + */ + private async processAndStoreVideo(data: Buffer, width: number = 720): Promise { + return new Promise(async (resolve) => { + const fileBasename = UploadRoute.getFileName() + ".mp4"; + await fsx.ensureDir(this.dataDir); + const filePath = path.join(this.dataDir, fileBasename); + const videoFileStream = new ReadableStreamBuffer({ + chunkSize: 2048, + frequency: 10, + }); + videoFileStream.put(data); + const video = ffmpeg(videoFileStream); + video + .on("end", () => { + resolve(`/${dataDirName}/${fileBasename}`); + }) + .size(`${width}x?`) + .toFormat("libx264") + .output(filePath); + }); + } + /** * Deletes an image for a provided web path. * @param webPath */ - private async deleteWebImage(webPath: string) { + private async deleteWebFile(webPath: string) { const realPath = path.join(this.dataDir, path.basename(webPath)); if (await fsx.pathExists(realPath)) { await fsx.unlink(realPath); diff --git a/yarn.lock b/yarn.lock index 8c6b810..52703c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18,6 +18,54 @@ esutils "^2.0.2" js-tokens "^4.0.0" +"@ffmpeg-installer/darwin-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz#48e1706c690e628148482bfb64acb67472089aaa" + integrity sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw== + +"@ffmpeg-installer/ffmpeg@^1.0.20": + version "1.0.20" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.0.20.tgz#d3c9c2bbcd76149468fb0886c2b3fe9e4795490b" + integrity sha512-wbgd//6OdwbFXYgV68ZyKrIcozEQpUKlvV66XHaqO2h3sFbX0jYLzx62Q0v8UcFWN21LoxT98NU2P+K0OWsKNA== + optionalDependencies: + "@ffmpeg-installer/darwin-x64" "4.1.0" + "@ffmpeg-installer/linux-arm" "4.1.3" + "@ffmpeg-installer/linux-arm64" "4.1.4" + "@ffmpeg-installer/linux-ia32" "4.1.0" + "@ffmpeg-installer/linux-x64" "4.1.0" + "@ffmpeg-installer/win32-ia32" "4.1.0" + "@ffmpeg-installer/win32-x64" "4.1.0" + +"@ffmpeg-installer/linux-arm64@4.1.4": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz#7219f3f901bb67f7926cb060b56b6974a6cad29f" + integrity sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg== + +"@ffmpeg-installer/linux-arm@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz#c554f105ed5f10475ec25d7bec94926ce18db4c1" + integrity sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg== + +"@ffmpeg-installer/linux-ia32@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz#adad70b0d0d9d8d813983d6e683c5a338a75e442" + integrity sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ== + +"@ffmpeg-installer/linux-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz#b4a5d89c4e12e6d9306dbcdc573df716ec1c4323" + integrity sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A== + +"@ffmpeg-installer/win32-ia32@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz#6eac4fb691b64c02e7a116c1e2d167f3e9b40638" + integrity sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw== + +"@ffmpeg-installer/win32-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz#17e8699b5798d4c60e36e2d6326a8ebe5e95a2c5" + integrity sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg== + "@types/babel-types@*", "@types/babel-types@^7.0.0": version "7.0.7" resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.7.tgz#667eb1640e8039436028055737d2b9986ee336e3" @@ -145,6 +193,13 @@ "@types/express-serve-static-core" "*" "@types/serve-static" "*" +"@types/fluent-ffmpeg@^2.1.13": + version "2.1.13" + resolved "https://registry.yarnpkg.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.13.tgz#bfffbcf298b0980924e9ba9aa471aba234626afb" + integrity sha512-hg87ZQb9WVcNGQHNhrYwWJM0ARNYbQbLGh1c6CfPl55/I+BH5UTpFJAr5aZWYGbl8BFVY82oF5iG4I+Ra3btiQ== + dependencies: + "@types/node" "*" + "@types/fs-extra@^8.0.0": version "8.0.1" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-8.0.1.tgz#a2378d6e7e8afea1564e44aafa2e207dadf77686" @@ -265,6 +320,13 @@ dependencies: "@types/node" "*" +"@types/stream-buffers@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/stream-buffers/-/stream-buffers-3.0.3.tgz#34e565bf64e3e4bdeee23fd4aa58d4636014a02b" + integrity sha512-NeFeX7YfFZDYsCfbuaOmFQ0OjSmHreKBpp7MQ4alWQBHeh2USLsj7qyMyn9t82kjqIX516CR/5SRHnARduRtbQ== + dependencies: + "@types/node" "*" + "@types/uuid@^3.4.6": version "3.4.6" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.6.tgz#d2c4c48eb85a757bf2927f75f939942d521e3016" @@ -601,6 +663,11 @@ async-settle@^1.0.0: dependencies: async-done "^1.2.2" +async@>=0.2.9: + version "3.1.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.1.0.tgz#42b3b12ae1b74927b5217d8c0016baaf62463772" + integrity sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ== + async@^2.6.1: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" @@ -2011,6 +2078,14 @@ flat@^4.1.0: dependencies: is-buffer "~2.0.3" +fluent-ffmpeg@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz#c952de2240f812ebda0aa8006d7776ee2acf7d74" + integrity sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ= + dependencies: + async ">=0.2.9" + which "^1.1.1" + flush-write-stream@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" @@ -4832,6 +4907,11 @@ static-extend@^0.1.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +stream-buffers@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-3.0.2.tgz#5249005a8d5c2d00b3a32e6e0a6ea209dc4f3521" + integrity sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ== + stream-exhaust@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/stream-exhaust/-/stream-exhaust-1.0.2.tgz#acdac8da59ef2bc1e17a2c0ccf6c320d120e555d" @@ -5476,7 +5556,7 @@ which-pm-runs@^1.0.0: resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= -which@1.3.1, which@^1.2.14: +which@1.3.1, which@^1.1.1, which@^1.2.14: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== From 010066d8198096135389756837e4aea0abfdb7c9 Mon Sep 17 00:00:00 2001 From: Trivernis Date: Wed, 22 Jan 2020 17:56:29 +0100 Subject: [PATCH 2/6] Add graphqlUpload --- package.json | 2 + src/app.ts | 9 ++- src/graphql/schema.graphql | 2 + yarn.lock | 113 +++++++++++++++++++++++++++++++++++-- 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 575f954..5aa3d63 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.0.20", "@types/body-parser": "^1.17.1", + "@types/graphql-upload": "^8.0.3", "body-parser": "^1.19.0", "compression": "^1.7.4", "config": "^3.2.4", @@ -80,6 +81,7 @@ "graphql": "^14.4.2", "graphql-import": "^0.7.1", "graphql-query-complexity": "^0.4.1", + "graphql-upload": "^9.0.0", "http-status": "^1.3.2", "js-yaml": "^3.13.1", "legit": "^1.0.7", diff --git a/src/app.ts b/src/app.ts index eeebd33..2e9c2e0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import * as fsx from "fs-extra"; import {buildSchema, GraphQLError} from "graphql"; import {importSchema} from "graphql-import"; import queryComplexity, {directiveEstimator, simpleEstimator} from "graphql-query-complexity"; +import {graphqlUploadExpress} from "graphql-upload"; import * as http from "http"; import {IncomingMessage} from "http"; import * as httpStatus from "http-status"; @@ -191,6 +192,10 @@ class App { total: config.get("api.rateLimit.graphql.total"), }); + this.app.use("/graphql", graphqlUploadExpress({ + maxFileSize: config.get("api.maxFileSize"), + maxFiles: 10, + })); // @ts-ignore this.app.use("/graphql", graphqlHTTP(async (request: any, response: any, {variables}) => { response.setHeader("X-Max-Query-Complexity", config.get("api.maxQueryComplexity")); @@ -207,7 +212,7 @@ class App { logger.silly(err.stack); return err.graphqlError ?? err; }, - graphiql: config.get("api.graphiql"), + graphiql: config.get("api.graphiql"), rootValue: resolver(request, response), schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))), validationRules: [ @@ -216,7 +221,7 @@ class App { directiveEstimator(), simpleEstimator(), ], - maximumComplexity: config.get("api.maxQueryComplexity"), + maximumComplexity: config.get("api.maxQueryComplexity"), onComplete: (complexity: number) => { logger.debug(`QueryComplexity: ${complexity}`); response.setHeader("X-Query-Complexity", complexity); diff --git a/src/graphql/schema.graphql b/src/graphql/schema.graphql index c7bc105..3015271 100644 --- a/src/graphql/schema.graphql +++ b/src/graphql/schema.graphql @@ -7,6 +7,8 @@ directive @complexity( multipliers: [String!] ) on FIELD_DEFINITION +scalar Upload + type Query { "returns the user object for a given user id or a handle (only one required)" getUser(userId: ID, handle: String): User diff --git a/yarn.lock b/yarn.lock index 52703c3..3f6bb53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -66,6 +66,13 @@ resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz#17e8699b5798d4c60e36e2d6326a8ebe5e95a2c5" integrity sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg== +"@types/accepts@*": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" + integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ== + dependencies: + "@types/node" "*" + "@types/babel-types@*", "@types/babel-types@^7.0.0": version "7.0.7" resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.7.tgz#667eb1640e8039436028055737d2b9986ee336e3" @@ -138,6 +145,16 @@ dependencies: "@types/express" "*" +"@types/cookies@*": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.4.tgz#26dedf791701abc0e36b5b79a5722f40e455f87b" + integrity sha512-oTGtMzZZAVuEjTwCjIh8T8FrC8n/uwy+PG0yTvQcdZ7etoel7C7/3MSd7qrukENTgQtotG7gvBlBojuVs7X5rw== + dependencies: + "@types/connect" "*" + "@types/express" "*" + "@types/keygrip" "*" + "@types/node" "*" + "@types/cors@^2.8.6": version "2.8.6" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.6.tgz#cfaab33c49c15b1ded32f235111ce9123009bd02" @@ -200,6 +217,13 @@ dependencies: "@types/node" "*" +"@types/fs-capacitor@*": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/fs-capacitor/-/fs-capacitor-2.0.0.tgz#17113e25817f584f58100fb7a08eed288b81956e" + integrity sha512-FKVPOCFbhCvZxpVAMhdBdTfVfXUpsh15wFHgqOKxh9N9vzWZVuWCSijZ5T4U34XYNnuj2oduh6xcs1i+LPI+BQ== + dependencies: + "@types/node" "*" + "@types/fs-extra@^8.0.0": version "8.0.1" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-8.0.1.tgz#a2378d6e7e8afea1564e44aafa2e207dadf77686" @@ -214,6 +238,21 @@ dependencies: graphql-query-complexity "*" +"@types/graphql-upload@^8.0.3": + version "8.0.3" + resolved "https://registry.yarnpkg.com/@types/graphql-upload/-/graphql-upload-8.0.3.tgz#b371edb5f305a2a1f7b7843a890a2a7adc55c3ec" + integrity sha512-hmLg9pCU/GmxBscg8GCr1vmSoEmbItNNxdD5YH2TJkXm//8atjwuprB+xJBK714JG1dkxbbhp5RHX+Pz1KsCMA== + dependencies: + "@types/express" "*" + "@types/fs-capacitor" "*" + "@types/koa" "*" + graphql "^14.5.3" + +"@types/http-assert@*": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" + integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ== + "@types/http-status@^0.2.30": version "0.2.30" resolved "https://registry.yarnpkg.com/@types/http-status/-/http-status-0.2.30.tgz#b43a1e1673b6ed9b5a28e8647862b51b6473634d" @@ -224,6 +263,30 @@ resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.1.tgz#5c6f4a1eabca84792fbd916f0cb40847f123c656" integrity sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA== +"@types/keygrip@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" + integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== + +"@types/koa-compose@*": + version "3.2.5" + resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d" + integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ== + dependencies: + "@types/koa" "*" + +"@types/koa@*": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.11.0.tgz#394a3e9ec94f796003a6c8374b4dbc2778746f20" + integrity sha512-Hgx/1/rVlJvqYBrdeCsS7PDiR2qbxlMt1RnmNWD4Uxi5FF9nwkYqIldo7urjc+dfNpk+2NRGcnAYd4L5xEhCcQ== + dependencies: + "@types/accepts" "*" + "@types/cookies" "*" + "@types/http-assert" "*" + "@types/keygrip" "*" + "@types/koa-compose" "*" + "@types/node" "*" + "@types/linkify-it@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-2.1.0.tgz#ea3dd64c4805597311790b61e872cbd1ed2cd806" @@ -274,6 +337,14 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== +"@types/readable-stream@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-2.3.5.tgz#99c215f9c78563ebdfeff400246a724fb36bae4a" + integrity sha512-Mq2eLkGYamlcolW603FY2ROBvcl90jPF+3jLkjpBV6qS+2aVeJqlgRG0TVAa1oWbmPdb5yOWlOPVvQle76nUNw== + dependencies: + "@types/node" "*" + safe-buffer "*" + "@types/redis@^2.8.14": version "2.8.14" resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.14.tgz#2ed46d0f923f7ccd63fbe73a46a1241e606cf716" @@ -2137,6 +2208,14 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +fs-capacitor@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-4.0.1.tgz#eb65700e641ce0323411694c1d8c1609273642ff" + integrity sha512-e0qFoKQMFe52F54dMvZLD+I1M/Gs6xB2gnZVQB5FYT/8ioP6qTb3U/tzp55O0IuPOMvSM8j4ta0bVafIFjJzxQ== + dependencies: + "@types/readable-stream" "^2.3.5" + readable-stream "^3.4.0" + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -2367,6 +2446,16 @@ graphql-query-complexity@*, graphql-query-complexity@^0.4.1: dependencies: lodash.get "^4.4.2" +graphql-upload@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/graphql-upload/-/graphql-upload-9.0.0.tgz#14c5250895f5d04ea83dcfcb3a16bd0e1c4cee95" + integrity sha512-YR2o9GoDa5On3q3lYLkLo3gHfa8crCHvMY1QbT7Zqja6BUqiihqaGjbWbvSPko/gbDSmZE+zLcX46Ef+/SmRyA== + dependencies: + busboy "^0.3.1" + fs-capacitor "^4.0.1" + http-errors "^1.7.3" + object-path "^0.11.4" + graphql@^14.4.2, graphql@^14.5.3: version "14.5.8" resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.8.tgz#504f3d3114cb9a0a3f359bbbcf38d9e5bf6a6b3c" @@ -3648,6 +3737,11 @@ object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +object-path@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949" + integrity sha1-NwrnUvvzfePqcKhhwju6iRVpGUk= + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -4298,6 +4392,15 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.4.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.5.0.tgz#465d70e6d1087f6162d079cd0b5db7fbebfd1606" + integrity sha512-gSz026xs2LfxBPudDuI41V1lka8cxg64E66SGe78zJlsUofOg/yqwezdIcdfwik6B4h8LFmWPA9ef9X3FiNFLA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" @@ -4503,16 +4606,16 @@ rimraf@^2.6.1: dependencies: glob "^7.1.3" +safe-buffer@*, safe-buffer@5.2.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" + integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" - integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== - safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" From 2899eb6762e4e6db0492071cb2e8f6303be4106f Mon Sep 17 00:00:00 2001 From: Trivernis Date: Wed, 22 Jan 2020 19:59:59 +0100 Subject: [PATCH 3/6] [WIP] Add media to posts - Add graphql upload handling - Add file handling for posts - Add media url to Post model --- config/default.toml | 1 - package.json | 3 +- src/app.ts | 49 +------ src/index.ts | 2 +- src/lib/UploadManager.ts | 136 ++++++++++++++++++ src/lib/errors/InvalidFileError.ts | 14 ++ src/lib/errors/UploadFailedError.ts | 10 ++ src/lib/globals.ts | 13 ++ src/lib/models/Post.ts | 6 + src/lib/regex.ts | 18 +++ src/routes/GraphqlRoute.ts | 81 +++++++++++ src/routes/UploadRoute.ts | 87 ++--------- src/{ => routes}/graphql/BaseResolver.ts | 2 +- src/{ => routes}/graphql/BlacklistedResult.ts | 0 src/{ => routes}/graphql/MutationResolver.ts | 55 +++++-- src/{ => routes}/graphql/QueryResolver.ts | 14 +- src/{ => routes}/graphql/SearchResult.ts | 2 +- src/{ => routes}/graphql/Token.ts | 0 src/{ => routes}/graphql/resolvers.ts | 0 src/{ => routes}/graphql/schema.graphql | 18 ++- yarn.lock | 24 +++- 21 files changed, 379 insertions(+), 156 deletions(-) create mode 100644 src/lib/UploadManager.ts create mode 100644 src/lib/errors/InvalidFileError.ts create mode 100644 src/lib/errors/UploadFailedError.ts create mode 100644 src/routes/GraphqlRoute.ts rename src/{ => routes}/graphql/BaseResolver.ts (84%) rename src/{ => routes}/graphql/BlacklistedResult.ts (100%) rename src/{ => routes}/graphql/MutationResolver.ts (88%) rename src/{ => routes}/graphql/QueryResolver.ts (92%) rename src/{ => routes}/graphql/SearchResult.ts (81%) rename src/{ => routes}/graphql/Token.ts (100%) rename src/{ => routes}/graphql/resolvers.ts (100%) rename src/{ => routes}/graphql/schema.graphql (93%) diff --git a/config/default.toml b/config/default.toml index 62bec3b..14e8a6a 100644 --- a/config/default.toml +++ b/config/default.toml @@ -51,7 +51,6 @@ angularIndex = "index.html" # The path to the public files publicPath = "./public" - # Configuration for the api [api] diff --git a/package.json b/package.json index 5aa3d63..d4be8d4 100644 --- a/package.json +++ b/package.json @@ -93,11 +93,12 @@ "reflect-metadata": "^0.1.13", "sequelize": "^5.19.6", "sequelize-typescript": "^1.0.0", - "sharp": "^0.23.4", + "sharp": "^0.24.0", "socket.io": "^2.2.0", "socket.io-redis": "^5.2.0", "sqlite3": "^4.1.0", "stream-buffers": "^3.0.2", + "stream-to-array": "^2.3.0", "toml": "^3.0.0", "uuid": "^3.3.3", "winston": "^3.2.1", diff --git a/src/app.ts b/src/app.ts index 2e9c2e0..3e1a6e5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,14 +4,9 @@ import * as cookieParser from "cookie-parser"; import * as cors from "cors"; import * as express from "express"; import {Request, Response} from "express"; -import * as graphqlHTTP from "express-graphql"; import * as session from "express-session"; import sharedsession = require("express-socket.io-session"); import * as fsx from "fs-extra"; -import {buildSchema, GraphQLError} from "graphql"; -import {importSchema} from "graphql-import"; -import queryComplexity, {directiveEstimator, simpleEstimator} from "graphql-query-complexity"; -import {graphqlUploadExpress} from "graphql-upload"; import * as http from "http"; import {IncomingMessage} from "http"; import * as httpStatus from "http-status"; @@ -21,9 +16,9 @@ import {RedisClient} from "redis"; import {Sequelize} from "sequelize-typescript"; import * as socketIo from "socket.io"; import * as socketIoRedis from "socket.io-redis"; -import {resolver} from "./graphql/resolvers"; import dataaccess from "./lib/dataAccess"; import globals from "./lib/globals"; +import {GraphqlRoute} from "./routes/GraphqlRoute"; import HomeRoute from "./routes/HomeRoute"; import {UploadRoute} from "./routes/UploadRoute"; @@ -161,8 +156,10 @@ class App { const uploadRoute = new UploadRoute(this.publicPath); const homeRoute = new HomeRoute(); + const graphqlRoute = new GraphqlRoute(); await uploadRoute.init(); await homeRoute.init(this.io); + await graphqlRoute.init(); this.app.use("/home", homeRoute.router); this.limiter({ @@ -191,46 +188,8 @@ class App { path: "/graphql", total: config.get("api.rateLimit.graphql.total"), }); + this.app.use("/graphql", graphqlRoute.router); - this.app.use("/graphql", graphqlUploadExpress({ - maxFileSize: config.get("api.maxFileSize"), - maxFiles: 10, - })); - // @ts-ignore - this.app.use("/graphql", graphqlHTTP(async (request: any, response: any, {variables}) => { - response.setHeader("X-Max-Query-Complexity", config.get("api.maxQueryComplexity")); - return { - // @ts-ignore all - context: {session: request.session}, - formatError: (err: GraphQLError | any) => { - if (err.statusCode) { - response.status(err.statusCode); - } else { - response.status(400); - } - logger.debug(err.message); - logger.silly(err.stack); - return err.graphqlError ?? err; - }, - graphiql: config.get("api.graphiql"), - rootValue: resolver(request, response), - schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))), - validationRules: [ - queryComplexity({ - estimators: [ - directiveEstimator(), - simpleEstimator(), - ], - maximumComplexity: config.get("api.maxQueryComplexity"), - onComplete: (complexity: number) => { - logger.debug(`QueryComplexity: ${complexity}`); - response.setHeader("X-Query-Complexity", complexity); - }, - variables, - }), - ], - }; - })); // allow access to cluster information this.app.use("/cluster-info", (req: Request, res: Response) => { res.json({ diff --git a/src/index.ts b/src/index.ts index 928d26a..ddd928e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ if (cluster.isMaster) { cluster.on("exit", (worker, code) => { console.error(`[CLUSTER-M] Worker ${worker.id} died! (code: ${code})`); console.log("[CLUSTER-M] Starting new worker"); - cluster.fork(); + setTimeout(cluster.fork, 1000); }); cluster.on("online", (worker) => { worker.process.stdout.on("data", (data) => { diff --git a/src/lib/UploadManager.ts b/src/lib/UploadManager.ts new file mode 100644 index 0000000..a12d68c --- /dev/null +++ b/src/lib/UploadManager.ts @@ -0,0 +1,136 @@ +import * as config from "config"; +import * as crypto from "crypto"; +import * as ffmpeg from "fluent-ffmpeg"; +import * as fsx from "fs-extra"; +import * as path from "path"; +import * as sharp from "sharp"; +import {Readable} from "stream"; +import {ReadableStreamBuffer} from "stream-buffers"; +import globals from "./globals"; + +const toArray = require("stream-to-array"); + +const dataDirName = "data"; + +interface IUploadConfirmation { + /** + * Indicates the error that might have occured during the upload + */ + error?: string; + + /** + * The file that has been uploaded + */ + fileName?: string; + + /** + * If the upload was successful + */ + success: boolean; +} + +type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside"; + +/** + * A helper class for uploading and managing files + */ +export class UploadManager { + + /** + * Returns the hash of the current time to be used as a filename. + */ + private static getCrypticFileName() { + const hash = crypto.createHash("md5"); + hash.update(Number(Date.now()).toString()); + return hash.digest("hex"); + } + + private readonly dataDir: string; + + constructor() { + this.dataDir = path.join(globals.getPublicDir(), dataDirName); + } + + /** + * Deletes an image for a provided web path. + * @param webPath + */ + public async deleteWebFile(webPath: string) { + const realPath = path.join(dataDirName, path.basename(webPath)); + if (await fsx.pathExists(realPath)) { + await fsx.unlink(realPath); + } else { + globals.logger.warn(`Could not delete web image ${realPath}: Not found!`); + } + } + + /** + * 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 + */ + public async processAndStoreImage(data: Buffer, width = 512, height = 512, + fit: ImageFit = "cover"): Promise { + const fileBasename = UploadManager.getCrypticFileName() + "." + config.get("api.imageFormat"); + await fsx.ensureDir(this.dataDir); + const filePath = path.join(this.dataDir, fileBasename); + let image = sharp(data) + .resize(width, height, { + fit, + }) + .normalise(); + if (config.get("api.imageFormat") === "webp") { + image = image.webp({ + reductionEffort: 6, + smartSubsample: true, + }); + } else { + image = image.png({ + adaptiveFiltering: true, + colors: 128, + }); + } + await image.toFile(filePath); + return `/${dataDirName}/${fileBasename}`; + } + + /** + * Converts a video into a smaller format and .mp4 and returns the web path + * @param data + * @param width + */ + public async processAndStoreVideo(data: Buffer, width: number = 720): Promise { + return new Promise(async (resolve) => { + const fileBasename = UploadManager.getCrypticFileName() + ".mp4"; + await fsx.ensureDir(this.dataDir); + const filePath = path.join(this.dataDir, fileBasename); + const videoFileStream = new ReadableStreamBuffer({ + chunkSize: 2048, + frequency: 10, + }); + videoFileStream.put(data); + const video = ffmpeg(videoFileStream); + video + .on("end", () => { + resolve(`/${dataDirName}/${fileBasename}`); + }) + .size(`${width}x?`) + .toFormat("libx264") + .output(filePath); + }); + } + + /** + * Convers a readable to a buffer + * @param stream + */ + public async streamToBuffer(stream: Readable) { + const parts = await toArray(stream); + const buffers = parts + .map((part: any) => Buffer.isBuffer(part) ? part : Buffer.from(part)); + return Buffer.concat(buffers); + } +} diff --git a/src/lib/errors/InvalidFileError.ts b/src/lib/errors/InvalidFileError.ts new file mode 100644 index 0000000..15886fa --- /dev/null +++ b/src/lib/errors/InvalidFileError.ts @@ -0,0 +1,14 @@ +import * as httpStatus from "http-status"; +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a invalid file type is uploaded + */ +export class InvalidFileError extends BaseError { + + public readonly statusCode = httpStatus.NOT_ACCEPTABLE; + + constructor(mimetype: string) { + super(`The mimetype '${mimetype}' is not allowed.`); + } +} diff --git a/src/lib/errors/UploadFailedError.ts b/src/lib/errors/UploadFailedError.ts new file mode 100644 index 0000000..8109cea --- /dev/null +++ b/src/lib/errors/UploadFailedError.ts @@ -0,0 +1,10 @@ +import {BaseError} from "./BaseError"; + +/** + * An error that is thrown when a file failed to upload + */ +export class UploadFailedError extends BaseError { + constructor() { + super("Failed to upload the file"); + } +} diff --git a/src/lib/globals.ts b/src/lib/globals.ts index 8847300..ca97fff 100644 --- a/src/lib/globals.ts +++ b/src/lib/globals.ts @@ -1,5 +1,6 @@ import * as config from "config"; import {EventEmitter} from "events"; +import * as path from "path"; import * as winston from "winston"; require("winston-daily-rotate-file"); @@ -38,6 +39,18 @@ namespace globals { }), ], }); + + /** + * Returns the absolute public path + */ + export function getPublicDir(): string { + let publicPath = config.get("frontend.publicPath"); + if (!path.isAbsolute(publicPath)) { + publicPath = path.normalize(path.join(__dirname, "../", publicPath)); + } + return publicPath; + } + export const internalEmitter: EventEmitter = new EventEmitter(); } diff --git a/src/lib/models/Post.ts b/src/lib/models/Post.ts index fad5013..8d60709 100644 --- a/src/lib/models/Post.ts +++ b/src/lib/models/Post.ts @@ -33,6 +33,12 @@ export class Post extends Model { @Column({allowNull: true}) public activityId: number; + /** + * An url pointing to any media that belongs to the post + */ + @Column({allowNull: true, type: sqz.STRING(512)}) + public mediaUrl: string; + /** * The author of the post */ diff --git a/src/lib/regex.ts b/src/lib/regex.ts index edfc0dc..859e4f6 100644 --- a/src/lib/regex.ts +++ b/src/lib/regex.ts @@ -1,5 +1,7 @@ export namespace is { const emailRegex = /\S+?@\S+?(\.\S+?)?\.\w{2,3}(.\w{2-3})?/g; + const videoRegex = /video\/.*/g; + const imageRegex = /image\/.*/g; /** * Tests if a string is a valid email. @@ -8,4 +10,20 @@ export namespace is { export function email(testString: string) { return emailRegex.test(testString); } + + /** + * Returns if the mimetype is a video + * @param mimetype + */ + export function video(mimetype: string) { + return videoRegex.test(mimetype); + } + + /** + * Returns if the mimetype is an image + * @param mimetype + */ + export function image(mimetype: string) { + return imageRegex.test(mimetype); + } } diff --git a/src/routes/GraphqlRoute.ts b/src/routes/GraphqlRoute.ts new file mode 100644 index 0000000..ea68e64 --- /dev/null +++ b/src/routes/GraphqlRoute.ts @@ -0,0 +1,81 @@ +import * as config from "config"; +import {Router} from "express"; +import * as graphqlHTTP from "express-graphql"; +import {buildSchema, GraphQLError} from "graphql"; +import {importSchema} from "graphql-import"; +import queryComplexity, {directiveEstimator, simpleEstimator} from "graphql-query-complexity"; +import {graphqlUploadExpress} from "graphql-upload"; +import * as path from "path"; +import globals from "../lib/globals"; +import Route from "../lib/Route"; +import {resolver} from "./graphql/resolvers"; + +const logger = globals.logger; + +/** + * A class for the /grpahql route + */ +export class GraphqlRoute extends Route { + /** + * Constructor, creates new router. + */ + constructor() { + super(); + this.router = Router(); + } + + + /** + * Initializes the route + * @param params + */ + public async init(...params: any): Promise { + this.router.use(graphqlUploadExpress({ + maxFileSize: config.get("api.maxFileSize"), + maxFiles: 10, + })); + // @ts-ignore + this.router.use(graphqlHTTP(async (request: any, response: any, {variables}) => { + response.setHeader("X-Max-Query-Complexity", config.get("api.maxQueryComplexity")); + return { + // @ts-ignore all + context: {session: request.session}, + formatError: (err: GraphQLError | any) => { + if (err.statusCode) { + response.status(err.statusCode); + } else { + response.status(400); + } + logger.debug(err.message); + logger.silly(err.stack); + return err.graphqlError ?? err; + }, + graphiql: config.get("api.graphiql"), + rootValue: resolver(request, response), + schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))), + validationRules: [ + queryComplexity({ + estimators: [ + directiveEstimator(), + simpleEstimator(), + ], + maximumComplexity: config.get("api.maxQueryComplexity"), + onComplete: (complexity: number) => { + logger.debug(`QueryComplexity: ${complexity}`); + response.setHeader("X-Query-Complexity", complexity); + }, + variables, + }), + ], + }; + })); + } + + /** + * Destroys the route + * @param params + */ + public async destroy(...params: any): Promise { + return undefined; + } +} diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index 66d2c1c..1797dae 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -2,17 +2,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 fileUpload from "express-fileupload"; import * as ffmpeg from "fluent-ffmpeg"; import * as fsx from "fs-extra"; import * as status from "http-status"; import * as path from "path"; -import * as sharp from "sharp"; -import {ReadableStreamBuffer} from "stream-buffers"; import globals from "../lib/globals"; import {Group, User} from "../lib/models"; import Route from "../lib/Route"; +import {UploadManager} from "../lib/UploadManager"; const ffmpegPath = require("@ffmpeg-installer/ffmpeg").path; const dataDirName = "data"; @@ -54,12 +53,14 @@ export class UploadRoute extends Route { * The directory where the uploaded data will be saved in */ public readonly dataDir: string; + private uploadManager: UploadManager; constructor(private publicPath: string) { super(); this.router = Router(); this.dataDir = path.join(this.publicPath, dataDirName); ffmpeg.setFfmpegPath(ffmpegPath); + this.uploadManager = new UploadManager(); } /** @@ -118,9 +119,9 @@ export class UploadRoute extends Route { try { const user = await User.findByPk(request.session.userId); if (user) { - fileName = await this.processAndStoreImage(profilePic.data); + fileName = await this.uploadManager.processAndStoreImage(profilePic.data); if (user.profilePicture) { - await this.deleteWebFile(user.profilePicture); + await this.uploadManager.deleteWebFile(user.profilePicture); } user.profilePicture = fileName; await user.save(); @@ -162,9 +163,9 @@ export class UploadRoute extends Route { } const isAdmin = await group.$has("rAdmins", user); if (isAdmin) { - fileName = await this.processAndStoreImage(groupPicture.data); + fileName = await this.uploadManager.processAndStoreImage(groupPicture.data); if (group.picture) { - await this.deleteWebFile(group.picture); + await this.uploadManager.deleteWebFile(group.picture); } group.picture = fileName; await group.save(); @@ -186,76 +187,4 @@ export class UploadRoute extends Route { 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"); - await fsx.ensureDir(this.dataDir); - 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}`; - } - - /** - * Converts a video into a smaller format and .mp4 and returns the web path - * @param data - * @param width - */ - private async processAndStoreVideo(data: Buffer, width: number = 720): Promise { - return new Promise(async (resolve) => { - const fileBasename = UploadRoute.getFileName() + ".mp4"; - await fsx.ensureDir(this.dataDir); - const filePath = path.join(this.dataDir, fileBasename); - const videoFileStream = new ReadableStreamBuffer({ - chunkSize: 2048, - frequency: 10, - }); - videoFileStream.put(data); - const video = ffmpeg(videoFileStream); - video - .on("end", () => { - resolve(`/${dataDirName}/${fileBasename}`); - }) - .size(`${width}x?`) - .toFormat("libx264") - .output(filePath); - }); - } - - /** - * Deletes an image for a provided web path. - * @param webPath - */ - private async deleteWebFile(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/src/graphql/BaseResolver.ts b/src/routes/graphql/BaseResolver.ts similarity index 84% rename from src/graphql/BaseResolver.ts rename to src/routes/graphql/BaseResolver.ts index 753af99..2bbe4cf 100644 --- a/src/graphql/BaseResolver.ts +++ b/src/routes/graphql/BaseResolver.ts @@ -1,4 +1,4 @@ -import {NotLoggedInGqlError} from "../lib/errors/graphqlErrors"; +import {NotLoggedInGqlError} from "../../lib/errors/graphqlErrors"; /** * Base resolver class to provide common methods to all resolver classes diff --git a/src/graphql/BlacklistedResult.ts b/src/routes/graphql/BlacklistedResult.ts similarity index 100% rename from src/graphql/BlacklistedResult.ts rename to src/routes/graphql/BlacklistedResult.ts diff --git a/src/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts similarity index 88% rename from src/graphql/MutationResolver.ts rename to src/routes/graphql/MutationResolver.ts index 236f067..696c2a5 100644 --- a/src/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -1,17 +1,21 @@ import {GraphQLError} from "graphql"; +import {FileUpload} from "graphql-upload"; import * as yaml from "js-yaml"; import isEmail from "validator/lib/isEmail"; -import dataaccess from "../lib/dataAccess"; -import {BlacklistedError} from "../lib/errors/BlacklistedError"; -import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError"; -import {InvalidEmailError} from "../lib/errors/InvalidEmailError"; -import {NotAGroupAdminError} from "../lib/errors/NotAGroupAdminError"; -import {NotAnAdminError} from "../lib/errors/NotAnAdminError"; -import {NotTheGroupCreatorError} from "../lib/errors/NotTheGroupCreatorError"; -import {PostNotFoundError} from "../lib/errors/PostNotFoundError"; -import globals from "../lib/globals"; -import {InternalEvents} from "../lib/InternalEvents"; -import {Activity, BlacklistedPhrase, ChatMessage, ChatRoom, Event, Group, Post, Request, User} from "../lib/models"; +import dataaccess from "../../lib/dataAccess"; +import {BlacklistedError} from "../../lib/errors/BlacklistedError"; +import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError"; +import {InvalidEmailError} from "../../lib/errors/InvalidEmailError"; +import {InvalidFileError} from "../../lib/errors/InvalidFileError"; +import {NotAGroupAdminError} from "../../lib/errors/NotAGroupAdminError"; +import {NotAnAdminError} from "../../lib/errors/NotAnAdminError"; +import {NotTheGroupCreatorError} from "../../lib/errors/NotTheGroupCreatorError"; +import {PostNotFoundError} from "../../lib/errors/PostNotFoundError"; +import globals from "../../lib/globals"; +import {InternalEvents} from "../../lib/InternalEvents"; +import {Activity, BlacklistedPhrase, ChatMessage, ChatRoom, Event, Group, Post, Request, User} from "../../lib/models"; +import {is} from "../../lib/regex"; +import {UploadManager} from "../../lib/UploadManager"; import {BaseResolver} from "./BaseResolver"; const legit = require("legit"); @@ -21,6 +25,17 @@ const legit = require("legit"); */ export class MutationResolver extends BaseResolver { + /** + * An instance of the upload manager to handle uploads + */ + protected uploadManager: UploadManager; + + constructor() { + super(); + this.uploadManager = new UploadManager(); + } + + /** * Accepts the usage of cookies and stores the session * @param args @@ -130,13 +145,27 @@ export class MutationResolver extends BaseResolver { * @param activityId * @param request */ - public async createPost({content, activityId}: { content: string, activityId?: number }, request: any): - Promise { + public async createPost({content, activityId, file}: { content: string, activityId?: number, file: FileUpload }, + request: any): Promise { this.ensureLoggedIn(request); if (content.length > 2048) { throw new GraphQLError("Content too long."); } const post = await dataaccess.createPost(content, request.session.userId, activityId); + if (file) { + let fileUrl: string; + if (is.video(file.mimetype)) { + const fileBuffer = await this.uploadManager.streamToBuffer(file.createReadStream()); + fileUrl = await this.uploadManager.processAndStoreVideo(fileBuffer); + } else if (is.image(file.mimetype)) { + const fileBuffer = await this.uploadManager.streamToBuffer(file.createReadStream()); + fileUrl = await this.uploadManager.processAndStoreImage(fileBuffer); + } else { + throw new InvalidFileError(file.mimetype); + } + post.mediaUrl = fileUrl; + await post.save(); + } globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); return post; } diff --git a/src/graphql/QueryResolver.ts b/src/routes/graphql/QueryResolver.ts similarity index 92% rename from src/graphql/QueryResolver.ts rename to src/routes/graphql/QueryResolver.ts index a6cf288..263e360 100644 --- a/src/graphql/QueryResolver.ts +++ b/src/routes/graphql/QueryResolver.ts @@ -1,12 +1,12 @@ import {GraphQLError} from "graphql"; import {Op} from "sequelize"; -import dataaccess from "../lib/dataAccess"; -import {ChatNotFoundError} from "../lib/errors/ChatNotFoundError"; -import {PostNotFoundGqlError} from "../lib/errors/graphqlErrors"; -import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError"; -import {RequestNotFoundError} from "../lib/errors/RequestNotFoundError"; -import {UserNotFoundError} from "../lib/errors/UserNotFoundError"; -import {Activity, BlacklistedPhrase, ChatRoom, Event, Group, Post, Request, User} from "../lib/models"; +import dataaccess from "../../lib/dataAccess"; +import {ChatNotFoundError} from "../../lib/errors/ChatNotFoundError"; +import {PostNotFoundGqlError} from "../../lib/errors/graphqlErrors"; +import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError"; +import {RequestNotFoundError} from "../../lib/errors/RequestNotFoundError"; +import {UserNotFoundError} from "../../lib/errors/UserNotFoundError"; +import {Activity, BlacklistedPhrase, ChatRoom, Event, Group, Post, Request, User} from "../../lib/models"; import {BlacklistedResult} from "./BlacklistedResult"; import {MutationResolver} from "./MutationResolver"; import {SearchResult} from "./SearchResult"; diff --git a/src/graphql/SearchResult.ts b/src/routes/graphql/SearchResult.ts similarity index 81% rename from src/graphql/SearchResult.ts rename to src/routes/graphql/SearchResult.ts index bee76ef..4ca0631 100644 --- a/src/graphql/SearchResult.ts +++ b/src/routes/graphql/SearchResult.ts @@ -1,4 +1,4 @@ -import {Event, Group, Post, User} from "../lib/models"; +import {Event, Group, Post, User} from "../../lib/models"; /** * A class to wrap search results returned by the search resolver diff --git a/src/graphql/Token.ts b/src/routes/graphql/Token.ts similarity index 100% rename from src/graphql/Token.ts rename to src/routes/graphql/Token.ts diff --git a/src/graphql/resolvers.ts b/src/routes/graphql/resolvers.ts similarity index 100% rename from src/graphql/resolvers.ts rename to src/routes/graphql/resolvers.ts diff --git a/src/graphql/schema.graphql b/src/routes/graphql/schema.graphql similarity index 93% rename from src/graphql/schema.graphql rename to src/routes/graphql/schema.graphql index 3015271..f8239af 100644 --- a/src/graphql/schema.graphql +++ b/src/routes/graphql/schema.graphql @@ -85,7 +85,7 @@ type Mutation { sendMessage(chatId: ID!, content: String!): ChatMessage "create a post that can belong to an activity" - createPost(content: String!, activityId: ID): Post! + createPost(content: String!, activityId: ID, file: Upload): Post! "delete the post for a given post id" deletePost(postId: ID!): Boolean! @@ -340,6 +340,9 @@ type Post { "the activity that belongs to the post" activity: Activity + + "the uploaded file or video for the post" + media: Media } "represents a request of any type" @@ -508,8 +511,21 @@ type BlacklistedResult { phrases: [String!]! } +"a type of uploaded media" +type Media { + + "the url pointing to the media in the data folder" + url: String! + "the type of media that is uploaded" + type: MediaType +} +"represents the type of media" +enum MediaType { + VIDEO + IMAGE +} "represents the type of vote performed on a post" enum VoteType { diff --git a/yarn.lock b/yarn.lock index 3f6bb53..23f601c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -534,7 +534,7 @@ ansi-wrap@0.1.0, ansi-wrap@^0.1.0: resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" integrity sha1-qCJQ3bABXponyoLoLqYDu/pF768= -any-promise@^1.3.0: +any-promise@^1.1.0, any-promise@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= @@ -4655,6 +4655,11 @@ semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.1.tgz#29104598a197d6cbe4733eeecbe968f7b43a9667" + integrity sha512-WfuG+fl6eh3eZ2qAf6goB7nhiCd7NPXhmyFxigB/TOkQyeLP8w8GsVehvtGNtnNmyboz4TgeK40B1Kbql/8c5A== + send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -4737,17 +4742,17 @@ setprototypeof@1.1.1: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== -sharp@^0.23.4: - version "0.23.4" - resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.23.4.tgz#ca36067cb6ff7067fa6c77b01651cb9a890f8eb3" - integrity sha512-fJMagt6cT0UDy9XCsgyLi0eiwWWhQRxbwGmqQT6sY8Av4s0SVsT/deg8fobBQCTDU5iXRgz0rAeXoE2LBZ8g+Q== +sharp@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.24.0.tgz#1200f4bb36ccc2bb36a78f0bcba0302cf1f7a5fd" + integrity sha512-kUtQE6+HJnNqO0H6ueOBtRXahktuqydIBaFMvhDelf/KaK9j/adEdjf4Y3+bbjYOa5i6hi2EAa2Y2G9umP4s2g== dependencies: color "^3.1.2" detect-libc "^1.0.3" nan "^2.14.0" npmlog "^4.1.2" prebuild-install "^5.3.3" - semver "^6.3.0" + semver "^7.1.1" simple-get "^3.1.0" tar "^5.0.5" tunnel-agent "^0.6.0" @@ -5025,6 +5030,13 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI= +stream-to-array@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/stream-to-array/-/stream-to-array-2.3.0.tgz#bbf6b39f5f43ec30bc71babcb37557acecf34353" + integrity sha1-u/azn19D7DC8cbq8s3VXrOzzQ1M= + dependencies: + any-promise "^1.1.0" + streamsearch@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" From 269b892cc465b479c3d4e83f5eb7a3456ebda001 Mon Sep 17 00:00:00 2001 From: Trivernis Date: Thu, 23 Jan 2020 09:10:43 +0100 Subject: [PATCH 4/6] Fix packages --- package.json | 2 +- yarn.lock | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index d4be8d4..7c101c0 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "reflect-metadata": "^0.1.13", "sequelize": "^5.19.6", "sequelize-typescript": "^1.0.0", - "sharp": "^0.24.0", + "sharp": "^0.23.4", "socket.io": "^2.2.0", "socket.io-redis": "^5.2.0", "sqlite3": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index 23f601c..5be4d88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4655,11 +4655,6 @@ semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.1.tgz#29104598a197d6cbe4733eeecbe968f7b43a9667" - integrity sha512-WfuG+fl6eh3eZ2qAf6goB7nhiCd7NPXhmyFxigB/TOkQyeLP8w8GsVehvtGNtnNmyboz4TgeK40B1Kbql/8c5A== - send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -4742,17 +4737,17 @@ setprototypeof@1.1.1: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== -sharp@^0.24.0: - version "0.24.0" - resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.24.0.tgz#1200f4bb36ccc2bb36a78f0bcba0302cf1f7a5fd" - integrity sha512-kUtQE6+HJnNqO0H6ueOBtRXahktuqydIBaFMvhDelf/KaK9j/adEdjf4Y3+bbjYOa5i6hi2EAa2Y2G9umP4s2g== +sharp@^0.23.4: + version "0.23.4" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.23.4.tgz#ca36067cb6ff7067fa6c77b01651cb9a890f8eb3" + integrity sha512-fJMagt6cT0UDy9XCsgyLi0eiwWWhQRxbwGmqQT6sY8Av4s0SVsT/deg8fobBQCTDU5iXRgz0rAeXoE2LBZ8g+Q== dependencies: color "^3.1.2" detect-libc "^1.0.3" nan "^2.14.0" npmlog "^4.1.2" prebuild-install "^5.3.3" - semver "^7.1.1" + semver "^6.3.0" simple-get "^3.1.0" tar "^5.0.5" tunnel-agent "^0.6.0" From ebeaf3f5494360852dfe16dae81e0f926e08efb4 Mon Sep 17 00:00:00 2001 From: trivernis Date: Thu, 23 Jan 2020 15:41:06 +0100 Subject: [PATCH 5/6] Change video format to webm - Change default allowed filesize to 10mb - Change video format for posts to webm --- config/default.toml | 2 +- src/lib/UploadManager.ts | 43 ++++++++++++--------- src/lib/models/Post.ts | 16 ++++++++ src/routes/UploadRoute.ts | 52 ++++++++++++++++++++++++-- src/routes/graphql/MutationResolver.ts | 16 +------- src/routes/graphql/schema.graphql | 2 +- 6 files changed, 94 insertions(+), 37 deletions(-) diff --git a/config/default.toml b/config/default.toml index 14e8a6a..d66d813 100644 --- a/config/default.toml +++ b/config/default.toml @@ -64,7 +64,7 @@ maxQueryComplexity = 5000 imageFormat = "png" # the max file size for uploading in bytes -maxFileSize = 5_242_880 +maxFileSize = 10_485_760 # Configuration for the api rate limit [api.rateLimit] diff --git a/src/lib/UploadManager.ts b/src/lib/UploadManager.ts index a12d68c..9b7280c 100644 --- a/src/lib/UploadManager.ts +++ b/src/lib/UploadManager.ts @@ -10,6 +10,7 @@ import globals from "./globals"; const toArray = require("stream-to-array"); +const ffmpegPath = require("@ffmpeg-installer/ffmpeg").path; const dataDirName = "data"; interface IUploadConfirmation { @@ -49,6 +50,7 @@ export class UploadManager { constructor() { this.dataDir = path.join(globals.getPublicDir(), dataDirName); + ffmpeg.setFfmpegPath(ffmpegPath); } /** @@ -79,6 +81,7 @@ export class UploadManager { const filePath = path.join(this.dataDir, fileBasename); let image = sharp(data) .resize(width, height, { + background: "#00000000", fit, }) .normalise(); @@ -103,23 +106,29 @@ export class UploadManager { * @param width */ public async processAndStoreVideo(data: Buffer, width: number = 720): Promise { - return new Promise(async (resolve) => { - const fileBasename = UploadManager.getCrypticFileName() + ".mp4"; - await fsx.ensureDir(this.dataDir); - const filePath = path.join(this.dataDir, fileBasename); - const videoFileStream = new ReadableStreamBuffer({ - chunkSize: 2048, - frequency: 10, - }); - videoFileStream.put(data); - const video = ffmpeg(videoFileStream); - video - .on("end", () => { - resolve(`/${dataDirName}/${fileBasename}`); - }) - .size(`${width}x?`) - .toFormat("libx264") - .output(filePath); + return new Promise(async (resolve, reject) => { + try { + const fileBasename = UploadManager.getCrypticFileName() + ".webm"; + await fsx.ensureDir(this.dataDir); + const filePath = path.join(this.dataDir, fileBasename); + const tempFile = filePath + ".tmp"; + await fsx.writeFile(tempFile, data); + const video = ffmpeg(tempFile); + video + .size(`${width}x?`) + .toFormat("webm") + .on("end", async () => { + await fsx.unlink(tempFile); + resolve(`/${dataDirName}/${fileBasename}`); + }) + .on("error", async (err) => { + await fsx.unlink(tempFile); + reject(err); + }) + .save(filePath); + } catch (err) { + reject(err); + } }); } diff --git a/src/lib/models/Post.ts b/src/lib/models/Post.ts index 8d60709..621f35c 100644 --- a/src/lib/models/Post.ts +++ b/src/lib/models/Post.ts @@ -106,6 +106,22 @@ export class Post extends Model { return (await this.votes()).filter((v) => v.PostVote.voteType === VoteType.DOWNVOTE).length; } + /** + * Returns the media description object of the post + */ + public get media() { + const url = this.getDataValue("mediaUrl"); + if (url) { + const type = url.endsWith(".webm") ? "VIDEO" : "IMAGE"; + return { + type, + url, + }; + } else { + return null; + } + } + /** * Toggles the vote of the user. * @param userId diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index 1797dae..ff0911f 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -9,11 +9,11 @@ import * as fsx from "fs-extra"; import * as status from "http-status"; import * as path from "path"; import globals from "../lib/globals"; -import {Group, User} from "../lib/models"; +import {Group, Post, User} from "../lib/models"; +import {is} from "../lib/regex"; import Route from "../lib/Route"; import {UploadManager} from "../lib/UploadManager"; -const ffmpegPath = require("@ffmpeg-installer/ffmpeg").path; const dataDirName = "data"; interface IUploadConfirmation { @@ -59,7 +59,6 @@ export class UploadRoute extends Route { super(); this.router = Router(); this.dataDir = path.join(this.publicPath, dataDirName); - ffmpeg.setFfmpegPath(ffmpegPath); this.uploadManager = new UploadManager(); } @@ -80,6 +79,8 @@ export class UploadRoute extends Route { uploadConfirmation = await this.uploadProfilePicture(req); } else if (req.files.groupPicture) { uploadConfirmation = await this.uploadGroupPicture(req); + } else if (req.files.postMedia) { + uploadConfirmation = await this.uploadPostMedia(req); } else { res.status(status.BAD_REQUEST); uploadConfirmation = { @@ -187,4 +188,49 @@ export class UploadRoute extends Route { success, }; } + + /** + * Uploads a media file for a post + * @param request + */ + private async uploadPostMedia(request: any) { + let error: string; + let fileName: string; + let success = false; + const postId = request.body.postId; + const postMedia = request.files.postMedia as UploadedFile; + if (postId) { + try { + const post = await Post.findByPk(postId); + if (post.authorId === request.session.userId) { + if (is.image(postMedia.mimetype)) { + fileName = await this.uploadManager.processAndStoreImage(postMedia.data, 1080, 720, "contain"); + } else if (is.video(postMedia.mimetype)) { + fileName = await this.uploadManager.processAndStoreVideo(postMedia.data, 1080); + } else { + error = "Wrong type of file provided"; + } + if (fileName) { + post.mediaUrl = fileName; + await post.save(); + success = true; + } + } else { + error = "You are not the author of the post"; + } + } catch (err) { + error = err.message; + globals.logger.error(err.message); + globals.logger.debug(err.stack); + } + } else { + error = "No post Id provided"; + } + + return { + error, + fileName, + success, + }; + } } diff --git a/src/routes/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts index 696c2a5..edf8f60 100644 --- a/src/routes/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -145,27 +145,13 @@ export class MutationResolver extends BaseResolver { * @param activityId * @param request */ - public async createPost({content, activityId, file}: { content: string, activityId?: number, file: FileUpload }, + public async createPost({content, activityId}: { content: string, activityId?: number}, request: any): Promise { this.ensureLoggedIn(request); if (content.length > 2048) { throw new GraphQLError("Content too long."); } const post = await dataaccess.createPost(content, request.session.userId, activityId); - if (file) { - let fileUrl: string; - if (is.video(file.mimetype)) { - const fileBuffer = await this.uploadManager.streamToBuffer(file.createReadStream()); - fileUrl = await this.uploadManager.processAndStoreVideo(fileBuffer); - } else if (is.image(file.mimetype)) { - const fileBuffer = await this.uploadManager.streamToBuffer(file.createReadStream()); - fileUrl = await this.uploadManager.processAndStoreImage(fileBuffer); - } else { - throw new InvalidFileError(file.mimetype); - } - post.mediaUrl = fileUrl; - await post.save(); - } globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); return post; } diff --git a/src/routes/graphql/schema.graphql b/src/routes/graphql/schema.graphql index f8239af..41b009a 100644 --- a/src/routes/graphql/schema.graphql +++ b/src/routes/graphql/schema.graphql @@ -85,7 +85,7 @@ type Mutation { sendMessage(chatId: ID!, content: String!): ChatMessage "create a post that can belong to an activity" - createPost(content: String!, activityId: ID, file: Upload): Post! + createPost(content: String!, activityId: ID): Post! "delete the post for a given post id" deletePost(postId: ID!): Boolean! From 537ec07f2937346c8ccf6d853fc96c22d6ca3db1 Mon Sep 17 00:00:00 2001 From: trivernis Date: Thu, 23 Jan 2020 15:43:13 +0100 Subject: [PATCH 6/6] Add deletion of media to deletePost --- src/routes/graphql/MutationResolver.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/routes/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts index edf8f60..ac30772 100644 --- a/src/routes/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -171,6 +171,14 @@ export class MutationResolver extends BaseResolver { }); const isAdmin = (await User.findOne({where: {id: request.session.userId}})).isAdmin; if (post.rAuthor.id === request.session.userId || isAdmin) { + if (post.mediaUrl) { + try { + await this.uploadManager.deleteWebFile(post.mediaUrl); + } catch (err) { + globals.logger.error(err.message); + globals.logger.debug(err.stack); + } + } return await dataaccess.deletePost(post.id); } else { throw new GraphQLError("User is not author of the post.");