diff --git a/.gitignore b/.gitignore index e2a46779..4c312bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ /**/**/package-lock.json /**/**/yarn.lock /**/**/.evite +/**/**/uploads /**/**/d_data # Logs diff --git a/packages/server/package.json b/packages/server/package.json index f218ca4c..86beddac 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,16 +14,18 @@ "connect-mongo": "4.6.0", "corenode": "0.28.26", "dicebar_lib": "1.0.1", + "dotenv": "16.0.1", + "fluent-ffmpeg": "^2.1.2", + "formidable": "^2.0.1", "jsonwebtoken": "8.5.1", - "linebridge": "0.11.13", + "linebridge": "0.11.14", "moment": "2.29.1", "mongoose": "6.1.9", "nanoid": "3.2.0", "passport": "0.5.2", "passport-jwt": "4.0.0", "passport-local": "1.0.0", - "path-to-regexp": "6.2.0", - "dotenv": "16.0.1" + "path-to-regexp": "6.2.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/packages/server/src/controllers/FilesController/index.js b/packages/server/src/controllers/FilesController/index.js index b0d1c322..8e564866 100644 --- a/packages/server/src/controllers/FilesController/index.js +++ b/packages/server/src/controllers/FilesController/index.js @@ -2,17 +2,68 @@ import { Controller } from "linebridge/dist/server" import path from "path" import fs from "fs" import stream from "stream" +const formidable = require("formidable") -function resolveToUrl(filepath) { - return `${global.globalPublicURI}/uploads/${filepath}` +function resolveToUrl(filepath, req) { + const host = req ? (req.protocol + "://" + req.get("host")) : global.globalPublicURI + + return `${host}/upload/${filepath}` +} + +// TODO: Get maximunFileSize by type of user subscription (free, premium, etc) when `PermissionsAPI` is ready +const maximumFileSize = 80 * 1024 * 1024 // max file size in bytes (80MB) By default, the maximum file size is 80MB. +const maximunFilesPerRequest = 10 +const acceptedMimeTypes = [ + "image/jpeg", + "image/png", + "image/gif", + "video/mp4", + "video/webm", + "video/quicktime", + "video/x-msvideo", + "video/x-ms-wmv", +] + +function videoTranscode(originalFilePath, outputPath, options = {}) { + return new Promise((resolve, reject) => { + const ffmpeg = require("fluent-ffmpeg") + + const filename = path.basename(originalFilePath) + const outputFilepath = `${outputPath}/${filename.split(".")[0]}.${options.format ?? "webm"}` + + console.debug(`[TRANSCODING] Transcoding ${originalFilePath} to ${outputFilepath}`) + + const onEnd = async () => { + // remove + await fs.promises.unlink(originalFilePath) + + console.debug(`[TRANSCODING] Transcoding ${originalFilePath} to ${outputFilepath} finished`) + + return resolve(outputFilepath) + } + + const onError = (err) => { + console.error(`[TRANSCODING] Transcoding ${originalFilePath} to ${outputFilepath} failed`, err) + + return reject(err) + } + + ffmpeg(originalFilePath) + .audioBitrate(options.audioBitrate ?? 128) + .videoBitrate(options.videoBitrate ?? 1024) + .videoCodec(options.videoCodec ?? "libvpx") + .audioCodec(options.audioCodec ?? "libvorbis") + .format(options.format ?? "webm") + .output(outputFilepath) + .on("error", onError) + .on("end", onEnd) + .run() + }) } export default class FilesController extends Controller { - static disabled = true - get = { - "/uploads/:id": { - enabled: false, + "/upload/:id": { fn: (req, res) => { const filePath = path.join(global.uploadPath, req.params?.id) @@ -32,37 +83,86 @@ export default class FilesController extends Controller { post = { "/upload": { - enabled: false, - middlewares: ["withAuthentication", "fileUpload"], + middlewares: ["withAuthentication"], fn: async (req, res) => { - const urls = [] - const failed = [] + // check directories exist + if (!fs.existsSync(global.uploadCachePath)) { + await fs.promises.mkdir(global.uploadCachePath, { recursive: true }) + } if (!fs.existsSync(global.uploadPath)) { await fs.promises.mkdir(global.uploadPath, { recursive: true }) } - if (req.files) { - for await (let file of req.files) { - try { - const filename = `${req.decodedToken.user_id}-${new Date().getTime()}-${file.filename}` + // decode body form-data + const form = formidable({ + multiples: true, + keepExtensions: true, + uploadDir: global.uploadCachePath, + maxFileSize: maximumFileSize, + maxFields: maximunFilesPerRequest, + filter: (stream) => { + // check if is allowed mime type + if (!acceptedMimeTypes.includes(stream.mimetype)) { + failed.push({ + fileName: file.originalFilename, + mimetype: file.mimetype, + error: "mimetype not allowed", + }) - const diskPath = path.join(global.uploadPath, filename) - - await fs.promises.writeFile(diskPath, file.data) - - urls.push(resolveToUrl(filename)) - } catch (error) { - console.log(error) - failed.push(file.filename) + return false } - } - } - return res.json({ - urls: urls, - failed: failed, + return true + } }) + + const results = await new Promise((resolve, reject) => { + const processedFiles = [] + + form.parse(req, async (err, fields, data) => { + if (err) { + return reject(err) + } + + for await (let file of data.files) { + // check if is video need to transcode + switch (file.mimetype) { + case "video/quicktime": { + file.filepath = await videoTranscode(file.filepath, global.uploadCachePath) + file.newFilename = path.basename(file.filepath) + break + } + + default: { + // do nothing + } + } + + // move file to upload path + await fs.promises.rename(file.filepath, path.join(global.uploadPath, file.newFilename)) + + // push final filepath to urls + processedFiles.push({ + name: file.originalFilename, + id: file.newFilename, + url: resolveToUrl(file.newFilename, req), + }) + } + + return resolve(processedFiles) + }) + }).catch((err) => { + res.status(400).json({ + error: err.message, + }) + + return false + }) + + if (results) { + return res.json(results) + } } } } diff --git a/packages/server/src/index.js b/packages/server/src/index.js index 213e0503..d5512215 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -22,6 +22,7 @@ Array.prototype.updateFromObjectKeys = function (obj) { import path from "path" import { Server as LinebridgeServer } from "linebridge/dist/server" +import express from "express" import bcrypt from "bcrypt" import passport from "passport" @@ -45,6 +46,7 @@ class Server { middlewares = require("./middlewares") httpInstance = new LinebridgeServer({ + httpEngine: "express", port: this.httpListenPort, wsPort: this.wsListenPort, headers: { @@ -65,6 +67,9 @@ class Server { } constructor() { + this.httpInstance.httpInterface.use(express.json()) + this.httpInstance.httpInterface.use(express.urlencoded({ extended: true })) + this.httpInstance.wsInterface["clients"] = [] this.httpInstance.wsInterface["findUserIdFromClientID"] = (searchClientId) => { return this.httpInstance.wsInterface.clients.find(client => client.id === searchClientId)?.userId ?? false @@ -83,7 +88,10 @@ class Server { global.wsInterface = this.httpInstance.wsInterface global.httpListenPort = this.listenPort global.globalPublicURI = this.env.globalPublicURI + global.uploadPath = this.env.uploadPath ?? path.resolve(process.cwd(), "uploads") + global.uploadCachePath = this.env.uploadCachePath ?? path.resolve(process.cwd(), "cache") + global.jwtStrategy = this.options.jwtStrategy global.signLocation = this.env.signLocation @@ -155,10 +163,6 @@ class Server { req.jwtStrategy = this.options.jwtStrategy next() } - this.httpInstance.middlewares["useWS"] = (req, res, next) => { - req.ws = global.wsInterface - next() - } passport.use(new LocalStrategy({ usernameField: "username", @@ -183,6 +187,11 @@ class Server { } initWebsockets() { + this.httpInstance.middlewares["useWS"] = (req, res, next) => { + req.ws = global.wsInterface + next() + } + const onAuthenticated = (socket, user_id) => { this.attachClientSocket(socket, user_id) socket.emit("authenticated")