├── .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 |
--------------------------------------------------------------------------------