├── .dockerignore ├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── config └── config.json ├── docker-compose.yml ├── docker ├── Dockerfile └── vue-cloudfront-api.sh ├── docs └── config.md ├── ecosystem.json ├── package-lock.json ├── package.json ├── scripts ├── start.js └── wipe-sessions.js └── src ├── api ├── api.js ├── endpoints │ ├── auth │ │ ├── login.js │ │ └── register.js │ ├── data │ │ ├── download.js │ │ ├── static.js │ │ └── upload │ │ │ ├── WritableVoidStream.js │ │ │ ├── middleware.js │ │ │ ├── storageEngine.js │ │ │ └── upload.js │ ├── nodes │ │ ├── addMark.js │ │ ├── addStaticId.js │ │ ├── changeColor.js │ │ ├── copy.js │ │ ├── createFolder.js │ │ ├── createFolders.js │ │ ├── delete.js │ │ ├── move.js │ │ ├── moveToBin.js │ │ ├── removeMark.js │ │ ├── removeStaticIds.js │ │ ├── rename.js │ │ ├── restoreFromBin.js │ │ ├── update.js │ │ └── zip.js │ ├── settings │ │ ├── settings.js │ │ └── updateSettings.js │ └── user │ │ ├── checkApiKey.js │ │ ├── deleteAccount.js │ │ ├── logout.js │ │ ├── logoutEverywhere.js │ │ ├── status.js │ │ └── updateCredentials.js ├── middleware │ ├── auth.js │ └── json.js └── tools │ ├── authViaApiKey.js │ ├── createZipStream.js │ ├── traverseNodes.js │ └── usedSpaceBy.js ├── app.js ├── db.js ├── models ├── node.js └── user.js ├── utils.js └── websocket.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "globals": { 7 | "_config": true 8 | }, 9 | "extends": [ 10 | "eslint:recommended" 11 | ], 12 | "parserOptions": { 13 | "ecmaVersion": 2018, 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-cond-assign": "off", 18 | "no-unused-vars": "warn", 19 | "no-console": "warn" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | /docker/* text eol=lf 3 | /ci/* text eol=lf 4 | /sbt text eol=lf 5 | /docker-*.sh text eol=lf 6 | /sbt-dist/bin/*.bash text eol=lf 7 | /sbt-dist/bin/sbt text eol=lf 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | yarn-error.log 6 | 7 | # Editor directories and files 8 | .idea 9 | *.suo 10 | *.ntvs* 11 | *.njsproj 12 | *.sln 13 | 14 | # Other stuff 15 | _psd/ 16 | _storage/ 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Simon Reinisch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

RESTful API for vue-cloudfront-api

3 |

4 | 5 | This is the official API for [vue-cloudfront](https://github.com/ovanta/vue-cloudfront). 6 | For installation and developing take a look at the [vue-cloudfront readme](https://github.com/ovanta/vue-cloudfront/blob/master/README.md). 7 | Roadmap is available [here](https://github.com/ovanta/vue-cloudfront/projects). 8 | 9 | For basic server configurations see [config.json](https://github.com/ovanta/vue-cloudfront-api/blob/master/config/config.json). 10 | -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "demo": false, 3 | "demoContent": [ 4 | { 5 | "regex": "\\.(gif|jpe?g|tiff|png)$", 6 | "url": "https://raw.githubusercontent.com/9530f/stuff/master/android-chrome-512x512.png" 7 | }, 8 | { 9 | "regex": "\\.(mp3|adts|ogg|webp|flac)$", 10 | "url": "https://github.com/9530f/stuff/blob/master/music_for_programming_1-datassette.mp3?raw=true" 11 | }, 12 | { 13 | "regex": "\\.(mp4|wav|webm)$", 14 | "url": "https://github.com/9530f/stuff/blob/master/Spinning%20Levers%201936%20-%20How%20A%20Transmission%20Works.mp4?raw=true" 15 | }, 16 | { 17 | "regex": "\\.(ttf|otf|woff|woff2|eot)$", 18 | "url": "https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2" 19 | } 20 | ], 21 | "server": { 22 | "port": 8080, 23 | "storagePath": "./_storage", 24 | "uploadSizeLimitPerFile": 5000000000, 25 | "totalStorageLimitPerUser": 1000000000, 26 | "mediaStreamChunckSize": 4096000 27 | }, 28 | "mongodb": { 29 | "databaseName": "vue-cloudfront", 30 | "defaultUIDLength": 8, 31 | "apikeyLength": 32, 32 | "staticLinkUIDLength": 5, 33 | "exposedProps": { 34 | "dirNode": [ 35 | "id", 36 | "parent", 37 | "lastModified", 38 | "type", 39 | "name", 40 | "marked", 41 | "bin", 42 | "color", 43 | "staticIds" 44 | ], 45 | "fileNode": [ 46 | "id", 47 | "parent", 48 | "lastModified", 49 | "type", 50 | "name", 51 | "marked", 52 | "size", 53 | "bin", 54 | "staticIds" 55 | ] 56 | } 57 | }, 58 | "auth": { 59 | "apikeyExpiry": 12960000000, 60 | "saltRounds": 10, 61 | "maxLoginAttempts": 3, 62 | "blockedLoginDuration": 30000, 63 | "disableRegistration": false, 64 | "defaultSettings": { 65 | "static": { 66 | "introBoxes": ["0", "1", "2"] 67 | }, 68 | "user": { 69 | "siPrefix": true, 70 | "hideTooltips": false, 71 | "immediateDeletion": false, 72 | "usePreferredColorScheme": false, 73 | "defaultFolderColor": "#333333", 74 | "theme": "light" 75 | } 76 | } 77 | }, 78 | "validation": { 79 | "username": "^[\\w\\d]{2,15}$", 80 | "password": "^(.){4,20}$", 81 | "dirname": "^(.){1,100}$", 82 | "hexcolor": "^#([\\dA-Fa-f]{6})$", 83 | "schemes": { 84 | "foldertree": { 85 | "type": "array", 86 | "items": { 87 | "type": "object", 88 | "additionalProperties": false, 89 | "required": ["parent", "id", "name"], 90 | "properties": { 91 | "parent": { 92 | "type": "number", 93 | "minimum": -1 94 | }, 95 | "id": { 96 | "type": "number", 97 | "minimum": -1 98 | }, 99 | "name": { 100 | "type": "string" 101 | } 102 | } 103 | } 104 | }, 105 | "settings": { 106 | "type": "object", 107 | "additionalProperties": false, 108 | "required": ["static", "user"], 109 | "properties": { 110 | "static": { 111 | "type": "object", 112 | "additionalProperties": false, 113 | "required": ["introBoxes"], 114 | "properties": { 115 | "introBoxes": { 116 | "type": "array", 117 | "minItems": 0, 118 | "maxItems": 3, 119 | "items": { 120 | "type": "string" 121 | } 122 | } 123 | } 124 | }, 125 | "user": { 126 | "type": "object", 127 | "additionalProperties": false, 128 | "required": ["siPrefix", "hideTooltips", "immediateDeletion", "usePreferredColorScheme", "theme", "defaultFolderColor"], 129 | "properties": { 130 | "siPrefix": { 131 | "type": "boolean" 132 | }, 133 | "hideTooltips": { 134 | "type": "boolean" 135 | }, 136 | "immediateDeletion": { 137 | "type": "boolean" 138 | }, 139 | "usePreferredColorScheme": { 140 | "type": "boolean" 141 | }, 142 | "theme": { 143 | "type": "string", 144 | "enum": ["light", "dark"] 145 | }, 146 | "defaultFolderColor": { 147 | "type": "string", 148 | "pattern": "^#([a-fA-F\\d]){6}$" 149 | } 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | container_name: vue-cloudfront-app 5 | restart: always 6 | build: 7 | context: . 8 | dockerfile: docker/Dockerfile 9 | ports: 10 | - "8080:8080" 11 | volumes: 12 | - vcf-storage:/app/_storage 13 | depends_on: 14 | - mongo 15 | - redis 16 | mongo: 17 | container_name: mongo 18 | image: mongo 19 | ports: 20 | - "27017:27017" 21 | volumes: 22 | - vcf-mongodb:/data/db 23 | redis: 24 | container_name: redis 25 | image: redis 26 | ports: 27 | - "6379:6379" 28 | volumes: 29 | vcf-mongodb: 30 | vcf-storage: 31 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use latest node version 2 | FROM node:latest 3 | MAINTAINER Simon Reinisch 4 | 5 | # Use app directory as working dir 6 | WORKDIR /app 7 | 8 | # Upgrade 9 | RUN apt-get update && \ 10 | apt-get upgrade -y 11 | 12 | # Copy content into the container at /app 13 | COPY . /app 14 | 15 | # Expost port 8080 16 | EXPOSE 8080 17 | 18 | # Run setup script 19 | CMD 'docker/vue-cloudfront-api.sh' 20 | -------------------------------------------------------------------------------- /docker/vue-cloudfront-api.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Install and start server 5 | npm i 6 | npm run start 7 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuration file 2 | Below is a description of each relevant property in [config.json](https://github.com/ovanta/vue-cloudfront-api/blob/master/config/config.json) which 3 | is used to define general behaviour of the official vue-cloudfront api. 4 | 5 | **`demo`** 6 | If true all files will be saved, but no bytes written into it. Used to show a demo without consuming storage space. 7 | See `demoContent` to configure which content should be send as placeholder. 8 | 9 | **`server.port`** 10 | Port where vue-cloudfront-api will listen for requests. 11 | 12 | **`server.mongoDBDatabaseName`** 13 | MongoDB Database name. 14 | 15 | **`server.storagePath`** 16 | Path where files will be stored. 17 | 18 | **`server.uploadSizeLimitPerFile`** 19 | Maximum size of a single file in an upload request. 20 | 21 | **`server.mediaStreamChunckSize`** 22 | Chunk-size for audio / video streams. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests and 23 | https://en.wikipedia.org/wiki/Chunked_transfer_encoding for more informations. 24 | 25 | **`server.totalStorageLimitPerUser`** 26 | Storage limit for each user. `-1` (actual any value below zero) means unlimited storage quota. 27 | 28 | **`server.defaultUIDLength`** 29 | Every node has its own unique id. If not specially defined this defines the default length. 30 | It's better to choose a long UID's to reduce the chance of a collision. 31 | 32 | **`server.staticLinkUIDLength`** 33 | UID Length for static links which serve as public download links. 34 | 35 | **`server.defaultFolderColor`** 36 | Default folder color (for new folders). 37 | 38 | **`auth.maxLoginAttempts`** 39 | How many attempts should the user have to login. 40 | 41 | **`auth.blockedLoginDuration`** 42 | How long the user need to wait (in milliseconds) until he can try again to login. 43 | 44 | **`auth.disableRegistration`** 45 | If registration should be disabled. Useful if further registrations should be obviated. 46 | -------------------------------------------------------------------------------- /ecosystem.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "vue-cloudfront-server", 5 | "max_memory_restart": "1G", 6 | "script": "./src/app.js", 7 | "instances" : "max", 8 | "exec_mode" : "cluster", 9 | "log_date_format": "YYYY-MM-DD HH:mm:ss", 10 | "env": { 11 | "NODE_ENV": "production" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-cloudfront-api", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Official RESTful api for vue-cloudfront", 6 | "author": "Simon Reinisch", 7 | "license": "MIT", 8 | "keywords": [], 9 | "main": "/src/app.js", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/ovanta/vue-cloudfront-api" 13 | }, 14 | "scripts": { 15 | "dev": "nodemon ./src/app.js --ignore '/_storage' --watch src", 16 | "debug": "node %NODE_DEBUG_OPTION% ./src/app.js", 17 | "build:docker": "docker build -f docker/Dockerfile . -t vue-cloudfront-rest-api", 18 | "update:geoip-lite": "cd node_modules/geoip-lite && npm run-script updatedb", 19 | "wipe:sessions": "node ./scripts/wipe-sessions.js", 20 | "start:ws": "node ./src/app.js", 21 | "start": "node ./scripts/start.js", 22 | "stop": "pm2 delete vue-cloudfront-server", 23 | "lint": "eslint ./src" 24 | }, 25 | "dependencies": { 26 | "bcrypt": "^3.0.6", 27 | "body-parser": "^1.19.0", 28 | "cors": "^2.8.5", 29 | "express": "^4.17.1", 30 | "geoip-lite": "^1.3.7", 31 | "graceful-fs": "^4.2.0", 32 | "i18n-iso-countries": "^4.2.0", 33 | "ajv": "^6.10.2", 34 | "jszip": "^3.2.2", 35 | "mongodb": "^3.2.7", 36 | "mongoose": "^5.7.5", 37 | "multer": "^1.4.2", 38 | "pm2": "^3.5.1", 39 | "redis": "^2.8.0", 40 | "ua-parser-js": "^0.7.20", 41 | "ws": "^7.1.0" 42 | }, 43 | "devDependencies": { 44 | "eslint": "^6.0.1", 45 | "nodemon": "^1.19.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | const pm2 = require('pm2'); 2 | 3 | const promisify = async (method, ...args) => new Promise((resolve, reject) => { 4 | pm2[method](...args, (err, res) => err ? reject(err) : resolve(res)); 5 | }); 6 | 7 | (async () => { 8 | 9 | // Launch pm2 10 | await promisify('start', './ecosystem.json'); 11 | const bus = await promisify('launchBus'); 12 | 13 | // Create message broadcaster 14 | bus.on('process:broadcast', async packet => { 15 | const list = await promisify('list'); 16 | const {process: {pm_id}, data} = packet; 17 | 18 | for (const {pm2_env} of list) { 19 | const id = pm2_env.pm_id; 20 | if (id !== pm_id) { 21 | promisify('sendDataToProcessId', { 22 | data: { 23 | type: 'broadcast', 24 | data 25 | }, id, 26 | type: 'process:msg', 27 | topic: 'broadcast' 28 | }).catch(console.warn); // eslint-disable no-console 29 | } 30 | } 31 | }); 32 | })(); 33 | -------------------------------------------------------------------------------- /scripts/wipe-sessions.js: -------------------------------------------------------------------------------- 1 | global._config = require('../config/config'); 2 | 3 | const userModel = require('../src/models/user'); 4 | const db = require('../src/db'); 5 | 6 | userModel.updateMany({}, { 7 | $set: { 8 | apikeys: [] 9 | } 10 | }).exec().then(() => { 11 | db.connection.close(); 12 | console.log('Done.'); 13 | }); 14 | -------------------------------------------------------------------------------- /src/api/api.js: -------------------------------------------------------------------------------- 1 | const uploadMiddleware = require('./endpoints/data/upload/middleware'); 2 | const json = require('./middleware/json'); 3 | const auth = require('./middleware/auth'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | const express = require('express'); 8 | const api = express.Router(); 9 | 10 | // Load endpoints dynamically 11 | const progBase = path.resolve('./src/api/endpoints'); 12 | for (const group of ['nodes', 'user', 'settings']) { 13 | const groupPath = path.join(progBase, group); 14 | 15 | // Resolve modules in this group 16 | for (const name of fs.readdirSync(groupPath)) { 17 | const loc = `/${path.basename(name, '.js')}`; 18 | const module = require(path.join(groupPath, name)); 19 | 20 | api.post(loc, json, auth, serializeResponseOf(module)); 21 | } 22 | } 23 | 24 | // Special user endpoints 25 | const register = require('./endpoints/auth/register'); 26 | const login = require('./endpoints/auth/login'); 27 | 28 | api.post('/register', json, serializeResponseOf(register)); 29 | api.post('/login', json, serializeResponseOf(login)); 30 | 31 | // Special data endpoints 32 | const download = require('./endpoints/data/download'); 33 | const statics = require('./endpoints/data/static'); 34 | const upload = require('./endpoints/data/upload/upload'); 35 | 36 | api.post('/upload', uploadMiddleware, serializeResponseOf(upload)); 37 | api.get('/s/*', auth, statics); 38 | api.get('/d/:id', download); 39 | 40 | function serializeResponseOf(mod) { 41 | return async (req, res) => { 42 | 43 | // Execute async handler and serialize response 44 | const response = await mod(req, res).then(res => { 45 | return {data: res, error: null}; 46 | }).catch(reason => { 47 | return {data: null, error: reason}; 48 | }); 49 | 50 | // Check if handler didn't send any response 51 | if (!res.headerSent) { 52 | res.send(response); 53 | } 54 | }; 55 | } 56 | 57 | module.exports = api; 58 | -------------------------------------------------------------------------------- /src/api/endpoints/auth/login.js: -------------------------------------------------------------------------------- 1 | const {uid, readableDuration} = require('../../../utils'); 2 | const bcrypt = require('bcrypt'); 3 | const userModel = require('../../../models/user'); 4 | 5 | module.exports = async req => { 6 | const { 7 | username, 8 | password, 9 | expireAfter = _config.auth.apikeyExpiry, 10 | expireAt = null 11 | } = req.body; 12 | 13 | if (typeof username !== 'string' || typeof password !== 'string') { 14 | throw {code: 402, text: 'Both username and password must be of type string'}; 15 | } 16 | 17 | // Check to see if the user already exists and throw error if so 18 | return userModel.findOne({username}).exec().then(async user => { 19 | 20 | // Validate 21 | if (!user) { 22 | throw {code: 402, text: 'User not found'}; 23 | } 24 | 25 | // Check password 26 | if (!bcrypt.compareSync(password, user.password)) { 27 | const {blockedLoginDuration, maxLoginAttempts} = _config.auth; 28 | const lastLoginAttemptMs = Date.now() - user.lastLoginAttempt; 29 | 30 | // Check if user tried to many times to login 31 | if (user.loginAttempts >= maxLoginAttempts) { 32 | if (lastLoginAttemptMs < blockedLoginDuration) { 33 | throw {code: 404, text: `Try again in ${readableDuration(blockedLoginDuration - lastLoginAttemptMs)}`}; 34 | } else { 35 | user.set('loginAttempts', 0); 36 | } 37 | } 38 | 39 | // Update login props 40 | user.set('loginAttempts', user.loginAttempts + 1); 41 | user.set('lastLoginAttempt', Date.now()); 42 | await user.save(); 43 | 44 | throw {code: 404, text: 'Wrong password'}; 45 | } else { 46 | 47 | // Reset loginAttempts 48 | user.set('loginAttempts', 0); 49 | } 50 | 51 | // Create and append new apikey 52 | const expire = (Date.now() + expireAfter) || (Date.now() + _config.auth.apikeyExpiry); 53 | const apikey = uid(_config.mongodb.apikeyLength); 54 | user.apikeys.push({ 55 | key: apikey, 56 | expiry: typeof expireAt === 'number' ? expireAt : expire 57 | }); 58 | 59 | // Save user with new apikey 60 | await user.save(); 61 | return {apikey}; 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /src/api/endpoints/auth/register.js: -------------------------------------------------------------------------------- 1 | const {uid} = require('../../../utils'); 2 | const bcrypt = require('bcrypt'); 3 | const nodeModel = require('../../../models/node'); 4 | const userModel = require('../../../models/user'); 5 | 6 | module.exports = async req => { 7 | const {username, password} = req.body; 8 | 9 | // Check if registrations are disabled 10 | if (_config.auth.disableRegistration) { 11 | throw {code: 406, text: 'Registration is currently disabled'}; 12 | } 13 | 14 | if (typeof username !== 'string' || typeof password !== 'string') { 15 | throw {code: 407, text: 'Both username and password must be of type string'}; 16 | } 17 | 18 | // Check to see if the user already exists and throw error if so 19 | return userModel.findOne({username}).exec().then(async opuser => { 20 | 21 | // Validate 22 | if (opuser) { 23 | throw {code: 408, text: 'A user with this name already exist'}; 24 | } 25 | 26 | if (!new RegExp(_config.validation.username).test(username)) { 27 | throw {code: 409, text: 'Username is too short or contains invalid characters'}; 28 | } 29 | 30 | if (!new RegExp(_config.validation.password).test(password)) { 31 | throw {code: 410, text: 'Password is too short'}; 32 | } 33 | 34 | const apikey = uid(_config.mongodb.apikeyLength); 35 | const userid = uid(); 36 | 37 | await Promise.all([ 38 | 39 | // Create user 40 | new userModel({ 41 | id: userid, 42 | username, 43 | password: bcrypt.hashSync(password, _config.auth.saltRounds), 44 | 45 | apikeys: [{ 46 | key: apikey, 47 | expiry: Date.now() + _config.auth.apikeyExpiry 48 | }], 49 | 50 | settings: _config.auth.defaultSettings 51 | }).save(), 52 | 53 | // Create entry node 54 | new nodeModel({ 55 | owner: userid, 56 | id: uid(), 57 | parent: 'root', 58 | lastModified: Date.now(), 59 | type: 'dir', 60 | name: 'Home', 61 | marked: false, 62 | color: null 63 | }).save() 64 | ]); 65 | 66 | return {apikey}; 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /src/api/endpoints/data/download.js: -------------------------------------------------------------------------------- 1 | const createZipStream = require('../../tools/createZipStream'); 2 | const authViaApiKey = require('../../tools/authViaApiKey'); 3 | const nodeModel = require('../../../models/node'); 4 | const fs = require('fs'); 5 | 6 | module.exports = async (req, res) => { 7 | const {apikey} = req.query; 8 | const {id} = req.params; 9 | 10 | if (typeof id !== 'string') { 11 | throw {code: 102, text: 'Invalid node id'}; 12 | } 13 | 14 | // Authenticate user 15 | const user = await authViaApiKey(apikey).catch(() => null); 16 | const node = await nodeModel.findOne(user ? {owner: user.id, id} : {staticIds: id}).exec(); 17 | 18 | if (!node) { 19 | return res.status(404).send(); 20 | } 21 | 22 | if (node.type === 'file') { 23 | const path = `${_config.server.storagePath}/${node.id}`; 24 | 25 | if (fs.existsSync(path)) { 26 | res.download(path, node.name); 27 | } else { 28 | 29 | // Delete node because the corresponding file is mising 30 | await nodeModel.deleteOne({id: node.id}).exec(); 31 | res.status(404).send(); 32 | } 33 | } else if (node.type === 'dir') { 34 | return createZipStream([node.id]).then(stream => { 35 | res.set('Content-Disposition', `attachment; filename=${node.name}.zip`); 36 | stream.pipe(res); 37 | }); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/api/endpoints/data/static.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | const fs = require('fs'); 3 | 4 | module.exports = async (req, res) => { 5 | const {_user, id} = req.body; 6 | 7 | if (typeof id !== 'string') { 8 | throw {code: 103, text: 'Invalid node id'}; 9 | } 10 | 11 | // Check if user is owner 12 | await nodeModel.findOne({owner: _user.id, id}).exec().then(async node => { 13 | 14 | // Check node 15 | if (!node) { 16 | return res.status(404).send(); 17 | } 18 | 19 | // Check if in demo mode 20 | if (_config.demo) { 21 | const {name} = node; 22 | 23 | for (const {regex, url} of _config.demoContent) { 24 | if (name.match(new RegExp(regex, 'i'))) { 25 | return res.redirect(url); 26 | } 27 | } 28 | } 29 | 30 | // Make sure the file exists 31 | const path = `${_config.server.storagePath}/${node.id}`; 32 | if (fs.existsSync(path)) { 33 | const fileSize = fs.statSync(path).size; 34 | 35 | res.contentType(node.name); 36 | res.set('Accept-Ranges', 'bytes'); 37 | 38 | // Get the 'range' header if one was sent 39 | if (req.headers.range) { 40 | const match = req.headers.range.match(/bytes=([\d]+)-([\d]*)/); 41 | 42 | if (match) { 43 | const start = Number(match[1]) || 0; 44 | const chunkEnd = start + _config.server.mediaStreamChunckSize; 45 | const end = Number(match[2]) || (chunkEnd < fileSize ? chunkEnd : fileSize); 46 | 47 | // Validate range 48 | if (start >= 0 && end <= fileSize) { 49 | res.set({ 50 | 'Content-Disposition': `attachment; filename="${node.name}"`, 51 | 'Content-Length': end - start, 52 | 'Content-Range': `bytes ${start}-${end - 1}/${fileSize}` 53 | }); 54 | 55 | // Set Partial Content header and stream range 56 | res.status(206); 57 | return fs.createReadStream(path, {start, end}).pipe(res); 58 | } 59 | } 60 | 61 | // Invalid range header or range is out of bounds. Send Range Not Satisfiable code back 62 | return res.status(416).send(); 63 | } 64 | 65 | res.set('Content-Length', fileSize); 66 | fs.createReadStream(path).pipe(res); 67 | } else { 68 | 69 | // Delete node because the corresponding file is mising 70 | await nodeModel.deleteOne({owner: _user.id, id}).exec(); 71 | res.status(404).send(); 72 | } 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /src/api/endpoints/data/upload/WritableVoidStream.js: -------------------------------------------------------------------------------- 1 | const stream = require('stream'); 2 | 3 | class WritableVoidStream extends stream.Writable { 4 | 5 | constructor() { 6 | super(); 7 | this.bytesWritten = 0; 8 | } 9 | 10 | _write(chunk, enc, next) { 11 | this.bytesWritten += chunk.length; 12 | next(); 13 | } 14 | } 15 | 16 | module.exports = WritableVoidStream; 17 | -------------------------------------------------------------------------------- /src/api/endpoints/data/upload/middleware.js: -------------------------------------------------------------------------------- 1 | const {uid} = require('../../../../utils'); 2 | const storageEngine = require('./storageEngine'); 3 | const multer = require('multer'); 4 | const fs = require('fs'); 5 | 6 | const formdata = multer({ 7 | storage: storageEngine(() => `${global._config.server.storagePath}/${uid(10)}`), 8 | limits: { 9 | fileSize: _config.server.uploadSizeLimitPerFile 10 | } 11 | }).any(); 12 | 13 | module.exports = (req, res, next) => { 14 | 15 | // Listen if request gets canceled 16 | req.on('aborted', () => { 17 | if (req.incomingFiles) { 18 | for (const {stream, path} of req.incomingFiles) { 19 | 20 | // Kill stream if not already closed 21 | if (!stream.closed) { 22 | stream.end(); 23 | } 24 | 25 | // Delete file 26 | if (fs.existsSync(path)) { 27 | fs.unlink(path, () => 0); 28 | } 29 | } 30 | } 31 | }); 32 | 33 | formdata(req, res, next); 34 | }; 35 | -------------------------------------------------------------------------------- /src/api/endpoints/data/upload/storageEngine.js: -------------------------------------------------------------------------------- 1 | const fs = require('graceful-fs'); 2 | const path = require('path'); 3 | const WritableVoidStream = require('./WritableVoidStream'); 4 | 5 | class StorageEngine { 6 | 7 | constructor(destination) { 8 | this.destination = destination; 9 | } 10 | 11 | _handleFile(req, file, cb) { 12 | const dest = this.destination(req, file); 13 | 14 | // Check path 15 | if (!dest || typeof dest !== 'string') { 16 | throw {code: 104, text: `Invalid path: ${dest}`}; 17 | } 18 | 19 | // Create file 20 | fs.closeSync(fs.openSync(dest, 'w')); 21 | 22 | // Create void stream if in demo mode 23 | const stream = _config.demo ? new WritableVoidStream() : fs.createWriteStream(dest); 24 | 25 | // Save stream and filepath to request object 26 | req.incomingFiles = req.incomingFiles || []; 27 | req.incomingFiles.push({stream: stream, path: dest}); 28 | 29 | stream.on('error', cb); 30 | stream.on('finish', () => { 31 | 32 | // Provide same props as multers storage engine 33 | cb(null, { 34 | filename: path.basename(dest), 35 | destination: path.dirname(dest), 36 | path: dest, 37 | size: stream.bytesWritten 38 | }); 39 | }); 40 | 41 | file.stream.pipe(stream); 42 | } 43 | 44 | _removeFile(req, file, cb) { 45 | fs.unlink(file.path, cb); 46 | } 47 | } 48 | 49 | module.exports = destination => new StorageEngine(destination); 50 | -------------------------------------------------------------------------------- /src/api/endpoints/data/upload/upload.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const {pick} = require('../../../../utils'); 3 | const authViaApiKey = require('../../../tools/authViaApiKey'); 4 | const usedSpaceBy = require('../../../tools/usedSpaceBy'); 5 | const nodeModel = require('../../../../models/node'); 6 | 7 | module.exports = async req => { 8 | const {body, files} = req; 9 | 10 | // Authenticate user 11 | const user = await authViaApiKey(body.apikey).catch(reason => { 12 | 13 | // Delete files 14 | for (const {path} of files) { 15 | fs.unlink(path, () => 0); 16 | } 17 | 18 | throw {code: 105, text: reason}; 19 | }); 20 | 21 | const contentLength = Number(req.get('content-length')); 22 | 23 | // Validate header 24 | if (isNaN(contentLength)) { 25 | throw {code: 106, text: 'Invalid content-length header'}; 26 | } 27 | 28 | // Compare current storage size and upload size with limit 29 | const {totalStorageLimitPerUser} = _config.server; 30 | if (~totalStorageLimitPerUser && (await usedSpaceBy(user.id)) + contentLength > totalStorageLimitPerUser) { 31 | throw {code: 107, text: `Storage limit of ${totalStorageLimitPerUser} bytes exceed`}; 32 | } 33 | 34 | // Rename files and create nodes 35 | const nodes = []; 36 | for (let i = 0, n = files.length; i < n; i++) { 37 | const {fieldname, filename, originalname, path, size} = files[i]; 38 | 39 | /** 40 | * Beause fordata cannot have multiple values assigned to 41 | * one key, they're prefixed with a index: [HASH]-[INDEX] 42 | * 43 | * Extract the hash. 44 | */ 45 | const parent = fieldname.substring(0, fieldname.indexOf('-')); 46 | if (!parent) { 47 | fs.unlinkSync(path); 48 | throw {code: 108, text: 'Invalid node key'}; 49 | } 50 | 51 | // Check if destination exists and is a folder 52 | const destNode = await nodeModel.findOne({owner: user.id, id: parent}).exec(); 53 | if (!destNode || destNode.type !== 'dir') { 54 | fs.unlinkSync(path); 55 | throw {code: 109, text: 'Invalid parent node'}; 56 | } 57 | 58 | // Create and push new node 59 | nodes.push(new nodeModel({ 60 | owner: user.id, 61 | id: filename, 62 | parent: parent, 63 | type: 'file', 64 | name: originalname, 65 | lastModified: Date.now(), 66 | marked: false, 67 | size 68 | }).save().then(node => { 69 | return pick(node, _config.mongodb.exposedProps.fileNode); 70 | }).catch(reason => { 71 | console.warn(reason); // eslint-disable-line no-console 72 | fs.unlink(path, () => 0); 73 | return null; 74 | })); 75 | } 76 | 77 | return Promise.all(nodes); 78 | }; 79 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/addMark.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | 3 | module.exports = async req => { 4 | const {_user, nodes} = req.body; 5 | 6 | // Validate 7 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 8 | throw {code: 200, text: 'Invalid nodes scheme'}; 9 | } 10 | 11 | // Mark nodes 12 | await nodeModel.updateMany( 13 | {owner: _user.id, id: {$in: nodes}}, 14 | { 15 | $set: { 16 | marked: true, 17 | lastModified: Date.now() 18 | } 19 | } 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/addStaticId.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | const {uid} = require('../../../utils'); 3 | 4 | module.exports = async req => { 5 | const {_user, node} = req.body; 6 | 7 | if (typeof node !== 'string') { 8 | throw {code: 201, text: 'Node must be of type string'}; 9 | } 10 | 11 | // Find requested node 12 | return nodeModel.findOne({ 13 | owner: _user.id, 14 | id: node 15 | }).exec().then(node => { 16 | if (node) { 17 | 18 | // Add new static id 19 | const newUid = uid(_config.mongodb.staticLinkUIDLength); 20 | node.staticIds.push(newUid); 21 | node.set('lastModified', Date.now()); 22 | return node.save().then(() => newUid); 23 | } else { 24 | throw {code: 202, text: 'Can\'t find requested node'}; 25 | } 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/changeColor.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | 3 | module.exports = async req => { 4 | const {_user, nodes, newColor} = req.body; 5 | 6 | // Validate 7 | if (!new RegExp(_config.validation.hexcolor).test(newColor)) { 8 | throw {code: 203, text: 'Color must be in hexadecimal format.'}; 9 | } 10 | 11 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 12 | throw {code: 204, text: 'Invalid nodes scheme'}; 13 | } 14 | 15 | // Find all nodes from this user and filter props 16 | await nodeModel.updateMany( 17 | {owner: _user.id, id: {$in: nodes}}, 18 | { 19 | $set: { 20 | color: newColor, 21 | lastModified: Date.now() 22 | } 23 | } 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/copy.js: -------------------------------------------------------------------------------- 1 | const usedSpaceBy = require('../../tools/usedSpaceBy'); 2 | const nodeModel = require('../../../models/node'); 3 | const {uid, pick} = require('../../../utils'); 4 | const mongoose = require('mongoose'); 5 | const fs = require('fs'); 6 | 7 | module.exports = async req => { 8 | const {_user, destination, nodes} = req.body; 9 | 10 | if (typeof destination !== 'string') { 11 | throw {code: 205, text: 'Destination must be of type string'}; 12 | } 13 | 14 | // Check if destination exists and is a folder 15 | const destNode = await nodeModel.findOne({owner: _user.id, id: destination}).exec(); 16 | if (!destNode || destNode.type !== 'dir') { 17 | throw {code: 206, text: 'Destination does not exist or is not a directory'}; 18 | } 19 | 20 | // Validate 21 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 22 | throw {code: 207, text: 'Invalid nodes scheme'}; 23 | } 24 | 25 | // Used to check whenever a copy is because of storage limit not possible 26 | let spaceUsed = await usedSpaceBy(_user.id); 27 | const {totalStorageLimitPerUser} = _config.server; 28 | const updateUsedSpace = amount => { 29 | if (~totalStorageLimitPerUser) { 30 | spaceUsed += amount; 31 | 32 | if (spaceUsed > totalStorageLimitPerUser) { 33 | throw {code: 208, text: `Storage limit of ${totalStorageLimitPerUser} bytes exceed`}; 34 | } 35 | } 36 | }; 37 | 38 | const newNodes = []; 39 | 40 | /* eslint-disable require-atomic-updates */ 41 | async function addChilds(n, newParent) { 42 | const newId = uid(); 43 | 44 | // Node is new 45 | n._id = mongoose.Types.ObjectId(); 46 | n.isNew = true; 47 | 48 | if (n.type === 'dir') { 49 | 50 | // Find all nodes which have n as parent 51 | await nodeModel.find({owner: _user.id, parent: n.id}).exec().then(async rnodes => { 52 | for (let i = 0, n; n = rnodes[i], i < rnodes.length; i++) { 53 | 54 | // Find all children recursive 55 | await addChilds(n, newId); 56 | } 57 | }); 58 | } else { 59 | 60 | // Copy file 61 | const src = `${_config.server.storagePath}/${n.id}`; 62 | const dest = `${_config.server.storagePath}/${newId}`; 63 | if (fs.existsSync(src)) { 64 | const {size} = fs.statSync(src); 65 | await updateUsedSpace(size); 66 | 67 | fs.copyFileSync(src, dest); 68 | } else { 69 | await nodeModel.deleteOne({owner: _user.id, id: n.id}); 70 | return; 71 | } 72 | } 73 | 74 | // Update id and parent 75 | n.id = newId; 76 | n.parent = newParent; 77 | newNodes.push(await n.save()); 78 | } 79 | 80 | const rnodes = await nodeModel.find({owner: _user.id, id: {$in: nodes}}).exec(); 81 | const destNodes = await nodeModel.find({owner: _user.id, parent: destination}).exec(); 82 | for (let i = 0, n; n = rnodes[i], i < rnodes.length; i++) { 83 | 84 | // Apply new name 85 | n.name = buildCopyName(n, destNodes); 86 | 87 | // Find all children recursive 88 | await addChilds(n, destination); 89 | } 90 | 91 | const {dirNode, fileNode} = _config.mongodb.exposedProps; 92 | return Promise.resolve({ 93 | nodes: newNodes.map(v => pick(v, v.type === 'dir' ? dirNode : fileNode)) 94 | }); 95 | }; 96 | 97 | function buildCopyName(node, nodes) { 98 | 99 | // Extract name and extenstion 100 | const {name: vName, extension: vExtension} = parseName(node.name); 101 | 102 | // Find previous copied versions 103 | let version = 1, match; 104 | for (let i = 0, n; n = nodes[i], i < nodes.length; i++) { 105 | 106 | // Extract raw name without any extensions 107 | const {name: nName} = parseName(n.name); 108 | 109 | /** 110 | * Check if node starts with the current to-copy nodes name and 111 | * has already a copy flag increase it. 112 | */ 113 | if (n.name.startsWith(vName) && 114 | (match = nName.match(/\((([\d]+)(st|nd|rd|th) |)Copy\)$/))) { 115 | 116 | // Check if node has been already multiple times copied 117 | if (match[2]) { 118 | const val = Number(match[2]); 119 | 120 | // Check if version is above current 121 | if (val && val >= version) { 122 | version = val + 1; 123 | } 124 | } 125 | } 126 | } 127 | 128 | return `${vName} ${version ? ` (${spelledNumber(version)} ` : '('}Copy)${vExtension}`; 129 | } 130 | 131 | // Function to extract a name and extension from a filename 132 | function parseName(name) { 133 | const rdi = name.indexOf('.'); 134 | const di = ~rdi ? rdi : name.length; 135 | return {name: name.substring(0, di), extension: name.substring(di, name.length)}; 136 | } 137 | 138 | function spelledNumber(num) { 139 | switch (num) { 140 | case 1: 141 | return `${num}st`; 142 | case 2: 143 | return `${num}nd`; 144 | case 3: 145 | return `${num}rd`; 146 | default: 147 | return `${num}th`; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/createFolder.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | const {uid, pick} = require('../../../utils'); 3 | 4 | module.exports = async req => { 5 | const {_user, parent, name} = req.body; 6 | 7 | if (typeof parent !== 'string') { 8 | throw {code: 209, text: 'Both parent and name must be of type string'}; 9 | } 10 | 11 | // Find all nodes from this user and filter props 12 | const exposedDirNodeProps = _config.mongodb.exposedProps.dirNode; 13 | return nodeModel.findOne({owner: _user.id, id: parent}).exec().then(parent => { 14 | 15 | if (!parent) { 16 | throw {code: 210, text: 'Can\'t find parent'}; 17 | } 18 | 19 | return new nodeModel({ 20 | owner: _user.id, 21 | id: uid(), 22 | parent: parent.id, 23 | type: 'dir', 24 | name: name || 'New Folder', 25 | lastModified: Date.now(), 26 | color: _user.settings.user.defaultFolderColor, 27 | marked: false 28 | }).save(); 29 | 30 | }).then(node => { 31 | return { 32 | node: pick(node, exposedDirNodeProps) 33 | }; 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/createFolders.js: -------------------------------------------------------------------------------- 1 | const {uid, pick} = require('../../../utils'); 2 | const nodeModel = require('../../../models/node'); 3 | const Ajv = require('ajv'); 4 | 5 | const foldertreeScheme = _config.validation.schemes.foldertree; 6 | module.exports = async req => { 7 | const {_user, folders, parent} = req.body; 8 | 9 | const ajv = new Ajv(); 10 | const valid = ajv.validate(foldertreeScheme, folders); 11 | 12 | // Check if validation has failed 13 | if (!valid) { 14 | throw {code: 211, text: 'Invalid request properties'}; 15 | } 16 | 17 | if (typeof parent !== 'string') { 18 | throw {code: 212, text: 'Parent must be of type string'}; 19 | } 20 | 21 | const exposedDirNodeProps = _config.mongodb.exposedProps.dirNode; 22 | return nodeModel.findOne({owner: _user.id, id: parent}).exec().then(async root => { 23 | 24 | if (!root) { 25 | throw {code: 213, text: 'Invalid root node'}; 26 | } 27 | 28 | // Create uids 29 | const folderAmount = folders.length; 30 | const idMap = {[-1]: root.id}; 31 | for (let i = 0; i < folderAmount; i++) { 32 | idMap[i] = uid(); 33 | } 34 | 35 | // Create nodes 36 | const nodes = []; 37 | for (let i = 0; i < folderAmount; i++) { 38 | const folder = folders[i]; 39 | 40 | // Create folder 41 | const newNode = await new nodeModel({ 42 | owner: _user.id, 43 | id: idMap[folder.id], 44 | parent: idMap[folder.parent], 45 | type: 'dir', 46 | name: folder.name || 'Unknown', 47 | lastModified: Date.now(), 48 | color: _user.settings.user.defaultFolderColor, 49 | marked: false 50 | }).save().then(node => { 51 | return pick(node, exposedDirNodeProps); 52 | }); 53 | 54 | nodes.push(newNode); 55 | } 56 | 57 | return {idMap, nodes}; 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/delete.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const traverseNodes = require('../../tools/traverseNodes'); 3 | const nodeModel = require('../../../models/node'); 4 | 5 | module.exports = async req => { 6 | const {_user, nodes} = req.body; 7 | 8 | // Validate 9 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 10 | throw {code: 100, text: 'Invalid nodes scheme'}; 11 | } 12 | 13 | // Resolve id's and remove files from file system 14 | const ids = []; 15 | await traverseNodes(_user, nodes, node => { 16 | 17 | // Prevent deleting of root element 18 | if (node.parent === 'root') { 19 | throw {code: 101, text: 'Can\'t delete root node'}; 20 | } 21 | 22 | // Remove file if node is an file 23 | if (node.type === 'file') { 24 | 25 | // Delete file 26 | fs.unlink(`${_config.server.storagePath}/${node.id}`, () => 0); 27 | } 28 | 29 | ids.push(node.id); 30 | }); 31 | 32 | // Remove nodes 33 | return nodeModel.deleteMany({owner: _user.id, id: {$in: ids}}) 34 | .exec() 35 | .then(() => null); 36 | }; 37 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/move.js: -------------------------------------------------------------------------------- 1 | const traverseNodes = require('../../tools/traverseNodes'); 2 | const nodeModel = require('../../../models/node'); 3 | 4 | module.exports = async req => { 5 | const {_user, destination, nodes} = req.body; 6 | 7 | await traverseNodes(_user, nodes, node => { 8 | 9 | // Check if user paste folder into itself or one of its siblings 10 | if (node.id === destination) { 11 | throw {code: 214, text: 'A directory cannot be put into itself'}; 12 | } 13 | }); 14 | 15 | if (typeof destination !== 'string') { 16 | throw {code: 215, text: 'Destination must be of type string'}; 17 | } 18 | 19 | // Check if destination exists and is a folder 20 | const destNode = await nodeModel.findOne({owner: _user.id, id: destination}).exec(); 21 | if (!destNode || destNode.type !== 'dir') { 22 | throw {code: 216, text: 'Destination does not exist or is not a directory'}; 23 | } 24 | 25 | // Validate 26 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 27 | throw {code: 217, text: 'Invalid nodes scheme'}; 28 | } 29 | 30 | // Move childs 31 | await nodeModel.find({owner: _user.id, id: {$in: nodes}}).exec().then(async nds => { 32 | 33 | if (nds.length !== nodes.length) { 34 | throw {code: 218, text: 'Request contains invalid nodes'}; 35 | } 36 | 37 | // Apply new parent 38 | for (let i = 0; i < nds.length; i++) { 39 | const node = nds[i]; 40 | node.set('parent', destination); 41 | await node.save(); 42 | } 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/moveToBin.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | 3 | module.exports = async req => { 4 | const {_user, nodes} = req.body; 5 | 6 | // Validate 7 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 8 | throw {code: 219, text: 'Invalid nodes scheme'}; 9 | } 10 | 11 | await nodeModel.updateMany( 12 | {owner: _user.id, id: {$in: nodes}}, 13 | { 14 | $set: { 15 | bin: true, 16 | lastModified: Date.now() 17 | } 18 | } 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/removeMark.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | 3 | module.exports = async req => { 4 | const {_user, nodes} = req.body; 5 | 6 | // Validate 7 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 8 | throw {code: 220, text: 'Invalid nodes scheme'}; 9 | } 10 | 11 | // Unmark nodes 12 | await nodeModel.updateMany( 13 | {owner: _user.id, id: {$in: nodes}}, 14 | { 15 | $set: { 16 | marked: false, 17 | lastModified: Date.now() 18 | } 19 | } 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/removeStaticIds.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | 3 | module.exports = async req => { 4 | const {_user, ids} = req.body; 5 | 6 | if (!Array.isArray(ids) || ids.some(v => typeof v !== 'string')) { 7 | throw {code: 221, text: 'Ids must be an Array of strings'}; 8 | } 9 | 10 | // Find requested node 11 | return nodeModel.updateMany( 12 | {owner: _user.id}, 13 | { 14 | $pullAll: { 15 | staticIds: ids 16 | } 17 | } 18 | ).exec().then(() => null); 19 | }; 20 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/rename.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | 3 | module.exports = async req => { 4 | const {_user, target, newName} = req.body; 5 | 6 | if (typeof newName !== 'string' || !new RegExp(_config.validation.dirname).test(newName)) { 7 | throw {code: 223, text: 'Invalid new name'}; 8 | } 9 | 10 | if (typeof target !== 'string') { 11 | throw {code: 224, text: 'Target must be of type string'}; 12 | } 13 | 14 | // Rename node 15 | await nodeModel.updateOne( 16 | {owner: _user.id, id: target}, 17 | { 18 | $set: { 19 | name: newName, 20 | lastModified: Date.now() 21 | } 22 | } 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/restoreFromBin.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | 3 | module.exports = async req => { 4 | const {_user, nodes} = req.body; 5 | 6 | // Validate 7 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 8 | throw {code: 225, text: 'Invalid nodes scheme'}; 9 | } 10 | 11 | await nodeModel.updateMany( 12 | {owner: _user.id, id: {$in: nodes}}, 13 | { 14 | $set: { 15 | bin: false, 16 | lastModified: Date.now() 17 | } 18 | } 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/update.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | const {pick} = require('../../../utils'); 3 | 4 | module.exports = async req => { 5 | const {_user} = req.body; 6 | 7 | // Find all nodes from this user and filter props 8 | const {dirNode, fileNode} = _config.mongodb.exposedProps; 9 | return nodeModel.find({owner: _user.id}).exec().then(res => { 10 | for (let i = 0, l = res.length; i < l; i++) { 11 | const node = res[i]; 12 | res[i] = pick(node, node.type === 'dir' ? dirNode : fileNode); 13 | } 14 | 15 | return {nodes: res}; 16 | }); 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /src/api/endpoints/nodes/zip.js: -------------------------------------------------------------------------------- 1 | const createZipStream = require('../../tools/createZipStream'); 2 | const nodeModel = require('../../../models/node'); 3 | const {uid, pick} = require('../../../utils'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | module.exports = async req => { 8 | const {_user, nodes} = req.body; 9 | 10 | // Validate 11 | if (!Array.isArray(nodes) || nodes.some(v => typeof v !== 'string')) { 12 | throw {code: 226, text: 'Invalid nodes scheme'}; 13 | } 14 | 15 | // Create name 16 | const aNode = await nodeModel.findOne({id: nodes[0]}).exec(); 17 | if (!aNode) { 18 | throw {code: 227, text: 'Trying to zip nothing (or invalid nodes)'}; 19 | } 20 | 21 | const nameNode = nodes.length === 1 ? aNode : await nodeModel.findOne({id: aNode.parent}).exec(); 22 | if (!nameNode) { 23 | throw {code: 228, text: 'Can\'t find parent node'}; 24 | } 25 | 26 | // Zip files 27 | const name = `${nameNode.name}.zip`; 28 | const id = uid(); 29 | return createZipStream(nodes).then(stream => { 30 | 31 | // Calculate size 32 | let size = 0; 33 | stream.on('data', chunk => size += chunk.length); 34 | 35 | // Pipe to file 36 | return new Promise((resolve, reject) => { 37 | stream.pipe(fs.createWriteStream(path.join(_config.server.storagePath, id))) 38 | .on('finish', () => resolve(size)) 39 | .on('error', reject) 40 | .on('close', reject); 41 | }); 42 | 43 | }).then(size => { 44 | return new nodeModel({ 45 | owner: _user.id, 46 | id, 47 | parent: aNode.parent, 48 | type: 'file', 49 | name, 50 | lastModified: Date.now(), 51 | marked: false, 52 | size 53 | }).save(); 54 | }).then(node => { 55 | return {node: pick(node, _config.mongodb.exposedProps.fileNode)}; 56 | }).catch(() => { 57 | throw {code: 229, text: 'Zipping failed'}; 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/api/endpoints/settings/settings.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({body: {_user}}) => _user.settings; 2 | -------------------------------------------------------------------------------- /src/api/endpoints/settings/updateSettings.js: -------------------------------------------------------------------------------- 1 | const userModel = require('../../../models/user'); 2 | const Ajv = require('ajv'); 3 | 4 | const settingsScheme = _config.validation.schemes.settings; 5 | module.exports = async req => { 6 | const {_user, settings} = req.body; 7 | 8 | // Validate body 9 | const ajv = new Ajv(); 10 | const valid = ajv.validate(settingsScheme, settings); 11 | 12 | // Check if validation has failed 13 | if (!valid) { 14 | throw {code: 300, text: 'Invalid request properties'}; 15 | } 16 | 17 | // Apply, mark as modified and save 18 | return userModel.updateOne( 19 | {id: _user.id}, 20 | {$set: {settings}} 21 | ).exec().then(() => null); 22 | }; 23 | -------------------------------------------------------------------------------- /src/api/endpoints/user/checkApiKey.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => true; 2 | -------------------------------------------------------------------------------- /src/api/endpoints/user/deleteAccount.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../../models/node'); 2 | const userModel = require('../../../models/user'); 3 | const bcrypt = require('bcrypt'); 4 | const fs = require('fs'); 5 | 6 | module.exports = async req => { 7 | const {_user, password} = req.body; 8 | 9 | // Validate password 10 | if (typeof password !== 'string' || !bcrypt.compareSync(password, _user.password)) { 11 | throw {code: 400, text: 'Password incorrect'}; 12 | } 13 | 14 | return Promise.all([ 15 | 16 | // Delete files 17 | nodeModel.find({owner: _user.id}).exec().then(nodes => { 18 | for (let i = 0, n; n = nodes[i], i < nodes.length; i++) { 19 | if (n.type === 'file') { 20 | 21 | // Build local storage path and check if file exists 22 | const path = `${_config.server.storagePath}/${nodeModel.id}`; 23 | if (fs.existsSync(path)) { 24 | fs.unlink(path, () => 0); 25 | } 26 | } 27 | } 28 | }), 29 | 30 | // Remove nodes 31 | nodeModel.deleteMany({owner: _user.id}).exec(), 32 | 33 | // Remove user 34 | userModel.deleteOne({id: _user.id}).exec() 35 | ]); 36 | }; 37 | -------------------------------------------------------------------------------- /src/api/endpoints/user/logout.js: -------------------------------------------------------------------------------- 1 | module.exports = async req => { 2 | const {_user, apikey} = req.body; 3 | 4 | // Remove apikey 5 | const apikeyIndex = _user.apikeys.findIndex(v => v.key === apikey); 6 | 7 | // Validate index and logout user 8 | if (~apikeyIndex) { 9 | _user.apikeys.splice(apikeyIndex, 1); 10 | _user.markModified('apikeys'); 11 | } 12 | 13 | return _user.save().then(() => null); 14 | }; 15 | -------------------------------------------------------------------------------- /src/api/endpoints/user/logoutEverywhere.js: -------------------------------------------------------------------------------- 1 | const websocket = require('../../../websocket'); 2 | 3 | module.exports = async req => { 4 | const {_user} = req.body; 5 | 6 | // Clear apikeys 7 | _user.set('apikeys', []); 8 | 9 | // Broadcast logout 10 | websocket.broadcast({ 11 | userid: _user.id, 12 | type: 'logout', 13 | data: null 14 | }); 15 | 16 | return _user.save().then(() => null); 17 | }; 18 | -------------------------------------------------------------------------------- /src/api/endpoints/user/status.js: -------------------------------------------------------------------------------- 1 | module.exports = async req => { 2 | const {_user} = req.body; 3 | 4 | return { 5 | availableSpace: _config.server.totalStorageLimitPerUser, 6 | uploadSizeLimitPerFile: _config.server.uploadSizeLimitPerFile, 7 | user: { 8 | id: _user.id, 9 | username: _user.username, 10 | lastLogin: _user.lastLoginAttempt, 11 | loginAttempts: _user.loginAttempts 12 | } 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/api/endpoints/user/updateCredentials.js: -------------------------------------------------------------------------------- 1 | const userModel = require('../../../models/user'); 2 | const bcrypt = require('bcrypt'); 3 | 4 | module.exports = async req => { 5 | const {_user, currentPassword, newUsername, newPassword} = req.body; 6 | 7 | // Validate password 8 | if (typeof currentPassword !== 'string' || !bcrypt.compareSync(currentPassword, _user.password)) { 9 | throw {code: 411, text: 'Wrong password'}; 10 | } 11 | 12 | // Apply new username (if set) 13 | if (typeof newUsername === 'string' && newUsername) { 14 | 15 | // Check if already a user has this username 16 | if (await userModel.findOne({username: newUsername}).exec()) { 17 | throw {code: 412, text: 'A user with this name already exist'}; 18 | } 19 | 20 | // Validate username 21 | if (new RegExp(_config.validation.username).test(newUsername)) { 22 | _user.set('username', newUsername); 23 | } else { 24 | throw {code: 413, text: 'Username is too short or contains invalid characters'}; 25 | } 26 | } 27 | 28 | // Apply new password (if set) 29 | if (typeof newPassword === 'string' && newPassword) { 30 | 31 | // Validate password 32 | if (new RegExp(_config.validation.password).test(newPassword)) { 33 | _user.set('password', bcrypt.hashSync(newPassword, _config.auth.saltRounds)); 34 | } else { 35 | throw {code: 414, text: 'Password is too short'}; 36 | } 37 | } 38 | 39 | await _user.save(); 40 | }; 41 | -------------------------------------------------------------------------------- /src/api/middleware/auth.js: -------------------------------------------------------------------------------- 1 | const userModel = require('../../models/user'); 2 | 3 | module.exports = (req, res, next) => { 4 | const {apikey} = (req.body || req.query); 5 | 6 | if (typeof apikey !== 'string' || !apikey) { 7 | throw 'APIKey is invalid'; 8 | } 9 | 10 | return userModel.findOne({ 11 | apikeys: { 12 | $elemMatch: { 13 | key: apikey 14 | } 15 | } 16 | }).exec().then(user => { 17 | 18 | // Check if user exists 19 | if (!user) { 20 | throw {code: -1, text: 'APIKey is invalid'}; 21 | } 22 | 23 | // Remove expired apikeys 24 | const now = Date.now(); 25 | user.set('apikeys', user.apikeys.filter( 26 | ({expiry}) => expiry > now 27 | )); 28 | 29 | return user.save(); 30 | }).then(user => { 31 | 32 | // Merge query and body 33 | req.body = { 34 | ...req.body, 35 | ...req.query 36 | }; 37 | 38 | req.body._user = user; 39 | next(); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/api/middleware/json.js: -------------------------------------------------------------------------------- 1 | const bodyParser = require('body-parser'); 2 | 3 | module.exports = bodyParser.json({ 4 | limit: '50mb' 5 | }); 6 | -------------------------------------------------------------------------------- /src/api/tools/authViaApiKey.js: -------------------------------------------------------------------------------- 1 | const userModel = require('../../models/user'); 2 | 3 | module.exports = async apikey => { 4 | 5 | if (typeof apikey !== 'string' || !apikey) { 6 | throw 'APIKey is invalid'; 7 | } 8 | 9 | return userModel.findOne({ 10 | apikeys: { 11 | $elemMatch: { 12 | key: apikey 13 | } 14 | } 15 | }).exec().then(user => { 16 | 17 | // Check if user exists 18 | if (!user) { 19 | throw {code: -1, text: 'APIKey is invalid'}; 20 | } 21 | 22 | // Remove expired apikeys 23 | const now = Date.now(); 24 | user.set('apikeys', user.apikeys.filter( 25 | ({expiry}) => expiry > now 26 | )); 27 | 28 | return user.save(); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/api/tools/createZipStream.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../models/node'); 2 | const JSZip = require('jszip'); 3 | const fs = require('fs'); 4 | 5 | module.exports = async nodes => { 6 | const zip = new JSZip(); 7 | 8 | async function handleNode(path = '', node) { 9 | 10 | if (node.type === 'file') { 11 | 12 | // Zip single file 13 | zip.file(path, fs.createReadStream(`${_config.server.storagePath}/${node.id}`)); 14 | } else if (node.type === 'dir') { 15 | 16 | // Resolve child nodes 17 | return nodeModel.find({parent: node.id}).exec().then(async rnodes => { 18 | for (let i = 0, l = rnodes.length; i < l; i++) { 19 | const node = rnodes[i]; 20 | await handleNode(`${path}/${node.name}`, node); 21 | } 22 | }); 23 | } 24 | } 25 | 26 | for (let i = 0, l = nodes.length; i < l; i++) { 27 | await nodeModel.findOne({id: nodes[i]}).exec().then(node => { 28 | if (node) { 29 | return handleNode(node.name, node); 30 | } 31 | }); 32 | } 33 | 34 | return zip.generateNodeStream({type: 'nodebuffer', streamFiles: true}); 35 | }; 36 | -------------------------------------------------------------------------------- /src/api/tools/traverseNodes.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../models/node'); 2 | 3 | module.exports = async (user, nodes, cb) => { 4 | 5 | async function handleNode(node) { 6 | if (node) { 7 | cb(node); 8 | 9 | if (node.type === 'dir') { 10 | await traverseNode(node); 11 | } 12 | } 13 | } 14 | 15 | async function traverseNode(n) { 16 | 17 | // Find all nodes which have n as parent 18 | await nodeModel.find({owner: user.id, parent: n.id}).exec().then(async rnodes => { 19 | for (let i = 0, l = rnodes.length; i < l; i++) { 20 | await handleNode(rnodes[i]); 21 | } 22 | }); 23 | } 24 | 25 | for (let i = 0, l = nodes.length; i < l; i++) { 26 | await handleNode(await nodeModel.findOne({owner: user.id, id: nodes[i]}).exec()); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/api/tools/usedSpaceBy.js: -------------------------------------------------------------------------------- 1 | const nodeModel = require('../../models/node'); 2 | 3 | /** 4 | * Calculates the total amount of space a user use in bytes 5 | * @param userid 6 | * @returns {Promise} 7 | */ 8 | module.exports = async userid => { 9 | 10 | if (_config.server.totalStorageLimitPerUser >= 0) { 11 | 12 | // Calculate current storage size 13 | let currentStorageSize = 0; 14 | const nodes = await nodeModel.find({owner: userid, type: 'file'}, 'size').exec(); 15 | for (let i = 0, n = nodes.length; i < n; i++) { 16 | currentStorageSize += nodes[i].size || 0; 17 | } 18 | 19 | return currentStorageSize; 20 | } 21 | 22 | return 0; 23 | }; 24 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const server = require('http').createServer(); 3 | const cors = require('cors'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | // Resolve storage path and create global config variable 8 | const config = require('../config/config'); 9 | config.server.storagePath = path.resolve(config.server.storagePath); 10 | 11 | // Freeze and store as global variable 12 | global._config = Object.freeze(config); 13 | 14 | // Create storage directory if not exists 15 | if (!fs.existsSync(config.server.storagePath)) { 16 | fs.mkdirSync(config.server.storagePath); 17 | } 18 | 19 | // Create app 20 | const app = express(); 21 | 22 | // Disable powered-by-message 23 | app.disable('x-powered-by'); 24 | 25 | // Allow CORS 26 | app.use(cors()); 27 | 28 | // GraphQL API Module 29 | app.use('/api', require('./api/api')); 30 | 31 | // Spawn Websocket 32 | require('./websocket').launch(server); 33 | 34 | // Add express http-server 35 | server.on('request', app); 36 | server.listen(config.server.port); 37 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | // Resolve correct host 4 | const host = process.env.NODE_ENV === 'production' ? 'mongo' : 'localhost'; 5 | 6 | // Connect to database 7 | mongoose.connect(`mongodb://${host}:27017/${_config.mongodb.databaseName}`, { 8 | useNewUrlParser: true 9 | }).catch(reason => { 10 | console.error(reason); // eslint-disable-line no-console 11 | process.exit(1); 12 | }); 13 | 14 | module.exports = mongoose; 15 | -------------------------------------------------------------------------------- /src/models/node.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('../db'); 2 | 3 | module.exports = mongoose.model('Node', { 4 | owner: String, // Owner id 5 | id: String, // Unique id of node 6 | parent: String, // Parent id 7 | lastModified: Number, // Last modified timestamp 8 | type: String, // 'dir' or 'file' 9 | name: String, // Folder / filename, 10 | marked: Boolean, // If marked 11 | bin: Boolean, // If moved to bin 12 | staticIds: [String], 13 | 14 | // ==== File specific === 15 | size: { 16 | required: false, 17 | type: Number 18 | }, 19 | 20 | // ==== Folder specific === 21 | color: { 22 | required: false, 23 | type: String // Hex color 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('../db'); 2 | 3 | module.exports = mongoose.model('User', { 4 | id: String, 5 | username: String, 6 | password: String, 7 | 8 | lastLoginAttempt: { 9 | type: Number, 10 | required: true, 11 | default: 0 12 | }, 13 | 14 | loginAttempts: { 15 | type: Number, 16 | required: true, 17 | default: 0 18 | }, 19 | 20 | apikeys: [{ 21 | key: String, 22 | expiry: Number 23 | }], 24 | 25 | settings: { 26 | type: Object, 27 | required: true, 28 | default: {} 29 | } 30 | }); 31 | 32 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const toBase61 = (() => { 2 | const base61Set = '0123456789abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWYXZ'; 3 | 4 | return num => { 5 | let result = ''; 6 | 7 | while (num > 0) { 8 | result = base61Set[num % 61] + result; 9 | num = Math.floor(num / 61); 10 | } 11 | 12 | return result; 13 | }; 14 | })(); 15 | 16 | module.exports = { 17 | 18 | pick(object, props) { 19 | const newObj = {}; 20 | 21 | for (let i = 0, l = props.length; i < l; i++) { 22 | const prop = props[i]; 23 | newObj[prop] = object[prop]; 24 | } 25 | 26 | return newObj; 27 | }, 28 | 29 | uid(length = _config.mongodb.defaultUIDLength) { 30 | let uid = ''; 31 | while (uid.length < length) { 32 | uid += toBase61(Date.now() * Math.floor(Math.random() * 1e15)); 33 | } 34 | return uid.substring(0, length); 35 | }, 36 | 37 | readableDuration(ms) { 38 | const types = ['millisecond', 'second', 'minute', 'hour', 'day']; 39 | const durations = [1, 1000, 60000, 3600000, 216000000, 5184000000]; 40 | for (let i = 0; i < durations.length - 1; i++) { 41 | if (ms < durations[i + 1]) { 42 | const v = Math.round(ms / durations[i]); 43 | return `${v} ${types[i] + (v > 1 ? 's' : '')}`; 44 | } 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/websocket.js: -------------------------------------------------------------------------------- 1 | const userModel = require('./models/user'); 2 | const userAgentParser = require('ua-parser-js'); 3 | const i18nCountries = require('i18n-iso-countries'); 4 | const redis = require('redis'); 5 | const redisClient = redis.createClient(process.env.NODE_ENV === 'production' ? {host: 'redis'} : undefined); 6 | const geoip = require('geoip-lite'); 7 | const WebSocket = require('ws'); 8 | 9 | // Clear redis db 10 | redisClient.flushall(); 11 | 12 | const userMap = {}; 13 | const websocket = { 14 | 15 | /** 16 | * Launches the websocket broadcast server 17 | * @param server 18 | */ 19 | launch(server) { 20 | const wss = new WebSocket.Server({server: server}); 21 | 22 | wss.on('connection', (ws, req) => { 23 | let user; 24 | 25 | ws.on('message', async message => { 26 | 27 | // Answer ping request 28 | if (message === '__ping__') { 29 | return ws.send('__pong__'); 30 | } 31 | 32 | // Try to parse message 33 | try { 34 | message = JSON.parse(message); 35 | } catch (ignored) { 36 | return; 37 | } 38 | 39 | const {type, value} = message; 40 | switch (type) { 41 | case 'register': { 42 | if (typeof value === 'string' && value) { 43 | user = await userModel.findOne({apikeys: {$elemMatch: {key: value}}}); 44 | 45 | if (!user) { 46 | return; 47 | } 48 | 49 | const userid = user.id; 50 | if (!(userid in userMap)) { 51 | userMap[userid] = { 52 | websockets: [], 53 | lastBroadcast: 0 54 | }; 55 | } else if (userMap[userid].websockets.includes(ws)) { 56 | return; 57 | } 58 | 59 | // Lookup ip info 60 | const lu = geoip.lookup(req.connection.remoteAddress); 61 | 62 | ws._sessionInfo = { 63 | id: Math.floor(Math.random() * 1e15).toString(16) + Date.now().toString(16), 64 | city: lu ? lu.city : 'Unknown', 65 | country: lu ? i18nCountries.getName(lu.country, 'en') : 'Unknown', 66 | device: userAgentParser(req.headers['user-agent']) 67 | }; 68 | 69 | // Push to redis 70 | redisClient.rpush(userid.toString(), JSON.stringify(ws._sessionInfo)); 71 | 72 | websocket.broadcast({ 73 | userid, 74 | data: { 75 | type: 'open-session', 76 | value: ws._sessionInfo 77 | } 78 | }); 79 | 80 | // Append websocket 81 | userMap[userid].websockets.push(ws); 82 | 83 | if (ws.readyState === 1) { 84 | 85 | // Approve registration 86 | ws.send(JSON.stringify({ 87 | type: 'registration-approval', 88 | value: { 89 | lastBroadcast: userMap[userid].lastBroadcast, 90 | sessions: await websocket.getSessionsBy(userid) 91 | } 92 | })); 93 | } 94 | } 95 | 96 | break; 97 | } 98 | case 'broadcast': { 99 | if (user) { 100 | const container = userMap[user.id]; 101 | 102 | // Broadcast message 103 | websocket.broadcast({ 104 | ignored: ws, 105 | userid: user.id, 106 | data: JSON.stringify({ 107 | type: 'broadcast', 108 | value 109 | }) 110 | }); 111 | 112 | // Update last broadcast timestamp 113 | container.lastBroadcast = Date.now(); 114 | } 115 | break; 116 | } 117 | } 118 | }); 119 | 120 | ws.on('close', () => { 121 | 122 | // Check if socket was registered 123 | if (user) { 124 | const {websockets} = userMap[user.id]; 125 | const idx = websockets.indexOf(ws); 126 | 127 | // Remove socket 128 | if (~idx) { 129 | const [socket] = websockets.splice(idx, 1); 130 | 131 | if (socket) { 132 | 133 | // Remove from redis list 134 | // TODO: Memory leak? 135 | redisClient.lrem(user.id.toString(), 1, JSON.stringify(socket._sessionInfo)); 136 | 137 | websocket.broadcast({ 138 | userid: user.id, 139 | data: { 140 | type: 'close-session', 141 | value: socket._sessionInfo 142 | } 143 | }); 144 | } 145 | } 146 | 147 | // Clean up if no connection is open anymore 148 | if (!websockets.length) { 149 | delete userMap[user.id]; 150 | } 151 | } 152 | }); 153 | }); 154 | }, 155 | 156 | /** 157 | * Returns the amount of currenty connected user 158 | * @param userid 159 | * @returns {number} 160 | */ 161 | async getSessionsBy(userid) { 162 | return new Promise(resolve => { 163 | 164 | // Resolve table 165 | redisClient.lrange(userid.toString(), 0, -1, (err, res) => { 166 | resolve(err ? [] : res.map(v => JSON.parse(v))); 167 | }); 168 | }); 169 | }, 170 | 171 | /** 172 | * Broadcast to all websockets from a single user 173 | */ 174 | broadcast({userid, data, ignored = null, preventBroadcast = false}) { 175 | const user = userMap[userid]; 176 | const websockets = ((user && user.websockets) || []); 177 | 178 | if (typeof data === 'object') { 179 | data = JSON.stringify(data); 180 | } 181 | 182 | for (let i = 0, l = websockets.length; i < l; i++) { 183 | const socket = websockets[i]; 184 | 185 | if (socket !== ignored && socket.readyState === 1) { 186 | socket.send(data); 187 | } 188 | } 189 | 190 | if (!preventBroadcast) { 191 | 192 | // Notify other instances 193 | process.send({ 194 | type: 'process:msg', 195 | data: { 196 | action: 'broadcast', 197 | userid, 198 | data 199 | } 200 | }); 201 | } 202 | } 203 | }; 204 | 205 | // Listen for other processes 206 | process.on('message', ({data}) => { 207 | switch (data.action) { 208 | case 'broadcast': { 209 | const {userid, data} = data; 210 | 211 | return websocket.broadcast({ 212 | userid, data, 213 | preventBroadcast: true 214 | }); 215 | } 216 | } 217 | }); 218 | 219 | /** 220 | * Returns the amount of currenty connected user 221 | * @param userid 222 | * @returns {number} 223 | */ 224 | module.exports = websocket; 225 | --------------------------------------------------------------------------------