├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── config │ └── types │ │ ├── note.json │ │ ├── page.json │ │ ├── test.json │ │ ├── todo.json │ │ └── user.json ├── controllers │ ├── api.ts │ ├── encryption.ts │ ├── storage.ts │ ├── types.ts │ └── users.ts ├── models │ ├── Type.ts │ └── User │ │ ├── index.ts │ │ ├── ipfs │ │ └── index.ts │ │ └── mongo │ │ ├── index.ts │ │ └── mongoose.ts ├── public │ └── .gitkeep ├── server.ts └── types │ ├── express-flash.d.ts │ ├── fbgraph.d.ts │ ├── global.d.ts │ ├── ipfs-api.d.ts │ ├── lusca.d.ts │ └── openpgp.d.ts ├── test └── api.test.ts ├── tsconfig.json └── tslint.json /.env.example: -------------------------------------------------------------------------------- 1 | PORT=5000 2 | 3 | TOKEN_SECRET=bFiZrRebiCYG9HLgzsLfDZRujxaCBUQRvcX#jcFUEoWwkscJ 4 | 5 | SESSION_EXPIRY=1d 6 | 7 | RSA_KEY_SIZE=1024 8 | 9 | PIN_DOCUMENTS=true 10 | 11 | # Uncomment this and comment out USER_STORAGE=ipfs to use MongoDB to store user data 12 | # USER_STORAGE=mongo 13 | # MONGODB_URI=mongodb://monguser:mongopass@localhost:27017/dcap 14 | 15 | USER_STORAGE=ipfs 16 | SERVER_KEY_PASS=MqM2bzKzNRZ% 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | .vscode 11 | 12 | pids 13 | logs 14 | results 15 | tmp 16 | 17 | #Build 18 | public/css/main.css 19 | 20 | # API keys and secrets 21 | .env 22 | src/config/*.key 23 | 24 | # Dependency directory 25 | node_modules 26 | bower_components 27 | 28 | # Editors 29 | .idea 30 | *.iml 31 | 32 | # OS metadata 33 | .DS_Store 34 | Thumbs.db 35 | 36 | # Ignore built ts files 37 | dist/**/* 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 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 | # DCAP 2 | 3 | ᴅᴇᴄᴇɴᴛʀᴀʟɪᴢᴇᴅ ᴄᴏɴᴛᴇɴᴛ ᴀɢɢʀᴇɢᴀᴛɪᴏɴ ᴩʟᴀᴛꜰᴏʀᴍ 4 | 5 | DCAP allows you to easily create and manage well-structured documents that are stored on IPFS. A simple user account system tied to a PGP keypair allows you to encrypt and decrypt your documents over an easy-to-use REST API. 6 | 7 | With DCAP you can create document types for things such as notes, todos, chat messages, web page data, social network updates and more. These documents will be stored on the decentralized IPFS network and pinned by the API to ensure accessibility. You can choose to store your documents in plaintext for all to see or encrypt them for privacy. DCAP's API makes it easier to create and retrieve these decentralized documents in your website and apps. 8 | 9 | ## Setup 10 | ### Requirements 11 | - Node.js 12 | 13 | ### Installation 14 | Clone this repository and run `npm install`. Copy the sample configuration file (`.env.example`) to `.env` and edit it to match your desired configuration (you should change the `TOKEN_SECRET` and `SERVER_KEY_PASS` values to different hard-to-guess passwords). Run `npm start` to start DCAP on `http://localhost:5000`. Please use this with SSL on production environments. 15 | 16 | ### Configuration 17 | The main configuration is loaded from `.env`. You can change the security behavior and whether DCAP should pin IPFS documents from these settings. 18 | 19 | In the `/src/config/types` directory are the type definitions that DCAP uses to validate the content you post. These must be written in [JSON Schema](http://json-schema.org) to be validated by DCAP. You can use the example types that come bundled with DCAP but will probably want to add some more attributes to make them useful. 20 | 21 | **Example type configuration** 22 | ``` 23 | { 24 | "$schema": "http://json-schema.org/draft-06/schema#", 25 | "title": "note", // Type name as referenced in API requests 26 | "description": "Simple note content type", 27 | "type": "object", 28 | "encrypted": true, // Set to false to make document publicly readable 29 | "properties": { 30 | "text": { 31 | "type": "string" 32 | }, 33 | "public": { 34 | "type": "string" 35 | } 36 | }, 37 | "public": [ // Array of properties that should appear in the public index 38 | "public" 39 | ], 40 | "required": [ // Required field names 41 | "text" 42 | ] 43 | } 44 | ``` 45 | 46 | **User Storage** 47 | 48 | Users are stored encrypted on IPFS. If you don't want public access to your list of usernames, you can use MongoDB as a user storage solution. Ensure a mongo daemon is running and in `.env` comment out `USER_STORAGE=ipfs` and uncomment `USER_STORAGE=mongo` and `MONGODB_URI` and point it to your mongo instance. 49 | 50 | ## API Reference 51 | 52 | [`POST /user/create`: Create New User](#create-new-user) 53 | 54 | [`POST /user/login`: Login User](#login-user) 55 | 56 | 🔑 [`POST /user/delete`: Delete Account](#delete-account) 57 | 58 | [`GET /type/:type`: Get Type Index](#get-type-index) 59 | 60 | [`GET /type/:type/schema`: Get Type Schema](#get-type-schema) 61 | 62 | 🔑 [`POST /type/:type`: Add New Document](#add-new-document) 63 | 64 | 🔑 [`POST /type/:type/:hash`: Get Decrypted Document](#get-decrypted-document) 65 | 66 | [`GET /document/:hash`: Get Unencrypted Document](#get-unencrypted-document) 67 | 68 | 🔑 [`PUT /type/:type/:hash`: Update Document](#update-document) 69 | 70 | 🔑 [`DELETE /type/:type/:hash`: Delete Document From Index](#delete-document-from-index) 71 | 72 | 73 | 🔑: Requires authentication token. This token (sent to you on login) can be passed to authorized endpoints via the `token` attribute in the request body, via a `token` query parameter, or with an `x-access-token` HTTP header. This token must be refreshed every 24 hours. 74 | 75 | ### Create New User 76 | `POST /user/create` 77 | 78 | Creates a new user and generates a PGP keypair. The public key will be stored in the database but you must keep your private key secure as it will be used to encrypt and decrypt your documents. 79 | 80 | **Request Body** 81 | ``` 82 | { 83 | "username": "testuser", 84 | "password": "abc123" 85 | } 86 | ``` 87 | 88 | **Success Response** 89 | ``` 90 | HTTP/1.1 200 OK 91 | { 92 | "success": "User created", 93 | "pub_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----....", 94 | "priv_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----..." 95 | } 96 | ``` 97 | 98 | **Error Responses** 99 | - `500` Key Creation Failed 100 | - `500` User Creation Failed 101 | 102 | 103 | ### Login User 104 | `POST /user/login` 105 | 106 | Logs user in and returns a JWT token to submit with authorized requests. 107 | 108 | **Request Body** 109 | ``` 110 | { 111 | "username": "testuser", 112 | "password": "abc123" 113 | } 114 | ``` 115 | 116 | **Success Response** 117 | ``` 118 | HTTP/1.1 200 OK 119 | { 120 | "success": "Login succeeded", 121 | "token": "eyJhbGciOi..." 122 | } 123 | ``` 124 | 125 | **Error Responses** 126 | - `401` User not found 127 | - `401` Wrong Password 128 | - `401` User Login Failed 129 | 130 | 131 | ### Delete Account 132 | 🔑 `POST /user/delete` 133 | 134 | Deletes your user account. Stored documents are not removed from their types. 135 | 136 | **Request Body** 137 | ``` 138 | { 139 | "token": "eyJhbGciOi...", // Token can also be sent as x-access-token header 140 | "password": "abc123" 141 | } 142 | ``` 143 | 144 | **Success Response** 145 | 146 | ``` 147 | HTTP/1.1 200 OK 148 | { 149 | "success": "User successfully deleted" 150 | } 151 | ``` 152 | 153 | **Error Responses** 154 | - `401` Password must be supplied 155 | - `401` Username not found in token 156 | - `401` Incorrect password 157 | - `403` User deletion failed 158 | 159 | 160 | ### Get Type Index 161 | `GET /type/:type[?filter=:filter]` 162 | 163 | Gets list of documents for a given type. You can also filter documents by username with the username query parameter. 164 | 165 | **URL Params** 166 | - `:type` Type name 167 | 168 | **Query Params** 169 | - `:filter`: Parameter to filter documents by. This can be `username`/`created`/`updated` or any parameters defined as public by the type's schema. 170 | 171 | **Success Response** 172 | 173 | ``` 174 | HTTP/1.1 200 OK 175 | { 176 | "documents": [ 177 | { 178 | "created": 1515884391549, 179 | "updated": 1515884391549, 180 | "username": "testuser", 181 | "link": { 182 | "/": "QmcxBLD2MCcqEw1q5L3xqxDiSMkYHnoe4LYLskTn7vhwci" 183 | } 184 | }, 185 | ... 186 | ], 187 | "hash": "QmQ5SBZnsAbZ1BEmsAZe8keBDbUwQMzuc2ZF7njYDE5Bum" // IFPS reference to this index 188 | } 189 | ``` 190 | 191 | **Error Responses** 192 | - `404` Type not found 193 | 194 | 195 | ### Get Type Schema 196 | `GET /type/:type/schema` 197 | 198 | Retrieve the schema specification for a given type. 199 | 200 | **URL Params** 201 | - `:type` Type name 202 | 203 | **Success Response** 204 | 205 | ``` 206 | HTTP/1.1 200 OK 207 | { 208 | "$schema": "http://json-schema.org/draft-06/schema#", 209 | "title": "note", 210 | ... 211 | } 212 | ``` 213 | 214 | **Error Responses** 215 | - `404` Type not found 216 | 217 | 218 | ### Add New Document 219 | 🔑 `POST /type/:type` 220 | 221 | Create a new document and store it to the type index. The document data must be passed in the body's `data` field. This data will be validated against the type's schema. If the type schema indicates the document should encrypted, a password and private key must be passed in the request body. 222 | 223 | **URL Params** 224 | - `:type` Type name 225 | 226 | **Request Body** 227 | ``` 228 | { 229 | "data": { "text": "foo bar"}, // Must match type schema 230 | "password": "abc123", // Required if type is encrypted 231 | "priv_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----..." // Required if type is encrypted 232 | } 233 | ``` 234 | 235 | **Success Response** 236 | 237 | ``` 238 | HTTP/1.1 200 OK 239 | { 240 | "success": "Document created", 241 | "hash": "QmaCSJeLMJYpbecr3Qft4w2CQQDmQo9w8Y5DPQmf9ptGVL" 242 | } 243 | ``` 244 | 245 | **Error Responses** 246 | - `401` Username not found in token 247 | - `404` Type does not exist 248 | - `500` Must supply data object in request body 249 | - `500` Private key not passed in request body 250 | - `500` Password not passed in request body 251 | - `500` Document already exists 252 | 253 | 254 | ### Get Decrypted Document 255 | 🔑 `POST /type/:type/:hash` 256 | 257 | Fetches IPFS document and decrypts it if encrypted (otherwise behaves the same as `GET /document/:hash`). 258 | 259 | **URL Params** 260 | - `:type` Type name 261 | - `:hash` Document hash 262 | 263 | **Request Body** 264 | ``` 265 | { 266 | "token": "eyJhbGciOi...", // Token can also be sent as x-access-token header 267 | "password": "abc123", // Required if type is encrypted 268 | "priv_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----..." // Required if type is encrypted 269 | } 270 | ``` 271 | 272 | **Success Response** 273 | 274 | ``` 275 | HTTP/1.1 200 OK 276 | { 277 | "text": "foo bar" 278 | } 279 | ``` 280 | 281 | **Error Responses** 282 | - `404` Type not found 283 | - `404` User not found 284 | - `401` Password and/or private key not included in request body 285 | 286 | 287 | ### Get Unencrypted Document 288 | `GET /document/:hash` 289 | 290 | Gets raw document by hash from IPFS. This is functionally equivalent to `ipfs cat ` 291 | 292 | **URL Params** 293 | - `:hash`: IPFS document hash 294 | 295 | **Success Response** 296 | 297 | ``` 298 | HTTP/1.1 200 OK 299 | "-----BEGIN PGP MESSAGE-----..." 300 | ``` 301 | 302 | 303 | ### Update Document 304 | 🔑 `PUT /type/:type/:hash` 305 | 306 | Update an existing document and replace its hash in the type index with the newly saved document. 307 | 308 | **URL Params** 309 | - `:type` Type name 310 | - `:hash` Document hash 311 | 312 | **Request Body** 313 | ``` 314 | { 315 | "username": "testuser", 316 | "password": "abc123" 317 | } 318 | ``` 319 | 320 | **Success Response** 321 | 322 | ``` 323 | HTTP/1.1 200 OK 324 | { 325 | "success": "Document updated", 326 | "hash": "QmaCSJeLMJYpbecf5Qft4w2CQQDmQo9w8Y5DPQmf9ptGw4..." 327 | } 328 | ``` 329 | 330 | **Error Responses** 331 | - `401` Username not found in token 332 | - `403` Invalid username for this document 333 | - `404` Document to update not found 334 | - `404` Type does not exist 335 | - `500` Must supply data document in request body 336 | - `500` Private key not passed in request body 337 | - `500` Password not passed in request body 338 | 339 | 340 | ### Delete Document From Index 341 | 🔑 `DELETE /type/:type/:hash` 342 | 343 | Removes document from type index. Does not remove the actual (immutable) IPFS document. 344 | 345 | **URL Params** 346 | - `:type` Type name 347 | - `:hash` Document hash 348 | 349 | **Success Response** 350 | 351 | ``` 352 | HTTP/1.1 200 OK 353 | { 354 | "success": "Document removed from type index", 355 | "hash": "QmcxBLD2MCcqEw1q5L3xqxDiSMkYHnoe4LYLskTn7vhwci" 356 | } 357 | ``` 358 | 359 | **Error Responses** 360 | - `401` Username not found in token 361 | - `403` Invalid username for this document 362 | - `404` Document to remove not found 363 | - `404` Type does not exist 364 | 365 | 366 | ## Contributing 367 | Contributions are welcome. Please see github issues for open bugs or feature requests, or to submit your own. DCAP has integration tests which should be run (`npm run test`) and updated for new code contributions. This project uses Typescript and all code should be accepted by Typescript's linter. 368 | 369 | ## License 370 | Copyright (c) 2018 Matt Crider. 371 | 372 | Licensed under the [MIT License](LICENSE). 373 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dcap-node", 3 | "version": "0.1.0", 4 | "description": "Convenient REST API for managing optionally encrypted decentralized documents hosted on IPFS", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/mcrider/dcap-node.git" 8 | }, 9 | "author": "Matthew Crider", 10 | "license": "MIT", 11 | "scripts": { 12 | "start": "npm run build && npm run watch", 13 | "build": "npm run build-ts && npm run tslint", 14 | "serve": "nodemon dist/server.js --watch src -e ts,js", 15 | "watch": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold,green.bold\" \"npm run watch-ts\" \"npm run serve\"", 16 | "test": "jest", 17 | "build-ts": "tsc", 18 | "watch-ts": "tsc -w", 19 | "tslint": "tslint -c tslint.json -p tsconfig.json", 20 | "debug": "npm run build && npm run watch-debug", 21 | "serve-debug": "nodemon --inspect dist/server.js", 22 | "watch-debug": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold,green.bold\" \"npm run watch-ts\" \"npm run serve-debug\"" 23 | }, 24 | "jest": { 25 | "globals": { 26 | "__TS_CONFIG__": "tsconfig.json" 27 | }, 28 | "moduleFileExtensions": [ 29 | "ts", 30 | "js" 31 | ], 32 | "transform": { 33 | "^.+\\.(ts|tsx)$": "./node_modules/ts-jest/preprocessor.js" 34 | }, 35 | "testMatch": [ 36 | "**/test/**/*.test.(ts|js)" 37 | ], 38 | "testEnvironment": "node" 39 | }, 40 | "dependencies": { 41 | "ajv": "^5.5.1", 42 | "async": "^2.1.2", 43 | "bcrypt": "^1.0.3", 44 | "bcrypt-nodejs": "^0.0.3", 45 | "body-parser": "^1.15.2", 46 | "compression": "^1.6.2", 47 | "cors": "^2.8.4", 48 | "dotenv": "^2.0.0", 49 | "errorhandler": "^1.4.3", 50 | "express": "^4.14.0", 51 | "express-flash": "^0.0.2", 52 | "express-session": "^1.14.2", 53 | "express-validator": "^3.1.3", 54 | "go-ipfs-dep": "^0.4.13", 55 | "ipfs": "^0.27.7", 56 | "ipfsd-ctl": "^0.27.1", 57 | "jsonwebtoken": "^8.1.0", 58 | "lodash": "^4.17.4", 59 | "lusca": "^1.4.1", 60 | "mongoose": "^5.0.0-rc0", 61 | "morgan": "^1.7.0", 62 | "openpgp": "^2.6.1", 63 | "request": "^2.78.0" 64 | }, 65 | "devDependencies": { 66 | "@types/ajv": "^1.0.0", 67 | "@types/async": "^2.0.40", 68 | "@types/bcrypt": "^1.0.0", 69 | "@types/bcrypt-nodejs": "0.0.30", 70 | "@types/body-parser": "^1.16.2", 71 | "@types/compression": "0.0.33", 72 | "@types/cors": "^2.8.3", 73 | "@types/dotenv": "^2.0.20", 74 | "@types/errorhandler": "0.0.30", 75 | "@types/express": "^4.0.35", 76 | "@types/express-session": "0.0.32", 77 | "@types/jest": "^19.2.2", 78 | "@types/jquery": "^2.0.41", 79 | "@types/jsonwebtoken": "^7.2.5", 80 | "@types/lodash": "^4.14.63", 81 | "@types/mongoose": "^4.7.31", 82 | "@types/morgan": "^1.7.32", 83 | "@types/node": "^7.0.12", 84 | "@types/nodemailer": "^1.3.32", 85 | "@types/passport": "^0.3.3", 86 | "@types/passport-facebook": "^2.1.3", 87 | "@types/request": "0.0.45", 88 | "@types/supertest": "^2.0.0", 89 | "concurrently": "^3.4.0", 90 | "frisby": "^2.0.11", 91 | "jest": "^19.0.2", 92 | "nodemon": "^1.11.0", 93 | "shelljs": "^0.7.7", 94 | "supertest": "^2.0.1", 95 | "ts-jest": "^19.0.8", 96 | "tslint": "^5.0.0", 97 | "typescript": "^2.4.0" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/config/types/note.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "note", 4 | "description": "Simple note content type", 5 | "type": "object", 6 | "encrypted": true, 7 | "properties": { 8 | "text": { 9 | "type": "string" 10 | } 11 | }, 12 | "required": [ 13 | "text" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/config/types/page.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "page", 4 | "description": "Simple page content type", 5 | "type": "object", 6 | "encrypted": false, 7 | "properties": { 8 | "title": { 9 | "type": "string" 10 | }, 11 | "contents": { 12 | "type": "string" 13 | } 14 | }, 15 | "required": [ 16 | "title", 17 | "contents" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/config/types/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "test", 4 | "description": "Test content type", 5 | "type": "object", 6 | "encrypted": true, 7 | "properties": { 8 | "text": { 9 | "type": "string" 10 | }, 11 | "public": { 12 | "type": "string" 13 | } 14 | }, 15 | "required": [ 16 | "text" 17 | ], 18 | "public": [ 19 | "public" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/config/types/todo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "todo", 4 | "description": "Simple todo content type", 5 | "type": "object", 6 | "encrypted": true, 7 | "properties": { 8 | "text": { 9 | "type": "string" 10 | }, 11 | "completed": { 12 | "type": "string" 13 | } 14 | }, 15 | "required": [ 16 | "text" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/config/types/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "user", 4 | "description": "Simple user content type", 5 | "type": "object", 6 | "encrypted": true, 7 | "properties": { 8 | "username": { 9 | "type": "string" 10 | }, 11 | "password": { 12 | "type": "string" 13 | }, 14 | "pub_key": { 15 | "type": "string" 16 | } 17 | }, 18 | "required": [ 19 | "username", 20 | "password", 21 | "pub_key" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/controllers/api.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request, NextFunction } from "express"; 2 | import * as Ajv from "ajv"; 3 | import * as jwt from "jsonwebtoken"; 4 | 5 | import * as storage from "./storage"; 6 | import * as types from "./types"; 7 | import * as users from "./users"; 8 | 9 | /** 10 | * GET / 11 | * Show API info page 12 | */ 13 | export let getRoot = (req: Request, res: Response) => { 14 | res.status(200).json({ 15 | message: "Welcome to dcap! Please visit https://github.com/mcrider/dcap-node for more information." 16 | }); 17 | }; 18 | 19 | /** 20 | * GET /type/{type} 21 | * Get type by name (shows index of documents) 22 | */ 23 | export let getType = async (req: Request, res: Response) => { 24 | const { status, response } = await types.getType(req.params.type, req.query); 25 | res.status(status).json(response); 26 | }; 27 | 28 | /** 29 | * GET /type/{type}/schema 30 | * Get type schema 31 | */ 32 | export let getTypeSchema = (req: Request, res: Response) => { 33 | const { status, response } = types.getTypeSchema(req.params.type); 34 | res.status(status).json(response); 35 | }; 36 | 37 | /** 38 | * GET /document/{hash} 39 | * Show document by hash 40 | */ 41 | export let getDocument = async (req: Request, res: Response) => { 42 | const data = await storage.getDocument(req.params.hash); 43 | if (data) { 44 | res.status(200).json(data); 45 | } else { 46 | res.status(404).json({ error: "IPFS document not found or innaccessible"}); 47 | } 48 | }; 49 | 50 | /** 51 | * POST /type/{type}/{hash} 52 | * Show document by hash (for encrypted documents) 53 | */ 54 | export let getTypeDocument = async (req: Request, res: Response) => { 55 | const { status, response } = await types.getEncryptedData(req.params.type, req.params.hash, req.body.priv_key, req.body.username, req.body.password); 56 | res.status(status).json(response); 57 | }; 58 | 59 | /** 60 | * POST /type/{type} 61 | * Add a new document 62 | */ 63 | export let addDocument = async (req: Request, res: Response) => { 64 | const { status, response } = await types.saveDocument(req.params.type, req.body.data, req.body.username, req.body.priv_key, req.body.password); 65 | res.status(status).json(response); 66 | }; 67 | 68 | /** 69 | * PUT /type/{type}/{hash} 70 | * Update an existing document 71 | */ 72 | export let updateDocument = async (req: Request, res: Response) => { 73 | const { status, response } = await types.saveDocument(req.params.type, req.body.data, req.body.username, req.body.priv_key, req.body.password, req.params.hash); 74 | res.status(status).json(response); 75 | }; 76 | 77 | /** 78 | * DELETE /type/{type}/{hash} 79 | * Remove an document from type index 80 | */ 81 | export let deleteDocument = async (req: Request, res: Response) => { 82 | const { status, response } = await types.deleteDocument(req.params.type, req.params.hash, req.body.username); 83 | res.status(status).json(response); 84 | }; 85 | 86 | /** 87 | * POST /user/create 88 | * Create a new user 89 | */ 90 | export let createUser = async (req: Request, res: Response) => { 91 | const { status, response } = await users.createUser(req.body.username, req.body.password); 92 | res.status(status).json(response); 93 | }; 94 | 95 | /** 96 | * DELETE /user 97 | * Remove your account 98 | */ 99 | export let deleteUser = async (req: Request, res: Response) => { 100 | const { status, response } = await users.deleteUser(req.body.username, req.body.password); 101 | res.status(status).json(response); 102 | }; 103 | 104 | /** 105 | * POST /user/login 106 | * Login user 107 | */ 108 | export let loginUser = async (req: Request, res: Response) => { 109 | const { status, response } = await users.loginUser(req.body.username, req.body.password); 110 | res.status(status).json(response); 111 | }; 112 | 113 | /** 114 | * Validate request token 115 | */ 116 | export let validateToken = async (req: Request, res: Response, next: Function) => { 117 | // check header or url parameters or post parameters for token 118 | const token = req.body.token || req.query.token || req.headers["x-access-token"]; 119 | 120 | if (!token) { 121 | return res.status(401).json({ error: "No token provided." }); 122 | } 123 | 124 | const decoded = await users.validateToken(token); 125 | if (decoded) { 126 | req.body.username = decoded.username; 127 | next(); 128 | } else { 129 | return res.status(401).json({ error: "Failed to authenticate token." }); 130 | } 131 | }; 132 | -------------------------------------------------------------------------------- /src/controllers/encryption.ts: -------------------------------------------------------------------------------- 1 | import * as openpgp from "openpgp"; 2 | 3 | /** 4 | * PGP-encrypt content 5 | */ 6 | export let encrypt = async (data: string, pubKey: string, privKey: string, password: string) => { 7 | const privKeyObj = openpgp.key.readArmored(privKey).keys[0]; 8 | privKeyObj.decrypt(password); 9 | 10 | const pgpOptions = { 11 | data: JSON.stringify(data), // input as String (or Uint8Array) 12 | publicKeys: openpgp.key.readArmored(pubKey).keys, // for encryption 13 | privateKeys: privKeyObj // for signing (optional) 14 | }; 15 | 16 | try { 17 | const encrypted = await openpgp.encrypt(pgpOptions); 18 | return encrypted.data; 19 | } catch (error) { 20 | console.error(error); 21 | return false; 22 | } 23 | }; 24 | 25 | /** 26 | * PGP-decrypt content 27 | */ 28 | export let decrypt = async (data: string, pubKey: string, privKey: string, password: string) => { 29 | const privKeyObj = openpgp.key.readArmored(privKey).keys[0]; 30 | privKeyObj.decrypt(password); 31 | 32 | const pgpOptions = { 33 | message: openpgp.message.readArmored(data), // parse armored message 34 | publicKeys: openpgp.key.readArmored(pubKey).keys, // for verification (optional) 35 | privateKey: privKeyObj // for decryption 36 | }; 37 | 38 | // let decrypted; 39 | try { 40 | const decrypted = await openpgp.decrypt(pgpOptions); 41 | return JSON.parse(decrypted.data); 42 | } catch (error) { 43 | console.error(error); 44 | return false; 45 | } 46 | }; 47 | 48 | /** 49 | * Generate a PGP keypair 50 | */ 51 | export let generateKeypair = async (username: string, password: string) => { 52 | const pgpOptions = { 53 | userIds: [{ name: username }], 54 | numBits: process.env.RSA_KEY_SIZE, 55 | passphrase: password 56 | }; 57 | 58 | let key; 59 | try { 60 | key = await openpgp.generateKey(pgpOptions); 61 | return key; 62 | } catch (error) { 63 | console.error(error); 64 | return false; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/controllers/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get IPFS Object 3 | * Return JSON 4 | */ 5 | export let getDocument = async (id: string) => { 6 | try { 7 | const data = await global.ipfsd.api.cat(id); 8 | const decoded = JSON.parse(data.toString("utf8")); 9 | 10 | return JSON.parse(data.toString("utf8")); 11 | } catch (error) { 12 | console.error(error); 13 | return false; 14 | } 15 | }; 16 | 17 | /** 18 | * Save IPFS Object 19 | */ 20 | export let saveDocument = async (data: Object) => { 21 | const res = await global.ipfsd.api.util.addFromStream(new Buffer(JSON.stringify(data), "utf8")); 22 | const document = res[0]; 23 | 24 | // Pin to server 25 | if (process.env.PIN_DOCUMENTS === true) { 26 | global.ipfsd.api.pin.add(document.hash); 27 | } 28 | 29 | return document; 30 | }; 31 | 32 | -------------------------------------------------------------------------------- /src/controllers/types.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as Ajv from "ajv"; 3 | 4 | import Type from "../models/Type"; 5 | import User from "../models/User/index"; 6 | import * as storage from "./storage"; 7 | import * as encryption from "./encryption"; 8 | 9 | const typesDir = "./src/config/types/"; 10 | 11 | interface Link { 12 | "/": string; 13 | } 14 | interface TypeDocument { 15 | link: Link; 16 | } 17 | 18 | 19 | /** 20 | * Load types from local JSON files 21 | * Validates type schema and creates index hash if needed 22 | */ 23 | export let loadTypes = () => { 24 | const typeSchemas = new Map(); 25 | 26 | const files = fs.readdirSync(typesDir); 27 | 28 | for (const file of files) { 29 | const data = fs.readFileSync(typesDir + file); 30 | const schema = JSON.parse(data.toString("utf8")); 31 | 32 | // Validate schema 33 | if (schema.title !== file.split(".")[0]) { 34 | throw new SyntaxError("Type config file name must match title attribute"); 35 | } 36 | 37 | // Throw an error if schema is invalid 38 | const ajv = new Ajv(); 39 | if (!ajv.validateSchema(schema)) { 40 | throw new SyntaxError(`Type schema "${schema.title}" is an invalid JSON Schema`); 41 | } 42 | 43 | const type = new Type(schema); 44 | typeSchemas.set(type.title, type); 45 | } 46 | 47 | global.dcap.typeSchemas = typeSchemas; 48 | 49 | checkTypeHashes(); 50 | }; 51 | 52 | 53 | /** 54 | * Get type by name (shows index of documents) 55 | */ 56 | export let getType = async (typeName: string, filter?: object) => { 57 | const type = global.dcap.typeSchemas.get(typeName); 58 | if (type) { 59 | const response = await storage.getDocument(type.hash); 60 | 61 | if (filter && Object.keys(filter).length) { 62 | const field = Object.keys(filter)[0]; 63 | const value = filter[field]; 64 | // Filter by query 65 | response.documents = response.documents.filter(filteredDocument => filteredDocument[field] ? (filteredDocument[field] === value) : false); 66 | } 67 | 68 | response.hash = type.hash; 69 | return { status: 200, response: response }; 70 | } else { 71 | return { status: 404, response: { error: "Type not found" } }; 72 | } 73 | }; 74 | 75 | 76 | /** 77 | * Get type schema from memory 78 | */ 79 | export let getTypeSchema = (typeName: string) => { 80 | const type = global.dcap.typeSchemas.get(typeName); 81 | if (type) { 82 | return { status: 200, response: type.schema }; 83 | } else { 84 | return { status: 404, response: { error: "Type not found" } }; 85 | } 86 | }; 87 | 88 | 89 | /** 90 | * Check that all types have associated IPFS hashes, or else create and save them 91 | */ 92 | export let checkTypeHashes = async () => { 93 | for (const [key, type] of global.dcap.typeSchemas) { 94 | if (!type.hash) { 95 | // Save empty array to IPFS to get the type listing's initial hash 96 | updateTypeIndex(type, { documents: [] }); 97 | } 98 | } 99 | }; 100 | 101 | 102 | /** 103 | * Save the typeIndex and save new hash to config file 104 | */ 105 | export let updateTypeIndex = async (type: Type, typeIndex: Object) => { 106 | const document = await storage.saveDocument(typeIndex); 107 | 108 | const hash = document.hash; 109 | 110 | const schema = type.schema; 111 | schema.hash = hash; 112 | 113 | try { 114 | fs.writeFileSync(typesDir + type.title + ".json", JSON.stringify(schema, undefined, 2)); 115 | } catch (err) { 116 | console.error(err); 117 | } 118 | }; 119 | 120 | 121 | /** 122 | * Fetch and decrypt document by hash (for encrypted documents) 123 | */ 124 | export let getEncryptedData = async (typeName: string, hash: string, privKey: string, username: string, password: string) => { 125 | const type = global.dcap.typeSchemas.get(typeName); 126 | if (!type) { 127 | return { status: 404, response: { error: "Type not found" } }; 128 | } 129 | 130 | const user = new User(username, password); 131 | const userData = await user.fetch(); 132 | if (!userData) { 133 | return { status: 404, response: { error: "User not found" } }; 134 | } 135 | 136 | if (type.schema.encrypted && (!privKey || !password)) { 137 | return { status: 401, response: { error: "Password and/or private key not included in request body" } }; 138 | } else if (type.schema.encrypted) { 139 | const data = await storage.getDocument(hash); 140 | const decrypted = await encryption.decrypt(data, userData.pub_key, privKey, password); 141 | return { status: 200, response: decrypted }; 142 | } else { 143 | const data = await storage.getDocument(hash); 144 | } 145 | }; 146 | 147 | 148 | /** 149 | * Save an document to IPFS and return its hash 150 | */ 151 | export let saveDocument = async (typeName: string, data: any, username: string, privKey?: string, password?: string, hash?: string) => { 152 | const type = global.dcap.typeSchemas.get(typeName); 153 | 154 | // Check that type in URL exists 155 | if (!type) { 156 | return { status: 404, response: { error: `Type "${typeName}" does not exist` } }; 157 | } 158 | 159 | if (!data) { 160 | return { status: 500, response: { error: "Must supply data in request body" } }; 161 | } 162 | 163 | if (!username) { 164 | return { status: 401, response: { error: "Username not found in token" } }; 165 | } 166 | 167 | // Validate against schema 168 | const ajv = new Ajv(); 169 | const valid = ajv.validate(type.schema, data); 170 | if (!valid) { 171 | return { status: 500, response: { error: ajv.errorsText() } }; 172 | } 173 | 174 | // If document is supposed to be encrypted, do so 175 | // Fail if private key and password not passed to request 176 | let documentData = data; 177 | if (type.schema.encrypted) { 178 | if (!privKey) { 179 | return { status: 500, response: { error: "Private key not passed in request body" } }; 180 | } 181 | 182 | if (!password) { 183 | return { status: 500, response: { error: "Password not passed in request body" } }; 184 | } 185 | 186 | let userData; 187 | try { 188 | const user = new User(username, password); 189 | userData = await user.fetch(); 190 | } catch (error) { 191 | console.error(error); 192 | return { status: 500, response: { error: "Error getting user data" } }; 193 | } 194 | 195 | documentData = await encryption.encrypt(data, userData.pub_key ? userData.pub_key : false, privKey, password); 196 | } 197 | 198 | // Save to IPFS 199 | const document = await storage.saveDocument(documentData); 200 | 201 | // If not already in there, save to type index 202 | const typeIndex = await storage.getDocument(type.hash); 203 | let exists = false; 204 | let hashIndex = -1; 205 | typeIndex.documents.forEach((typeDocument: TypeDocument, index: number) => { 206 | if (typeDocument && typeDocument.link["/"] == document.hash) { 207 | exists = true; 208 | } 209 | 210 | if (hash && typeDocument && typeDocument.link["/"] == hash) { 211 | hashIndex = index; 212 | } 213 | }); 214 | 215 | if (exists) { 216 | return { status: 500, response: { error: "Document already exists" } }; 217 | } else if (hash) { 218 | // Replace existing hash 219 | if (hashIndex < 0) { 220 | return { status: 404, response: { success: "Document to update not found" } }; 221 | } else if (typeIndex.documents[hashIndex].username !== username) { 222 | return { status: 403, response: { error: "Invalid username for this document" } }; 223 | } else { 224 | const created = typeIndex.documents[hashIndex].created; 225 | const indexItem = { 226 | "created": created, 227 | "updated": Date.now(), 228 | "username": username, 229 | "link": {"/": document.hash }, 230 | }; 231 | // Add public items to index 232 | if (type.schema.public) { 233 | type.schema.public.forEach((item) => { 234 | indexItem[item] = data[item]; 235 | }); 236 | } 237 | typeIndex.documents[hashIndex] = indexItem; 238 | updateTypeIndex(type, typeIndex); 239 | return { status: 200, response: { success: "Document updated", hash: document.hash } }; 240 | } 241 | } else { 242 | const indexItem = { 243 | "created": Date.now(), 244 | "updated": Date.now(), 245 | "username": username, 246 | "link": {"/": document.hash }, 247 | }; 248 | // Add public items to index 249 | if (type.schema.public) { 250 | type.schema.public.forEach((item) => { 251 | indexItem[item] = data[item]; 252 | }); 253 | } 254 | typeIndex.documents.push(indexItem); 255 | updateTypeIndex(type, typeIndex); 256 | return { status: 200, response: { success: "Document created", hash: document.hash } }; 257 | } 258 | }; 259 | 260 | 261 | /** 262 | * Remove an document from a type index (does not delete the actual IPFS doc though) 263 | */ 264 | export let deleteDocument = async (typeName: string, hash: string, username: string) => { 265 | const type = global.dcap.typeSchemas.get(typeName); 266 | 267 | // Check that type in URL exists 268 | if (!type) { 269 | return { status: 404, response: { error: `Type "${typeName}" does not exist` } }; 270 | } 271 | 272 | if (!username) { 273 | return { status: 401, response: { error: "Username not found in token" } }; 274 | } 275 | 276 | const typeIndex = await storage.getDocument(type.hash); 277 | let hashIndex = -1; 278 | typeIndex.documents.forEach((typeDocument: TypeDocument, index: number) => { 279 | if (typeDocument && typeDocument.link["/"] === hash) { 280 | hashIndex = index; 281 | } 282 | }); 283 | 284 | if (hashIndex < 0) { 285 | return { status: 404, response: { error: "Document to remove not found", hash: hash } }; 286 | } else { 287 | if (typeIndex.documents[hashIndex].username !== username) { 288 | return { status: 403, response: { error: "Invalid username for this document" } }; 289 | } 290 | typeIndex.documents.splice(hashIndex, 1); 291 | updateTypeIndex(type, typeIndex); 292 | return { status: 200, response: { success: "Document removed from type index", hash: hash } }; 293 | } 294 | }; 295 | -------------------------------------------------------------------------------- /src/controllers/users.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as jwt from "jsonwebtoken"; 3 | import * as encryption from "./encryption"; 4 | import User from "../models/User/index"; 5 | 6 | 7 | /** 8 | * Create a new user 9 | */ 10 | export let createUser = async (username: string, password: string) => { 11 | const user = new User(username, password); 12 | 13 | if (await user.fetch()) { 14 | return { status: 401, response: { error: "User already exists" } }; 15 | } 16 | 17 | // Generate PGP keypair 18 | const key = await encryption.generateKeypair(username, password); 19 | if (!key) { 20 | return { status: 500, response: { error: "Key creation failed" } }; 21 | } 22 | 23 | try { 24 | const userData = await user.create(key.publicKeyArmored); 25 | return { 26 | status: 200, 27 | response: { 28 | success: "User created", 29 | pub_key: key.publicKeyArmored, 30 | priv_key: key.privateKeyArmored 31 | } 32 | }; 33 | } catch (error) { 34 | return { status: 500, response: { error: `User creation failed: ${error.code}` } }; 35 | } 36 | }; 37 | 38 | /** 39 | * Login user and get session key 40 | */ 41 | export let loginUser = async (username: string, password: string) => { 42 | try { 43 | const user = new User(username, password); 44 | 45 | const userData = await user.fetch(); 46 | if (!user.fetch()) { 47 | return { status: 401, response: { error: "Authentication failed. User not found." } }; 48 | } 49 | 50 | const valid = await user.checkPassword(); 51 | if (!valid) { 52 | return { status: 401, response: { error: "Authentication failed. Wrong Password." } }; 53 | } 54 | 55 | const payload = { 56 | username: username 57 | }; 58 | 59 | const token = jwt.sign(payload, process.env.TOKEN_SECRET, { 60 | expiresIn: process.env.SESSION_EXPIRY 61 | }); 62 | 63 | return { status: 200, response: { success: "Login succeeded", token: token } }; 64 | } catch (error) { 65 | return { status: 403, response: { error: "User login failed" } }; 66 | } 67 | }; 68 | 69 | /** 70 | * Remove own user account 71 | */ 72 | export let deleteUser = async (username: string, password: string) => { 73 | if (!password) { 74 | return { status: 401, response: { error: "Password must be supplied" } }; 75 | } 76 | 77 | if (!username) { 78 | return { status: 401, response: { error: "Username not found in token" } }; 79 | } 80 | 81 | try { 82 | const user = new User(username, password); 83 | 84 | const userData = await user.fetch(); 85 | if (!userData) { 86 | return { status: 401, response: { error: "Authentication failed. User not found." } }; 87 | } 88 | 89 | const valid = await user.checkPassword(); 90 | if (!valid) { 91 | return { status: 401, response: { error: "Authentication failed. Wrong Password." } }; 92 | } 93 | 94 | const deleted = await user.delete(); 95 | if (!deleted) { 96 | return { status: 403, response: { error: "User deletion failed" } }; 97 | } else { 98 | return { status: 200, response: { success: "User successfully deleted" } }; 99 | } 100 | } catch (error) { 101 | return { status: 403, response: { error: "User deletion failed" } }; 102 | } 103 | }; 104 | 105 | /** 106 | * Validate request token 107 | */ 108 | export let validateToken = async (token: string) => { 109 | if (!token) { 110 | return false; 111 | } 112 | 113 | try { 114 | const decoded = jwt.verify(token, process.env.TOKEN_SECRET); 115 | return decoded; 116 | } catch (err) { 117 | return false; 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /src/models/Type.ts: -------------------------------------------------------------------------------- 1 | interface TypeSchema { 2 | $schema: string; 3 | title: string; 4 | description?: string; 5 | type: string; 6 | properties: Object; 7 | required: Array; 8 | hash?: string; 9 | } 10 | 11 | export default class Type { 12 | private _schema: TypeSchema; 13 | 14 | constructor(schema: TypeSchema) { 15 | if (!schema.title) { 16 | throw new SyntaxError("Type schema must have a title attribute"); 17 | } 18 | 19 | this._schema = schema; 20 | } 21 | 22 | get schema() { 23 | return this._schema; 24 | } 25 | 26 | get title() { 27 | return this._schema.title; 28 | } 29 | 30 | get hash() { 31 | return this._schema.hash; 32 | } 33 | 34 | set hash(hash: string) { 35 | this._schema.hash = hash; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/models/User/index.ts: -------------------------------------------------------------------------------- 1 | import * as MongoUser from "./mongo/index"; 2 | import * as IpfsUser from "./ipfs/index"; 3 | 4 | /** 5 | * This is a proxy class to various user storage implementations 6 | */ 7 | export default class User { 8 | private _username: string; 9 | private _password: string; 10 | private _userHandler: any; 11 | 12 | constructor(username: string, password: string) { 13 | this._username = username; 14 | this._password = password; 15 | 16 | // TODO set based on env config 17 | if (process.env.USER_STORAGE === "mongo") { 18 | this._userHandler = MongoUser; 19 | } else { 20 | this._userHandler = IpfsUser; 21 | } 22 | } 23 | 24 | /** 25 | * Fetch all stored user data 26 | */ 27 | async fetch() { 28 | if (!this._userHandler.fetchUser) { 29 | console.error("fetchUser not implemented in child"); 30 | } 31 | 32 | return this._userHandler.fetchUser.apply(undefined, [this._username]); 33 | } 34 | 35 | /** 36 | * Create new user record 37 | */ 38 | async create(pubKey: string) { 39 | if (!this._userHandler.createUser) { 40 | throw new Error("createUser not implemented in child"); 41 | } 42 | 43 | return this._userHandler.createUser.apply(undefined, [this._username, this._password, pubKey]); 44 | } 45 | 46 | /** 47 | * Validate password against user record 48 | */ 49 | async checkPassword() { 50 | if (!this._userHandler.checkPassword) { 51 | console.error("checkPassword not implemented in child"); 52 | } 53 | 54 | return this._userHandler.checkPassword.apply(undefined, [this._username, this._password]); 55 | } 56 | 57 | /** 58 | * Delete user record 59 | */ 60 | async delete() { 61 | if (!this._userHandler.deleteUser) { 62 | console.error("deleteUser not implemented in child"); 63 | } 64 | 65 | return this._userHandler.deleteUser.apply(undefined, [this._username, this._password]); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/models/User/ipfs/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as bcrypt from "bcrypt"; 3 | 4 | import * as storage from "../../../controllers/storage"; 5 | import * as types from "../../../controllers/types"; 6 | import * as encryption from "../../../controllers/encryption"; 7 | 8 | const configDir = "./src/config/"; 9 | 10 | const getServerKeypair = async () => { 11 | // TODO: Get from disk, or else generate and save to disk 12 | let publicKey; 13 | try { 14 | publicKey = fs.readFileSync(`${configDir}/public.key`, "utf8"); 15 | } catch (err) { 16 | if (err.code === "ENOENT") { 17 | publicKey = false; 18 | } else { 19 | throw err; 20 | } 21 | } 22 | 23 | let privateKey; 24 | try { 25 | privateKey = fs.readFileSync(`${configDir}/private.key`, "utf8"); 26 | } catch (err) { 27 | if (err.code === "ENOENT") { 28 | privateKey = false; 29 | } else { 30 | throw err; 31 | } 32 | } 33 | 34 | if (!publicKey || !privateKey) { 35 | const key = await encryption.generateKeypair("dcap", process.env.SERVER_KEY_PASS); 36 | publicKey = key.publicKeyArmored; 37 | privateKey = key.privateKeyArmored; 38 | fs.writeFileSync(`${configDir}/public.key`, publicKey); 39 | fs.writeFileSync(`${configDir}/private.key`, privateKey); 40 | } 41 | 42 | return { pubKey: publicKey, privKey: privateKey, password: process.env.SERVER_KEY_PASS }; 43 | }; 44 | 45 | export let createUser = async (username: string, password: string, pubKey: string) => { 46 | const serverKeypair = await getServerKeypair(); 47 | 48 | const salt = await bcrypt.genSalt(12); 49 | const hash = await bcrypt.hash(password, salt); 50 | const userData = { username, password: hash, pub_key: pubKey }; 51 | 52 | const type = global.dcap.typeSchemas.get("user"); 53 | if (!type) { 54 | return false; 55 | } 56 | 57 | const data = await encryption.encrypt(JSON.stringify(userData), serverKeypair.pubKey, serverKeypair.privKey, serverKeypair.password); 58 | 59 | // Save to IPFS 60 | const document = await storage.saveDocument(data); 61 | 62 | // Update type index with new user 63 | const typeIndex = await storage.getDocument(type.hash); 64 | typeIndex.documents.push({ 65 | "created": Date.now(), 66 | "updated": Date.now(), 67 | "username": username, 68 | "link": {"/": document.hash }, 69 | }); 70 | types.updateTypeIndex(type, typeIndex); 71 | return true; 72 | }; 73 | 74 | export let fetchUser = async (username: string) => { 75 | const index = await types.getType("user", { username: username}); 76 | if (!index.response.documents.length) { 77 | return false; 78 | } 79 | const document = index.response.documents[0]; 80 | 81 | const data = await storage.getDocument(document.link["/"]); 82 | 83 | const serverKeypair = await getServerKeypair(); 84 | const decrypted = await encryption.decrypt(data, serverKeypair.pubKey, serverKeypair.privKey, serverKeypair.password); 85 | 86 | return JSON.parse(decrypted); 87 | }; 88 | 89 | export let checkPassword = async (username, password: string) => { 90 | const user = await fetchUser(username); 91 | 92 | let isValid; 93 | try { 94 | isValid = await bcrypt.compare(password, user.password); 95 | } catch (err) { 96 | console.error(err); 97 | return false; 98 | } 99 | return isValid; 100 | }; 101 | 102 | export let deleteUser = async (username: string) => { 103 | const index = await types.getType("user", { username: username}); 104 | const document = index.response.documents[0]; 105 | if (!document) { 106 | return false; 107 | } 108 | 109 | const data = await storage.getDocument(document.link["/"]); 110 | const result = await types.deleteDocument("user", document.link["/"], username); 111 | return result.status === 200; 112 | }; 113 | 114 | -------------------------------------------------------------------------------- /src/models/User/mongo/index.ts: -------------------------------------------------------------------------------- 1 | import { MongooseUser } from "./mongoose"; 2 | 3 | export let createUser = async (username: string, password: string, pubKey: string) => { 4 | const user = new MongooseUser({ 5 | username: username, 6 | password: password, 7 | pub_key: pubKey 8 | }); 9 | 10 | return user.save(); 11 | }; 12 | 13 | export let fetchUser = async (username: string) => { 14 | return await MongooseUser.findOne({ username: username }); 15 | }; 16 | 17 | export let checkPassword = async (username, password: string) => { 18 | const user = await fetchUser(username); 19 | 20 | if (!user) { 21 | return false; 22 | } 23 | 24 | return await user.comparePassword(password); 25 | }; 26 | 27 | export let deleteUser = async (username: string, password: string) => { 28 | const user = await fetchUser(username); 29 | if (!user) { 30 | return false; 31 | } 32 | 33 | try { 34 | return await user.remove(); 35 | } catch (error) { 36 | console.error(error); 37 | return false; 38 | } 39 | }; 40 | 41 | -------------------------------------------------------------------------------- /src/models/User/mongo/mongoose.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from "mongoose"; 2 | import * as bcrypt from "bcrypt"; 3 | 4 | const SALT_WORK_FACTOR = 12; 5 | 6 | if (process.env.MONGODB_URI) { 7 | mongoose.connect(process.env.MONGODB_URI, (err) => { 8 | if (err) { 9 | throw err; 10 | } 11 | }); 12 | } 13 | 14 | export interface IMongooseUser extends mongoose.Document { 15 | username: string; 16 | password: string; 17 | pub_key: string; 18 | comparePassword: Function; 19 | } 20 | 21 | const schema = new mongoose.Schema({ 22 | username: { 23 | type: String, 24 | required: true, 25 | index: { unique: true } 26 | }, 27 | password: { 28 | type: String, 29 | required: true 30 | }, 31 | pub_key: { 32 | type: String 33 | } 34 | }); 35 | 36 | schema.pre("save", async function (next) { 37 | // Hash the user's password before saving 38 | const salt = await bcrypt.genSalt(12); 39 | const hash = await bcrypt.hash(this.password, salt); 40 | 41 | this.password = hash; 42 | next(); 43 | }); 44 | 45 | schema.methods.comparePassword = async function (candidatePassword: string) { 46 | return await bcrypt.compare(candidatePassword, this.password); 47 | }; 48 | 49 | export const MongooseUser = mongoose.model("Users", schema); 50 | -------------------------------------------------------------------------------- /src/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcrider/dcap-node/4cb16159854b8c005a9c247d7631f4a28f45dfef/src/public/.gitkeep -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | import * as express from "express"; 5 | import * as compression from "compression"; // compresses requests 6 | import * as session from "express-session"; 7 | import * as bodyParser from "body-parser"; 8 | import * as cors from "cors"; 9 | import * as logger from "morgan"; 10 | import * as errorHandler from "errorhandler"; 11 | import * as lusca from "lusca"; 12 | import * as dotenv from "dotenv"; 13 | import * as flash from "express-flash"; 14 | import * as path from "path"; 15 | import expressValidator = require("express-validator"); 16 | import daemonFactory = require("ipfsd-ctl"); 17 | 18 | 19 | /** 20 | * Load environment variables from .env file, where API keys and passwords are configured. 21 | */ 22 | dotenv.config({ path: ".env" }); 23 | 24 | 25 | /** 26 | * Controllers 27 | */ 28 | import * as apiController from "./controllers/api"; 29 | import * as types from "./controllers/types"; 30 | 31 | 32 | /** 33 | * Create Express server. 34 | */ 35 | const app = express(); 36 | 37 | /** 38 | * Express configuration. 39 | */ 40 | app.set("port", process.env.PORT || 3000); 41 | app.use(compression()); 42 | app.use(cors()); 43 | app.use(logger("dev")); 44 | app.use(bodyParser.json()); 45 | app.use(bodyParser.urlencoded({ extended: true })); 46 | app.use(expressValidator()); 47 | app.use(flash()); 48 | app.use(lusca.xframe("SAMEORIGIN")); 49 | app.use(lusca.xssProtection(true)); 50 | app.use(express.static(path.join(__dirname, "public"), { maxAge: 31557600000 })); 51 | 52 | /** 53 | * API routes. 54 | */ 55 | app.get("/", apiController.getRoot); 56 | app.post("/user/create", apiController.createUser); 57 | app.post("/user/login", apiController.loginUser); 58 | app.post("/user/delete", apiController.validateToken, apiController.deleteUser); 59 | app.get("/type/:type", apiController.getType); 60 | app.get("/type/:type/schema", apiController.getTypeSchema); 61 | app.post("/type/:type", apiController.validateToken, apiController.addDocument); 62 | app.post("/type/:type/:hash", apiController.validateToken, apiController.getTypeDocument); 63 | app.put("/type/:type/:hash", apiController.validateToken, apiController.updateDocument); 64 | app.delete("/type/:type/:hash", apiController.validateToken, apiController.deleteDocument); 65 | app.get("/document/:hash", apiController.getDocument); 66 | 67 | 68 | /** 69 | * Error Handler. Provides full stack - remove for production 70 | */ 71 | app.use(errorHandler()); 72 | process.on("unhandledRejection", r => console.log(r)); 73 | process.on("exit", function () { 74 | if (global.ipfsd) { 75 | global.ipfsd.stop(); 76 | } 77 | }); 78 | 79 | 80 | /** 81 | * Start IPFS daemon then Express server. 82 | */ 83 | daemonFactory 84 | .create({ type: "go" }) 85 | .spawn({ disposable: false }, (err, ipfsd) => { 86 | if (err) { 87 | throw err; 88 | } 89 | 90 | ipfsd.init((_) => { 91 | ipfsd.start((_) => { 92 | global.ipfsd = ipfsd; 93 | global.dcap = {}; 94 | types.loadTypes(); 95 | 96 | app.listen(app.get("port"), () => { 97 | console.log("--------------------------------------------------------------"); 98 | console.log(("| App is running at http://localhost:%d in %s mode"), app.get("port"), app.get("env")); 99 | console.log("| Press CTRL-C to stop"); 100 | console.log("--------------------------------------------------------------\n"); 101 | }); 102 | }); 103 | }); 104 | }); 105 | 106 | module.exports = app; 107 | -------------------------------------------------------------------------------- /src/types/express-flash.d.ts: -------------------------------------------------------------------------------- 1 | 2 | /// 3 | 4 | // Add RequestValidation Interface on to Express's Request Interface. 5 | declare namespace Express { 6 | interface Request extends Flash {} 7 | } 8 | 9 | interface Flash { 10 | flash(type: string, message: any): void; 11 | } 12 | 13 | declare module "express-flash"; 14 | 15 | -------------------------------------------------------------------------------- /src/types/fbgraph.d.ts: -------------------------------------------------------------------------------- 1 | /** Declaration file generated by dts-gen */ 2 | 3 | export const version: string; 4 | 5 | export function authorize(params: any, callback: any): any; 6 | 7 | export function batch(reqs: any, additionalData: any, callback: any): any; 8 | 9 | export function del(url: any, postData: any, callback: any): any; 10 | 11 | export function extendAccessToken(params: any, callback: any): any; 12 | 13 | export function fql(query: any, params: any, callback: any): any; 14 | 15 | export function get(url: any, params?: any, callback?: any): any; 16 | 17 | export function getAccessToken(): any; 18 | 19 | export function getAppSecret(): any; 20 | 21 | export function getGraphUrl(): any; 22 | 23 | export function getOauthUrl(params: any, opts: any): any; 24 | 25 | export function getOptions(): any; 26 | 27 | export function post(url: any, postData: any, callback: any): any; 28 | 29 | export function search(options: any, callback: any): any; 30 | 31 | export function setAccessToken(token: any): any; 32 | 33 | export function setAppSecret(token: any): any; 34 | 35 | export function setGraphUrl(url: any): any; 36 | 37 | export function setOptions(options: any): any; 38 | 39 | export function setVersion(version: any): any; 40 | 41 | /** 42 | * Fairly incomplete. I only added some commonly used fields. 43 | */ 44 | export type FacebookUser = { 45 | id: string, 46 | name: string, 47 | email: string, 48 | first_name: string, 49 | last_name: string, 50 | gender: string, 51 | link: string, 52 | locale: string, 53 | timezone: number 54 | }; 55 | 56 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Global { 3 | dcap: any; 4 | ipfsd: any; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/types/ipfs-api.d.ts: -------------------------------------------------------------------------------- 1 | export as namespace IpfsAPI; 2 | export = ipfsAPI; 3 | declare function ipfsAPI(hostOrMultiaddr: string, port?: string, opts?: any): any; 4 | declare function ipfsAPI(hostOrMultiaddr: any): any; 5 | 6 | declare namespace ipfsAPI { 7 | function version(callback: (err: string, version: string) => void): any; 8 | function ping(): any; 9 | function ls(): any; 10 | 11 | export interface bitswap { 12 | 13 | } 14 | export interface block { 15 | 16 | } 17 | export interface bootstrap { 18 | 19 | } 20 | export interface files { 21 | // ls(directory:string, callback:(error,result) => void); 22 | // read(file:string, {offset,count}:{offset?:number,count?: number}, callback:(err,stream:NodeJS.ReadableStream) => void); 23 | } 24 | export interface dht { 25 | 26 | } 27 | export interface config { 28 | 29 | } 30 | export interface log { 31 | 32 | } 33 | export interface repo { 34 | 35 | } 36 | export interface swarm { 37 | 38 | } 39 | export interface refs { 40 | 41 | } 42 | export interface pin { 43 | // add() 44 | // rm(); 45 | // ls(); 46 | 47 | } 48 | } 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/types/lusca.d.ts: -------------------------------------------------------------------------------- 1 | export function xframe(type: string): any; 2 | export function xssProtection(enabled: boolean): any; 3 | -------------------------------------------------------------------------------- /src/types/openpgp.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for openpgpjs 2 | // Project: http://openpgpjs.org/ 3 | // Definitions by: Guillaume Lacasa 4 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 5 | 6 | export as namespace openpgp; 7 | 8 | export interface KeyPair { 9 | key: key.Key; 10 | privateKeyArmored: string; 11 | publicKeyArmored: string; 12 | } 13 | 14 | export interface KeyOptions { 15 | keyType?: enums.publicKey; 16 | numBits: number; 17 | userIds: Array; 18 | passphrase: string; 19 | unlocked?: boolean; 20 | } 21 | 22 | export interface MessageOptions { 23 | data?: string; 24 | message?: message.Message; 25 | publicKeys: Array; 26 | privateKeys?: any; 27 | privateKey?: any; 28 | } 29 | 30 | export interface Keyid { 31 | bytes: string; 32 | } 33 | 34 | export interface Signature { 35 | keyid: Keyid; 36 | valid: boolean; 37 | } 38 | 39 | export interface VerifiedMessage { 40 | text: string; 41 | signatures: Array; 42 | } 43 | 44 | /** Decrypts message and verifies signatures 45 | @param privateKey private key with decrypted secret key data 46 | @param publicKeys array of keys to verify signatures 47 | @param msg the message object with signed and encrypted data 48 | */ 49 | export function decryptAndVerifyMessage(privateKey: key.Key, publicKeys: Array, msg: string): Promise; 50 | /** Decrypts message and verifies signatures 51 | @param privateKey private key with decrypted secret key data 52 | @param publicKey single key to verify signatures 53 | @param msg the message object with signed and encrypted data 54 | */ 55 | export function decryptAndVerifyMessage(privateKey: key.Key, publicKey: key.Key, msg: string): Promise; 56 | 57 | /** Decrypts message 58 | @param privateKey private key with decrypted secret key data 59 | @param msg the message object with the encrypted data 60 | */ 61 | export function decrypt(options: MessageOptions): Promise; 62 | 63 | 64 | /** Encrypts message text with keys 65 | @param options 66 | */ 67 | export function encrypt(options: MessageOptions): Promise; 68 | 69 | /** Generates a new OpenPGP key pair. Currently only supports RSA keys. Primary and subkey will be of same type. 70 | @param options 71 | */ 72 | export function generateKey(options: KeyOptions): Promise; 73 | 74 | /** Signs message text and encrypts it 75 | 76 | @param publicKeys array of keys used to encrypt the message 77 | @param privateKey private key with decrypted secret key data for signing 78 | @param text private key with decrypted secret key data for signing 79 | */ 80 | export function signAndEncryptMessage(publicKeys: Array, privateKey: key.Key, text: string): Promise; 81 | /** Signs message text and encrypts it 82 | 83 | @param publicKeys single key used to encrypt the message 84 | @param privateKey private key with decrypted secret key data for signing 85 | @param text private key with decrypted secret key data for signing 86 | */ 87 | export function signAndEncryptMessage(publicKey: key.Key, privateKey: key.Key, text: string): Promise; 88 | 89 | /** Signs a cleartext message 90 | 91 | @param privateKeys array of keys with decrypted secret key data to sign cleartext 92 | @param text cleartext 93 | */ 94 | export function signClearMessage(privateKeys: Array, text: string): Promise; 95 | /** Signs a cleartext message 96 | 97 | @param privateKeys single key with decrypted secret key data to sign cleartext 98 | @param text cleartext 99 | */ 100 | export function signClearMessage(privateKey: key.Key, text: string): Promise; 101 | 102 | /** Verifies signatures of cleartext signed message 103 | 104 | @param publicKeys array of keys to verify signatures 105 | @param msg cleartext message object with signatures 106 | */ 107 | export function verifyClearSignedMessage(publicKeys: Array, msg: cleartext.CleartextMessage): Promise; 108 | /** Verifies signatures of cleartext signed message 109 | 110 | @param publicKeys single key to verify signatures 111 | @param msg cleartext message object with signatures 112 | */ 113 | export function verifyClearSignedMessage(publicKey: key.Key, msg: cleartext.CleartextMessage): Promise; 114 | 115 | 116 | export namespace armor { 117 | /** Armor an OpenPGP binary packet block 118 | 119 | @param messagetype type of the message 120 | @param body 121 | @param partindex 122 | @param parttotal 123 | */ 124 | function armor(messagetype: enums.armor, body: Object, partindex: number, parttotal: number): string; 125 | 126 | /** DeArmor an OpenPGP armored message; verify the checksum and return the encoded bytes 127 | 128 | @param text OpenPGP armored message 129 | */ 130 | function dearmor(text: string): Object; 131 | } 132 | 133 | export namespace cleartext { 134 | /** Class that represents an OpenPGP cleartext signed message. 135 | */ 136 | interface CleartextMessage { 137 | /** Returns ASCII armored text of cleartext signed message 138 | */ 139 | armor(): string; 140 | 141 | /** Returns the key IDs of the keys that signed the cleartext message 142 | */ 143 | getSigningKeyIds(): Array; 144 | 145 | /** Get cleartext 146 | */ 147 | getText(): string; 148 | 149 | /** Sign the cleartext message 150 | @param privateKeys private keys with decrypted secret key data for signing 151 | */ 152 | sign(privateKeys: Array): void; 153 | 154 | /** Verify signatures of cleartext signed message 155 | @param keys array of keys to verify signatures 156 | */ 157 | verify(keys: Array): Array; 158 | } 159 | 160 | function readArmored(armoredText: string): CleartextMessage; 161 | } 162 | 163 | export namespace config { 164 | var prefer_hash_algorithm: enums.hash; 165 | var encryption_cipher: enums.symmetric; 166 | var compression: enums.compression; 167 | var show_version: boolean; 168 | var show_comment: boolean; 169 | var integrity_protect: boolean; 170 | var keyserver: string; 171 | var debug: boolean; 172 | } 173 | 174 | export namespace crypto { 175 | interface Mpi { 176 | data: number; 177 | read(input: string): number; 178 | write(): string; 179 | } 180 | 181 | /** Generating a session key for the specified symmetric algorithm 182 | @param algo Algorithm to use 183 | */ 184 | function generateSessionKey(algo: enums.symmetric): string; 185 | 186 | /** generate random byte prefix as string for the specified algorithm 187 | @param algo Algorithm to use 188 | */ 189 | function getPrefixRandom(algo: enums.symmetric): string; 190 | 191 | /** Returns the number of integers comprising the private key of an algorithm 192 | @param algo The public key algorithm 193 | */ 194 | function getPrivateMpiCount(algo: enums.symmetric): number; 195 | 196 | /** Decrypts data using the specified public key multiprecision integers of the private key, the specified secretMPIs of the private key and the specified algorithm. 197 | @param algo Algorithm to be used 198 | @param publicMPIs Algorithm dependent multiprecision integers of the public key part of the private key 199 | @param secretMPIs Algorithm dependent multiprecision integers of the private key used 200 | @param data Data to be encrypted as MPI 201 | */ 202 | function publicKeyDecrypt(algo: enums.publicKey, publicMPIs: Array, secretMPIs: Array, data: Mpi): Mpi; 203 | 204 | /** Encrypts data using the specified public key multiprecision integers and the specified algorithm. 205 | @param algo Algorithm to be used 206 | @param publicMPIs Algorithm dependent multiprecision integers 207 | @param data Data to be encrypted as MPI 208 | */ 209 | function publicKeyEncrypt(algo: enums.publicKey, publicMPIs: Array, data: Mpi): Array; 210 | 211 | 212 | namespace cfb { 213 | /** This function decrypts a given plaintext using the specified blockcipher to decrypt a message 214 | @param cipherfn the algorithm cipher class to decrypt data in one block_size encryption 215 | @param key binary string representation of key to be used to decrypt the ciphertext. This will be passed to the cipherfn 216 | @param ciphertext to be decrypted provided as a string 217 | @param resync a boolean value specifying if a resync of the IV should be used or not. The encrypteddatapacket uses the "old" style with a resync. Decryption within an encryptedintegrityprotecteddata packet is not resyncing the IV. 218 | */ 219 | function decrypt(cipherfn: string, key: string, ciphertext: string, resync: boolean): string; 220 | 221 | /** This function encrypts a given with the specified prefixrandom using the specified blockcipher to encrypt a message 222 | @param prefixrandom random bytes of block_size length provided as a string to be used in prefixing the data 223 | @param cipherfn the algorithm cipher class to encrypt data in one block_size encryption 224 | @param plaintext data to be encrypted provided as a string 225 | @param key binary string representation of key to be used to encrypt the plaintext. This will be passed to the cipherfn 226 | @param resync a boolean value specifying if a resync of the IV should be used or not. The encrypteddatapacket uses the "old" style with a resync. Encryption within an encryptedintegrityprotecteddata packet is not resyncing the IV. 227 | */ 228 | function encrypt(prefixrandom: string, cipherfn: string, plaintext: string, key: string, resync: boolean): string; 229 | 230 | /** Decrypts the prefixed data for the Modification Detection Code (MDC) computation 231 | @param cipherfn cipherfn.encrypt Cipher function to use 232 | @param key binary string representation of key to be used to check the mdc This will be passed to the cipherfn 233 | @param ciphertext The encrypted data 234 | */ 235 | function mdc(cipherfn: Object, key: string, ciphertext: string): string; 236 | } 237 | 238 | namespace hash { 239 | /** Create a hash on the specified data using the specified algorithm 240 | @param algo Hash algorithm type 241 | @param data Data to be hashed 242 | */ 243 | function digest(algo: enums.hash, data: string): string; 244 | 245 | /** Returns the hash size in bytes of the specified hash algorithm type 246 | @param algo Hash algorithm type 247 | */ 248 | function getHashByteLength(algo: enums.hash): number; 249 | } 250 | 251 | namespace random { 252 | /** Create a secure random big integer of bits length 253 | @param bits Bit length of the MPI to create 254 | */ 255 | function getRandomBigInteger(bits: number): number; 256 | 257 | /** Retrieve secure random byte string of the specified length 258 | @param length Length in bytes to generate 259 | */ 260 | function getRandomBytes(length: number): string; 261 | 262 | /** Helper routine which calls platform specific crypto random generator 263 | @param buf 264 | */ 265 | function getRandomValues(buf: Uint8Array): void; 266 | 267 | /** Return a secure random number in the specified range 268 | @param from Min of the random number 269 | @param to Max of the random number (max 32bit) 270 | */ 271 | function getSecureRandom(from: number, to: number): number; 272 | } 273 | 274 | namespace signature { 275 | /** Create a signature on data using the specified algorithm 276 | @param hash_algo hash Algorithm to use 277 | @param algo Asymmetric cipher algorithm to use 278 | @param publicMPIs Public key multiprecision integers of the private key 279 | @param secretMPIs Private key multiprecision integers which is used to sign the data 280 | @param data Data to be signed 281 | */ 282 | function sign(hash_algo: enums.hash, algo: enums.publicKey, publicMPIs: Array, secretMPIs: Array, data: string): Mpi; 283 | 284 | /** 285 | @param algo public Key algorithm 286 | @param hash_algo Hash algorithm 287 | @param msg_MPIs Signature multiprecision integers 288 | @param publickey_MPIs Public key multiprecision integers 289 | @param data Data on where the signature was computed on 290 | */ 291 | function verify(algo: enums.publicKey, hash_algo: enums.hash, msg_MPIs: Array, publickey_MPIs: Array, data: string): boolean; 292 | } 293 | } 294 | 295 | export namespace enums { 296 | enum armor { 297 | multipart_section, 298 | multipart_last, 299 | signed, 300 | message, 301 | public_key, 302 | private_key 303 | } 304 | 305 | enum compression { 306 | uncompressed, 307 | zip, 308 | zlib, 309 | bzip2 310 | } 311 | 312 | enum hash { 313 | md5, 314 | sha1, 315 | ripemd, 316 | sha256, 317 | sha384, 318 | sha512, 319 | sha224 320 | } 321 | 322 | enum packet { 323 | publicKeyEncryptedSessionKey, 324 | signature, 325 | symEncryptedSessionKey, 326 | onePassSignature, 327 | secretKey, 328 | publicKey, 329 | secretSubkey, 330 | compressed, 331 | symmetricallyEncrypted, 332 | marker, 333 | literal, 334 | trust, 335 | userid, 336 | publicSubkey, 337 | userAttribute, 338 | symEncryptedIntegrityProtected, 339 | modificationDetectionCode, 340 | } 341 | 342 | enum publicKey { 343 | rsa_encrypt_sign, 344 | rsa_encrypt, 345 | rsa_sign, 346 | elgamal, 347 | dsa 348 | } 349 | 350 | enum symmetric { 351 | plaintext, 352 | idea, 353 | tripledes, 354 | cast5, 355 | blowfish, 356 | aes128, 357 | aes192, 358 | aes256, 359 | twofish 360 | } 361 | 362 | enum keyStatus { 363 | invalid, 364 | expired, 365 | revoked, 366 | valid, 367 | no_self_cert 368 | } 369 | } 370 | 371 | export namespace key { 372 | interface KeyResult { 373 | keys: Array; 374 | err: Array; 375 | } 376 | 377 | /** Class that represents an OpenPGP key. Must contain a primary key. Can contain additional subkeys, signatures, user ids, user attributes. 378 | */ 379 | interface Key { 380 | armor(): string; 381 | decrypt(passphrase: string): boolean; 382 | getExpirationTime(): Date; 383 | getKeyIds(): Array; 384 | getPreferredHashAlgorithm(): string; 385 | getPrimaryUser(): any; 386 | getUserIds(): Array; 387 | isPrivate(): boolean; 388 | isPublic(): boolean; 389 | primaryKey: packet.PublicKey; 390 | toPublic(): Key; 391 | update(key: Key): void; 392 | verifyPrimaryKey(): enums.keyStatus; 393 | } 394 | 395 | /** Generates a new OpenPGP key. Currently only supports RSA keys. Primary and subkey will be of same type. 396 | 397 | @param options 398 | */ 399 | function generate(options: KeyOptions): Key; 400 | 401 | /** Reads an OpenPGP armored text and returns one or multiple key objects 402 | 403 | @param armoredText text to be parsed 404 | */ 405 | function readArmored(armoredText: string): KeyResult; 406 | } 407 | 408 | export namespace message { 409 | /** Class that represents an OpenPGP message. Can be an encrypted message, signed message, compressed message or literal message 410 | */ 411 | interface Message { 412 | /** Returns ASCII armored text of message 413 | */ 414 | armor(): string; 415 | 416 | /** Decrypt the message 417 | @param privateKey private key with decrypted secret data 418 | */ 419 | decrypt(privateKey: key.Key): Array; 420 | 421 | /** Encrypt the message 422 | @param keys array of keys, used to encrypt the message 423 | */ 424 | encrypt(keys: Array): Array; 425 | 426 | /** Returns the key IDs of the keys to which the session key is encrypted 427 | */ 428 | getEncryptionKeyIds(): Array; 429 | 430 | /** Get literal data that is the body of the message 431 | */ 432 | getLiteralData(): string; 433 | 434 | /** Returns the key IDs of the keys that signed the message 435 | */ 436 | getSigningKeyIds(): Array; 437 | 438 | /** Get literal data as text 439 | */ 440 | getText(): string; 441 | 442 | /** Sign the message (the literal data packet of the message) 443 | @param privateKey private keys with decrypted secret key data for signing 444 | */ 445 | sign(privateKey: Array): Message; 446 | 447 | /** Unwrap compressed message 448 | */ 449 | unwrapCompressed(): Message; 450 | 451 | /** Verify message signatures 452 | @param keys array of keys to verify signatures 453 | */ 454 | verify(keys: Array): Array; 455 | } 456 | 457 | /** creates new message object from binary data 458 | @param bytes 459 | */ 460 | function fromBinary(bytes: string): Message; 461 | 462 | /** creates new message object from text 463 | @param text 464 | */ 465 | function fromText(text: string): Message; 466 | 467 | /** reads an OpenPGP armored message and returns a message object 468 | 469 | @param armoredText text to be parsed 470 | */ 471 | function readArmored(armoredText: string): Message; 472 | } 473 | 474 | export namespace packet { 475 | interface PublicKey { 476 | algorithm: enums.publicKey; 477 | created: Date; 478 | fingerprint: string; 479 | 480 | getBitSize(): number; 481 | getFingerprint(): string; 482 | getKeyId(): string; 483 | read(input: string): any; 484 | write(): any; 485 | } 486 | 487 | interface SecretKey extends PublicKey { 488 | read(bytes: string): void; 489 | write(): string; 490 | clearPrivateMPIs(str_passphrase: string): boolean; 491 | encrypt(passphrase: string): void; 492 | } 493 | 494 | /** Allocate a new packet from structured packet clone 495 | @param packetClone packet clone 496 | */ 497 | function fromStructuredClone(packetClone: Object): Object; 498 | 499 | /** Allocate a new packet 500 | @param property name from enums.packet 501 | */ 502 | function newPacketFromTag(tag: string): Object; 503 | } 504 | 505 | export namespace util { 506 | /** Convert an array of integers(0.255) to a string 507 | @param bin An array of (binary) integers to convert 508 | */ 509 | function bin2str(bin: Array): string; 510 | 511 | /** Calculates a 16bit sum of a string by adding each character codes modulus 65535 512 | @param text string to create a sum of 513 | */ 514 | function calc_checksum(text: string): number; 515 | 516 | /** Convert a string of utf8 bytes to a native javascript string 517 | @param utf8 A valid squence of utf8 bytes 518 | */ 519 | function decode_utf8(utf8: string): string; 520 | 521 | /** Convert a native javascript string to a string of utf8 bytes 522 | param str The string to convert 523 | */ 524 | function encode_utf8(str: string): string; 525 | 526 | /** Return the algorithm type as string 527 | */ 528 | function get_hashAlgorithmString(): string; 529 | 530 | /** Get native Web Cryptography api. The default configuration is to use the api when available. But it can also be deactivated with config.useWebCrypto 531 | */ 532 | function getWebCrypto(): Object; 533 | 534 | /** Create binary string from a hex encoded string 535 | @param str Hex string to convert 536 | */ 537 | function hex2bin(str: string): string; 538 | 539 | /** Creating a hex string from an binary array of integers (0..255) 540 | @param str Array of bytes to convert 541 | */ 542 | function hexidump(str: string): string; 543 | 544 | /** Create hexstring from a binary 545 | @param str string to convert 546 | */ 547 | function hexstrdump(str: string): string; 548 | 549 | /** Helper function to print a debug message. Debug messages are only printed if 550 | @param str string of the debug message 551 | */ 552 | function print_debug(str: string): void; 553 | 554 | /** Helper function to print a debug message. Debug messages are only printed if 555 | @param str string of the debug message 556 | */ 557 | function print_debug_hexstr_dump(str: string): void; 558 | 559 | /** Shifting a string to n bits right 560 | @param value The string to shift 561 | @param bitcount Amount of bits to shift (MUST be smaller than 9) 562 | */ 563 | function shiftRight(value: string, bitcount: number): string; 564 | 565 | /** Convert a string to an array of integers(0.255) 566 | @param str string to convert 567 | */ 568 | function str2bin(str: string): Array; 569 | 570 | /** Convert a string to a Uint8Array 571 | @param str string to convert 572 | */ 573 | function str2Uint8Array(str: string): Uint8Array; 574 | 575 | /** Convert a Uint8Array to a string. This currently functions the same as bin2str. 576 | @param bin An array of (binary) integers to convert 577 | */ 578 | function Uint8Array2str(bin: Uint8Array): string; 579 | } 580 | -------------------------------------------------------------------------------- /test/api.test.ts: -------------------------------------------------------------------------------- 1 | import * as frisby from "frisby"; 2 | import { Joi } from "frisby"; 3 | import * as dotenv from "dotenv"; 4 | 5 | dotenv.config({ path: ".env" }); 6 | 7 | const BASE_URL = `http://localhost:${process.env.PORT}`; 8 | const TEST_USERNAME = "testuser"; 9 | const TEST_PASSWORD = "abc123"; 10 | // TODO: Use mock type schemas (encrypted and unencrypted) instead of test.json 11 | const TEST_TYPE = "test"; 12 | const TEST_DATA = { "text": "foo", "public": "bar" }; 13 | 14 | let privKey; 15 | let token; 16 | let hash; 17 | 18 | process.on("unhandledRejection", r => console.log(r)); 19 | 20 | /** 21 | * Show API info page 22 | */ 23 | describe("GET /", () => { 24 | it("should return 200 OK with valid response", (done) => { 25 | frisby 26 | .get(`${BASE_URL}/`) 27 | .expect("status", 200) 28 | .expect("jsonTypes", { 29 | message: Joi.string() 30 | }) 31 | .done(done); 32 | }); 33 | }); 34 | 35 | 36 | /** 37 | * Create a new user 38 | */ 39 | describe("POST /user/create", () => { 40 | it("should return 200 OK with valid response", (done) => { 41 | frisby 42 | .post(`${BASE_URL}/user/create`, { username: TEST_USERNAME, password: TEST_PASSWORD }) 43 | .expect("status", 200) 44 | .expect("jsonTypes", { 45 | success: Joi.string(), 46 | pub_key: Joi.string(), 47 | priv_key: Joi.string() 48 | }) 49 | .then(function (res) { 50 | privKey = res.json.priv_key; 51 | return true; 52 | }) 53 | .done(done); 54 | }); 55 | }); 56 | 57 | 58 | /** 59 | * Login user 60 | */ 61 | describe("POST /user/login", () => { 62 | it("should return 200 OK with valid response", (done) => { 63 | frisby 64 | .post(`${BASE_URL}/user/login`, { username: TEST_USERNAME, password: TEST_PASSWORD }) 65 | .expect("status", 200) 66 | .expect("jsonTypes", { 67 | success: Joi.string(), 68 | token: Joi.string() 69 | }) 70 | .then(function (res) { 71 | token = res.json.token; 72 | return true; 73 | }) 74 | .done(done); 75 | }); 76 | 77 | it("should fail with an invalid password", (done) => { 78 | frisby 79 | .post(`${BASE_URL}/user/login`, { username: TEST_USERNAME, password: "wrongpass" }) 80 | .expect("status", 401) 81 | .expect("jsonTypes", { 82 | error: Joi.string() 83 | }) 84 | .done(done); 85 | }); 86 | }); 87 | 88 | 89 | /** 90 | * Get type schema 91 | */ 92 | describe("GET /type/{type}/schema", () => { 93 | it("should return 200 OK with valid response", (done) => { 94 | frisby 95 | .get(`${BASE_URL}/type/${TEST_TYPE}/schema`) 96 | .expect("status", 200) 97 | .expect("jsonTypes", { 98 | documents: Joi.array() 99 | }) 100 | .done(done); 101 | }); 102 | 103 | it("should return 404 for a nonexistent type", (done) => { 104 | frisby 105 | .get(`${BASE_URL}/type/foo/schema`) 106 | .expect("status", 404) 107 | .expect("jsonTypes", { 108 | title: Joi.string(), 109 | $schema: Joi.string() 110 | }) 111 | .done(done); 112 | }); 113 | }); 114 | 115 | 116 | /** 117 | * Add a new document 118 | */ 119 | describe("POST /type/{type}", () => { 120 | it("should return 200 OK with valid response", (done) => { 121 | frisby 122 | .setup({ request: { headers: { "x-access-token": token } }}) 123 | .post(`${BASE_URL}/type/${TEST_TYPE}`, { 124 | data: TEST_DATA, 125 | password: TEST_PASSWORD, 126 | priv_key: privKey 127 | }) 128 | .expect("status", 200) 129 | .expect("jsonTypes", { 130 | success: Joi.string(), 131 | hash: Joi.string() 132 | }) 133 | .then(function (res) { 134 | hash = res.json.hash; 135 | return true; 136 | }) 137 | .done(done); 138 | }); 139 | 140 | it("should fail without a data object", (done) => { 141 | frisby 142 | .setup({ request: { headers: { "x-access-token": token } }}) 143 | .post(`${BASE_URL}/type/${TEST_TYPE}`, { password: TEST_PASSWORD }) 144 | .expect("status", 500) 145 | .expect("jsonTypes", { 146 | error: Joi.string() 147 | }) 148 | .done(done); 149 | }); 150 | 151 | it("should fail without a login token", (done) => { 152 | frisby 153 | .setup({ request: { headers: { "x-access-token": "wrongtoken" } }}) 154 | .post(`${BASE_URL}/type/${TEST_TYPE}`, { password: TEST_PASSWORD }) 155 | .expect("status", 401) 156 | .expect("jsonTypes", { 157 | error: Joi.string() 158 | }) 159 | .done(done); 160 | }); 161 | 162 | it("should return 404 for a nonexistent type", (done) => { 163 | frisby 164 | .setup({ request: { headers: { "x-access-token": token } }}) 165 | .post(`${BASE_URL}/type/foo`, { data: TEST_DATA, password: TEST_PASSWORD }) 166 | .expect("status", 404) 167 | .expect("jsonTypes", { 168 | error: Joi.string() 169 | }) 170 | .done(done); 171 | }); 172 | }); 173 | 174 | 175 | /** 176 | * Get type by name (shows index of documents) 177 | */ 178 | describe("GET /type/{type}", () => { 179 | it("should return 200 OK with valid response", (done) => { 180 | frisby 181 | .get(`${BASE_URL}/type/${TEST_TYPE}`) 182 | .expect("status", 200) 183 | .expect("jsonTypes", { 184 | documents: Joi.array().length(1) 185 | }) 186 | .done(done); 187 | }); 188 | 189 | it("each item should have the correct public fields", (done) => { 190 | frisby 191 | .get(`${BASE_URL}/type/${TEST_TYPE}`) 192 | .expect("status", 200) 193 | .expect("jsonTypes", { 194 | documents: Joi.array().length(1) 195 | }) 196 | .expect("jsonTypes", "documents.*", { // Assert *each* object in array matches 197 | "public": Joi.string().required(), 198 | "created": Joi.date().timestamp().required(), 199 | "updated": Joi.date().timestamp().required(), 200 | "username": Joi.string().required(), 201 | "link": Joi.object().required() 202 | }) 203 | .done(done); 204 | }); 205 | 206 | it("should return 404 for a nonexistent type", (done) => { 207 | frisby 208 | .get(`${BASE_URL}/type/foo`) 209 | .expect("status", 404) 210 | .expect("jsonTypes", { 211 | documents: Joi.array() 212 | }) 213 | .done(done); 214 | }); 215 | }); 216 | 217 | 218 | /** 219 | * Get IPFS Document (without handling encryption) 220 | */ 221 | describe("GET /document/{hash}", () => { 222 | it("should return 200 OK with valid response", (done) => { 223 | frisby 224 | .get(`${BASE_URL}/document/${hash}`) 225 | .expect("status", 200) 226 | .expect("bodyContains", "BEGIN PGP MESSAGE") 227 | .done(done); 228 | }); 229 | 230 | it("should return 404 for a nonexistent type", (done) => { 231 | frisby 232 | .get(`${BASE_URL}/document/wronghash`) 233 | .expect("status", 404) 234 | .expect("jsonTypes", { 235 | error: Joi.string() 236 | }) 237 | .done(done); 238 | }); 239 | }); 240 | 241 | 242 | /** 243 | * Show document by hash (for encrypted documents) 244 | */ 245 | describe("POST /type/{type}/{hash}", () => { 246 | it("should return 200 OK with valid response", (done) => { 247 | frisby 248 | .setup({ request: { headers: { "x-access-token": token } }}) 249 | .post(`${BASE_URL}/type/${TEST_TYPE}/${hash}`, { 250 | password: TEST_PASSWORD, 251 | priv_key: privKey 252 | }) 253 | .expect("status", 200) 254 | .expect("json", TEST_DATA) 255 | .done(done); 256 | }); 257 | 258 | it("should fail without a login token", (done) => { 259 | frisby 260 | .post(`${BASE_URL}/type/${TEST_TYPE}`, { token: "wrongtoken", password: TEST_PASSWORD }) 261 | .expect("status", 401) 262 | .expect("jsonTypes", { 263 | error: Joi.string() 264 | }) 265 | .done(done); 266 | }); 267 | 268 | it("should return 404 for a nonexistent type", (done) => { 269 | frisby 270 | .post(`${BASE_URL}/type/foo`, { token: token, password: TEST_PASSWORD }) 271 | .expect("status", 404) 272 | .expect("jsonTypes", { 273 | error: Joi.string() 274 | }) 275 | .done(done); 276 | }); 277 | }); 278 | 279 | 280 | /** 281 | * Update an existing document 282 | */ 283 | describe("PUT /type/{type}/{hash}", () => { 284 | it("should only allow users to update their own documents", (done) => { 285 | frisby 286 | .post(`${BASE_URL}/user/create`, { username: TEST_USERNAME + "put", password: TEST_PASSWORD }) 287 | .expect("status", 200) 288 | .then((res) => { 289 | const putPrivKey = res.json.priv_key; 290 | 291 | return frisby.post(`${BASE_URL}/user/login`, { username: TEST_USERNAME + "put", password: TEST_PASSWORD }) 292 | .expect("status", 200) 293 | .then((res) => { 294 | const putToken = res.json.token; 295 | 296 | return frisby.setup({ request: { headers: { "x-access-token": putToken } }}) 297 | .put(`${BASE_URL}/type/${TEST_TYPE}/${hash}`, { 298 | data: TEST_DATA, 299 | password: TEST_PASSWORD, 300 | priv_key: putPrivKey 301 | }) 302 | .expect("status", 403) 303 | .expect("jsonTypes", { 304 | error: Joi.string() 305 | }) 306 | .then((res) => { 307 | // Cleanup 308 | return frisby.setup({ request: { headers: { "x-access-token": putToken } }}) 309 | .post(`${BASE_URL}/user/delete`, { token: putToken, password: TEST_PASSWORD }) 310 | .expect("status", 200); 311 | }); 312 | }); 313 | }) 314 | .done(done); 315 | }); 316 | 317 | it("should return 200 OK with valid response", (done) => { 318 | frisby 319 | .setup({ request: { headers: { "x-access-token": token } }}) 320 | .put(`${BASE_URL}/type/${TEST_TYPE}/${hash}`, { 321 | data: TEST_DATA, 322 | password: TEST_PASSWORD, 323 | priv_key: privKey 324 | }) 325 | .expect("status", 200) 326 | .expect("jsonTypes", { 327 | success: Joi.string(), 328 | hash: Joi.string() 329 | }) 330 | .then(function (res) { 331 | hash = res.json.hash; 332 | return true; 333 | }) 334 | .done(done); 335 | }); 336 | 337 | it("should fail without a login token", (done) => { 338 | frisby 339 | .put(`${BASE_URL}/type/${TEST_TYPE}/${hash}`, { token: "wrongtoken", password: TEST_PASSWORD }) 340 | .expect("status", 401) 341 | .expect("jsonTypes", { 342 | error: Joi.string() 343 | }) 344 | .done(done); 345 | }); 346 | 347 | it("should return 404 for a nonexistent type", (done) => { 348 | frisby 349 | .put(`${BASE_URL}/type/foo/${hash}`, { token: token, password: TEST_PASSWORD }) 350 | .expect("status", 404) 351 | .expect("jsonTypes", { 352 | error: Joi.string() 353 | }) 354 | .done(done); 355 | }); 356 | }); 357 | 358 | 359 | /** 360 | * Remove an document from type index 361 | */ 362 | describe("DELETE /type/{type}/{hash}", () => { 363 | it("should only allow users to delete their own documents", (done) => { 364 | frisby 365 | .post(`${BASE_URL}/user/create`, { username: TEST_USERNAME + "del", password: TEST_PASSWORD }) 366 | .expect("status", 200) 367 | .then((res) => { 368 | return frisby.post(`${BASE_URL}/user/login`, { username: TEST_USERNAME + "del", password: TEST_PASSWORD }) 369 | .expect("status", 200) 370 | .then((res) => { 371 | const delToken = res.json.token; 372 | 373 | return frisby.setup({ request: { headers: { "x-access-token": delToken } }}) 374 | .del(`${BASE_URL}/type/${TEST_TYPE}/${hash}`) 375 | .expect("status", 403) 376 | .expect("jsonTypes", { 377 | error: Joi.string() 378 | }) 379 | .then((res) => { 380 | // Cleanup 381 | return frisby.setup({ request: { headers: { "x-access-token": delToken } }}) 382 | .post(`${BASE_URL}/user/delete`, { token: delToken, password: TEST_PASSWORD }) 383 | .expect("status", 200); 384 | }); 385 | }); 386 | }) 387 | .done(done); 388 | }); 389 | 390 | it("should return 200 OK with valid response", (done) => { 391 | frisby 392 | .setup({ request: { headers: { "x-access-token": token } }}) 393 | .del(`${BASE_URL}/type/${TEST_TYPE}/${hash}`) 394 | .expect("status", 200) 395 | .expect("jsonTypes", { 396 | success: Joi.string(), 397 | hash: Joi.string() 398 | }) 399 | .done(done); 400 | }); 401 | 402 | it("should fail without a login token", (done) => { 403 | frisby 404 | .del(`${BASE_URL}/type/${TEST_TYPE}/${hash}`) 405 | .expect("status", 401) 406 | .expect("jsonTypes", { 407 | error: Joi.string() 408 | }) 409 | .done(done); 410 | }); 411 | 412 | it("should return 404 for a nonexistent type", (done) => { 413 | frisby 414 | .setup({ request: { headers: { "x-access-token": token } }}) 415 | .del(`${BASE_URL}/type/foo/${hash}`) 416 | .expect("status", 404) 417 | .expect("jsonTypes", { 418 | error: Joi.string() 419 | }) 420 | .done(done); 421 | }); 422 | 423 | it("should return 404 for a nonexistent document", (done) => { 424 | frisby 425 | .setup({ request: { headers: { "x-access-token": token } }}) 426 | .del(`${BASE_URL}/type/${TEST_TYPE}/wrongdoc`) 427 | .expect("status", 404) 428 | .expect("jsonTypes", { 429 | success: Joi.string(), 430 | hash: Joi.string() 431 | }) 432 | .done(done); 433 | }); 434 | }); 435 | 436 | 437 | /** 438 | * Remove own user account 439 | */ 440 | describe("POST /user/delete", () => { 441 | it("should fail with an invalid password", (done) => { 442 | frisby 443 | .setup({ request: { headers: { "x-access-token": token } }}) 444 | .post(`${BASE_URL}/user/delete`, { password: "wrongpass" }) 445 | .expect("status", 401) 446 | .expect("jsonTypes", { 447 | error: Joi.string() 448 | }) 449 | .done(done); 450 | }); 451 | 452 | it("should fail without a login token", (done) => { 453 | frisby 454 | .setup({ request: { headers: { "x-access-token": "wrongtoken" } }}) 455 | .post(`${BASE_URL}/user/delete`, { password: TEST_PASSWORD }) 456 | .expect("status", 401) 457 | .expect("jsonTypes", { 458 | error: Joi.string() 459 | }) 460 | .done(done); 461 | }); 462 | 463 | it("should return 200 OK with valid response", (done) => { 464 | frisby 465 | .setup({ request: { headers: { "x-access-token": token } }}) 466 | .post(`${BASE_URL}/user/delete`, { password: TEST_PASSWORD }) 467 | .expect("status", 200) 468 | .expect("jsonTypes", { 469 | success: Joi.string() 470 | }) 471 | .done(done); 472 | }); 473 | }); 474 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "outDir": "dist", 8 | "baseUrl": ".", 9 | "allowSyntheticDefaultImports": true, 10 | "paths": { 11 | "*": [ 12 | "node_modules/*", 13 | "src/types/*" 14 | ] 15 | } 16 | }, 17 | "include": [ 18 | "src/**/*.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "one-line": [ 13 | true, 14 | "check-open-brace", 15 | "check-whitespace" 16 | ], 17 | "no-var-keyword": true, 18 | "quotemark": [ 19 | true, 20 | "double", 21 | "avoid-escape" 22 | ], 23 | "semicolon": [ 24 | true, 25 | "always", 26 | "ignore-bound-class-methods" 27 | ], 28 | "whitespace": [ 29 | true, 30 | "check-branch", 31 | "check-decl", 32 | "check-operator", 33 | "check-module", 34 | "check-separator", 35 | "check-type" 36 | ], 37 | "typedef-whitespace": [ 38 | true, 39 | { 40 | "call-signature": "nospace", 41 | "index-signature": "nospace", 42 | "parameter": "nospace", 43 | "property-declaration": "nospace", 44 | "variable-declaration": "nospace" 45 | }, 46 | { 47 | "call-signature": "onespace", 48 | "index-signature": "onespace", 49 | "parameter": "onespace", 50 | "property-declaration": "onespace", 51 | "variable-declaration": "onespace" 52 | } 53 | ], 54 | "no-internal-module": true, 55 | "no-trailing-whitespace": true, 56 | "no-null-keyword": true, 57 | "prefer-const": true, 58 | "jsdoc-format": true 59 | } 60 | } --------------------------------------------------------------------------------