├── .circleci └── config.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── TODO ├── config ├── config.yaml.ci ├── config.yaml.default └── config.yaml.docker ├── controllers ├── bookmarking.js ├── cla.js ├── errlog.js ├── feedback.js ├── files.js ├── health.js ├── spaces.js ├── sync.js └── users.js ├── docker-compose.yml ├── entrypoint.sh ├── example-plugins ├── email │ └── main.js └── package.json ├── helpers ├── auth.js ├── config.js ├── cors.js ├── db.js ├── error.js ├── log.js ├── plugins.js ├── tres.js ├── util.js └── validator.js ├── models ├── analytics.js ├── board.js ├── bookmark.js ├── cla.js ├── email.js ├── errlog.js ├── feedback.js ├── file.js ├── invite.js ├── keychain.js ├── note.js ├── profile.js ├── space.js ├── sync.js └── user.js ├── package.json ├── scripts ├── init-db.sh └── install-plugins.sh ├── server.js └── tools ├── create-db-schema.js ├── delete_user.js ├── note-rm.js └── populate-test-data.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | aliases: 2 | - &defaults 3 | working_directory: /tmp/turtl/server 4 | 5 | version: 2 6 | jobs: 7 | test: 8 | <<: *defaults 9 | 10 | docker: 11 | - image: circleci/node:4.8.3 12 | - image: circleci/postgres:9.6.2-alpine 13 | environment: 14 | - POSTGRES_USER=turtl 15 | - POSTGRES_DB=turtl 16 | 17 | steps: 18 | - checkout 19 | 20 | # set up/run server 21 | - run: cp config/config.yaml.ci config/config.yaml 22 | - restore_cache: 23 | key: server-npm-v1-{{ checksum "../server/package.json" }} 24 | key: server-npm-v1- 25 | - run: npm install 26 | - save_cache: 27 | key: server-npm-v1-{{ checksum "../server/package.json" }} 28 | paths: 29 | - node_modules 30 | - run: mkdir -p plugins 31 | - run: mkdir -p public/uploads 32 | - run: sleep 5 33 | - run: node tools/create-db-schema.js 34 | - run: node tools/populate-test-data.js 35 | - run: 36 | command: node server.js 37 | background: true 38 | 39 | # install rust 40 | - run: gpg --import build-tools/rust.gpg.pub 41 | - run: wget https://static.rust-lang.org/rustup.sh -O /tmp/rustup.sh && chmod 0750 /tmp/rustup.sh 42 | - run: sudo /tmp/rustup.sh --revision=1.24.1 43 | 44 | # set up/run core 45 | - run: git clone https://github.com/turtl/core-rs ../core-rs 46 | - run: cd ../core-rs && bash ./.circleci/prepare-config.sh 47 | - run: | 48 | cd ../core-rs/integration-tests 49 | mkdir -p /tmp/turtl/integration 50 | make \ 51 | RUST_BACKTRACE=1 \ 52 | TURTL_LOGLEVEL=info \ 53 | TURTL_CONFIG_FILE=../config.yaml \ 54 | LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:../target/release" \ 55 | test 56 | 57 | workflows: 58 | version: 2 59 | full: 60 | jobs: 61 | - test: 62 | filters: 63 | branches: 64 | only: master 65 | tags: 66 | only: /.*/ 67 | 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | /config/config.yaml 5 | /sess.vim 6 | /plugins 7 | *.un~ 8 | /play 9 | *.stackdump 10 | /scripts/grim 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11-alpine 2 | 3 | ARG TURTL_SERVER_PLUGIN_REPO 4 | ARG TURTL_SERVER_PLUGIN_LOCATION 5 | 6 | EXPOSE 8181 7 | WORKDIR /app 8 | COPY . . 9 | COPY config/config.yaml.docker config/config.yaml 10 | 11 | RUN apk add -U bash git &&\ 12 | npm install --production &&\ 13 | ./scripts/install-plugins.sh &&\ 14 | mkdir /plugins /uploads 15 | 16 | ENTRYPOINT ["/app/entrypoint.sh"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | 663 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turtl server 2 | 3 | _Opening an issue? See the [Turtl project tracker](https://github.com/turtl/project-tracker/issues)_ 4 | 5 | This is the new Turtl server. It handles a number of things for Turtl clients: 6 | 7 | - Account management (join/login/delete) 8 | - Data storage 9 | - Syncing 10 | - Permissions and sharing 11 | 12 | It implements a plugin architecture so things like analytics and payment 13 | processing can be used without forcing a particular method/service. 14 | 15 | ## Running the server 16 | 17 | The Turtl server requires [Node](https://nodejs.org/) >= 8 and a [Postgres](https://www.postgresql.org/) 18 | instance (>= 9.6) with a dedicated user/db set up for it. 19 | 20 | Once you have Node and Postgres set up, do the following: 21 | 22 | ```sh 23 | mkdir turtl 24 | cd turtl 25 | git clone https://github.com/turtl/server 26 | cd server/ 27 | npm install 28 | cp config/config.yaml.default config/config.yaml 29 | ``` 30 | 31 | Now edit `config/config.yaml` as needed. 32 | You'll want to main get your `db` settings correct, and `uploads`/`s3` sections 33 | configured. Also, be sure to change `app.secure_hash_salt` _(unless you're going 34 | to be running the integration tests against this server)_. 35 | 36 | Now do: 37 | 38 | ```sh 39 | # create the plugin directory from config.yaml#plugins.plugin_location 40 | mkdir /path/to/plugin/dir # (usually just plugins/ in turtl/server/) 41 | ./scripts/init-db.sh 42 | node server.js 43 | ``` 44 | 45 | Great, done. 46 | 47 | ## Running the server (via docker-compose) 48 | 49 | You only have to run the following docker-compose command: 50 | 51 | ```sh 52 | docker-compose up 53 | ``` 54 | 55 | It will spawn a postgres database and the turtl server itself. Now you have a running turtl 56 | which is available under 'http://localhost:8181'. 57 | 58 | Be aware: after you cancel the docker-compose the data will be lost. For productive usage you may want 59 | to store the postgres-data inside a docker volume. 60 | 61 | ### Configuration via ENV-Variables 62 | In docker you may want to set each configuration value (for example the database) via environment 63 | variables. You can override **each** default value via environment variable! Just create a variable named 64 | with the prefix **TURTLE_** followed by the "yaml-path" written in UPPERCASE. For example: If you want 65 | to change the **app.api_url** value you have to define the variable name like **TURTL_APP_API_URL**. 66 | 67 | Some configuration values are explained in `config/config.yaml.default`. 68 | 69 | ## Integration tests 70 | 71 | If you want to run the [integration tests](https://github.com/turtl/core-rs/tree/master/integration-tests) 72 | against this instance of the server you need to do two things: 73 | 74 | 1. Leave the `app.secure_hash_salt` value as it appears in `config.yaml.default` 75 | 2. Run `node tools/populate-test.data.js` 76 | 77 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - plugin architecture for 2 | - premium 3 | 4 | -------------------------------------------------------------------------------- /config/config.yaml.ci: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | port: 8181 4 | 5 | db: 6 | host: '127.0.0.1' 7 | port: 5432 8 | database: 'turtl' 9 | user: 'turtl' 10 | password: '' 11 | pool: 24 12 | 13 | loglevel: 'info' 14 | 15 | app: 16 | enable_bookmarker_proxy: false 17 | # no trailing slash 18 | api_url: 'http://127.0.0.1:8181' 19 | www_url: 'https://yourdomain.com' 20 | login: 21 | # Max failed login attemps. Set to -1 to disable 22 | max_attemps: 5 23 | # User locked for this duration in seconds 24 | lock_duration: 60 25 | emails: 26 | admin: 'admin@turtlapp.com' 27 | info: 'Turtl ' 28 | invites: 'invites@turtlapp.com' 29 | secure_hash_salt: "Plaque is a figment of the liberal media and the dental industry to scare you into buying useless appliances and pastes. Now, I've read the arguments on both sides and I haven't found any evidence yet to support the need to brush your teeth. Ever." 30 | allow_unconfirmed_invites: true 31 | 32 | sync: 33 | # how many sync records can a client send at a time? it's a good idea to have 34 | # a limit here, lest a rogue client flood the server with sync items 35 | max_bulk_sync_records: 32 36 | 37 | plugins: 38 | plugin_location: '/tmp/turtl/server/plugins' 39 | analytics: 40 | enabled: false 41 | email: 42 | enabled: false 43 | premium: 44 | enabled: false 45 | 46 | uploads: 47 | local: '/tmp/turtl/server/public/uploads' 48 | local_proxy: true 49 | url: 'http://127.0.0.1:8181/uploads' 50 | 51 | s3: 52 | token: 'IHADAPETSNAKEBUTHEDIEDNOOOOO' 53 | secret: '' 54 | bucket: '' 55 | endpoint: 'https://s3.amazonaws.com' 56 | pathstyle: false 57 | -------------------------------------------------------------------------------- /config/config.yaml.default: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | # Per default, turtl will listen on all IP addresses 4 | # You can choose the IP it will use with this parameter 5 | host: 6 | port: 8181 7 | 8 | db: 9 | connstr: 'postgres://slappy:floppy@127.0.0.1:5432/turtl' 10 | pool: 24 11 | 12 | loglevel: 'debug' 13 | 14 | app: 15 | # ALWAYS false in production. Always. 16 | # Set to 'I UNDERSTAND THIS VIOLATES THE PRIVACY OF MY USERS' to enable 17 | enable_bookmarker_proxy: false 18 | # no trailing slash 19 | api_url: 'http://api.yourdomain.com:8181' 20 | www_url: 'https://yourdomain.com' 21 | login: 22 | # Max failed login attemps. Set to -1 to disable 23 | max_attemps: 5 24 | # User locked for this duration in seconds 25 | lock_duration: 60 26 | emails: 27 | admin: 'admin@turtlapp.com' 28 | info: 'Turtl ' 29 | invites: 'invites@turtlapp.com' 30 | # TODO: replace this with a long, unique value. seriously. write down a dream 31 | # you had, or the short story you came up with during your creative writing 32 | # class in your freshmen year of college. have fun with it. 33 | secure_hash_salt: "Plaque is a figment of the liberal media and the dental industry to scare you into buying useless appliances and pastes. Now, I've read the arguments on both sides and I haven't found any evidence yet to support the need to brush your teeth. Ever." 34 | # set to true if you think it's ok to SEND invites if you have not confirmed 35 | # your account. great for testing, not so great for production. but what do 36 | # i know... 37 | allow_unconfirmed_invites: false 38 | 39 | sync: 40 | # how many sync records can a client send at a time? it's a good idea to have 41 | # a limit here, lest a rogue client flood the server with sync items 42 | max_bulk_sync_records: 32 43 | 44 | plugins: 45 | plugin_location: '/var/www/turtl/server/plugins' 46 | # each key here corresponds to a folder name in the plugins folder, so `email` 47 | # below would be a plugin at /var/www/turtl/server/plugins/email (see the 48 | # example-plugins/ folder for an email plugin you can use) 49 | email: 50 | enabled: false 51 | endpoint: 'smtps://user:password@smtp.gmail.com/?pool=true' 52 | defaults: {} 53 | 54 | uploads: 55 | # if set to a path, files will be uploaded to the local filesystem instead of 56 | # S3. otherwise, set to false 57 | local: '/var/www/turtl/server/public/uploads' 58 | # if true, downloading local files will be proxied through the turtl server. 59 | # this avoids needing to set up any CORS config in your favorite webserver, 60 | # but may slightly affect performance on high-demand servers. 61 | local_proxy: true 62 | # if local_proxy is false, this is should be the url path the uploaded files 63 | # are publicly available on 64 | url: 'http://api.turtl.dev/uploads' 65 | 66 | s3: 67 | token: 'IHADAPETSNAKEBUTHEDIEDNOOOOO' 68 | secret: '' 69 | bucket: '' 70 | endpoint: 'https://s3.amazonaws.com' 71 | pathstyle: false 72 | -------------------------------------------------------------------------------- /config/config.yaml.docker: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | host: 4 | port: 8181 5 | 6 | db: 7 | host: 'db' 8 | port: 5432 9 | database: 'turtl' 10 | user: 'turtl' 11 | password: '' 12 | pool: 24 13 | 14 | loglevel: 'info' 15 | 16 | app: 17 | enable_bookmarker_proxy: false 18 | # no trailing slash 19 | api_url: 'http://127.0.0.1:8181' 20 | www_url: 'https://yourdomain.com' 21 | login: 22 | # Max failed login attemps. Set to -1 to disable 23 | max_attemps: 5 24 | # User locked for this duration in seconds 25 | lock_duration: 60 26 | emails: 27 | admin: 'admin@turtlapp.com' 28 | info: 'Turtl ' 29 | invites: 'invites@turtlapp.com' 30 | secure_hash_salt: "Plaque is a figment of the liberal media and the dental industry to scare you into buying useless appliances and pastes. Now, I've read the arguments on both sides and I haven't found any evidence yet to support the need to brush your teeth. Ever." 31 | allow_unconfirmed_invites: true 32 | 33 | sync: 34 | # how many sync records can a client send at a time? it's a good idea to have 35 | # a limit here, lest a rogue client flood the server with sync items 36 | max_bulk_sync_records: 32 37 | 38 | plugins: 39 | plugin_location: '/plugins' 40 | analytics: 41 | enabled: false 42 | email: 43 | enabled: false 44 | endpoint: 45 | defaults: {} 46 | premium: 47 | enabled: false 48 | 49 | uploads: 50 | local: '/uploads' 51 | local_proxy: true 52 | url: 'http://127.0.0.1:8181/uploads' 53 | 54 | s3: 55 | token: 'IHADAPETSNAKEBUTHEDIEDNOOOOO' 56 | secret: '' 57 | bucket: '' 58 | endpoint: 'https://s3.amazonaws.com' 59 | pathstyle: false 60 | -------------------------------------------------------------------------------- /controllers/bookmarking.js: -------------------------------------------------------------------------------- 1 | var model = require('../models/bookmark'); 2 | var tres = require('../helpers/tres'); 3 | var config = require('../helpers/config'); 4 | var log = require('../helpers/log'); 5 | 6 | exports.route = function(app) { 7 | app.get('/bookmark', proxy_url); 8 | }; 9 | 10 | var proxy_url = function(req, res) { 11 | var url = req.query.url; 12 | tres.wrap(res, model.proxy_url(url), {raw: true, content_type: 'text/html'}); 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /controllers/cla.js: -------------------------------------------------------------------------------- 1 | var model = require('../models/cla'); 2 | var tres = require('../helpers/tres'); 3 | var config = require('../helpers/config'); 4 | 5 | exports.route = function(app) { 6 | app.post('/cla/sign', sign); 7 | }; 8 | 9 | var sign = function(req, res) { 10 | var redirect = req.body['redirect'] || config.app.www_url+'/contributing/sign-thanks'; 11 | var redirect_err = req.body['redirect-err'] || config.app.www_url+'/contributing/sign-error'; 12 | var fields = [ 13 | 'type', 'entity', 'fullname', 14 | 'email', 'address1', 'address2', 15 | 'city', 'state', 'zip', 16 | 'country', 'phone', 'github', 17 | 'sign', 18 | ]; 19 | var sig = {}; 20 | fields.forEach(function(field) { sig[field] = req.body[field]; }); 21 | model.sign(sig) 22 | .then(function() { 23 | tres.redirect(res, redirect, 'yay', {status: 302}); 24 | }) 25 | .catch(function(err) { 26 | tres.redirect(res, redirect_err, 'There was an error processing your signature.', {status: 302}); 27 | }); 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /controllers/errlog.js: -------------------------------------------------------------------------------- 1 | var model = require('../models/errlog'); 2 | var tres = require('../helpers/tres'); 3 | var analytics = require('../models/analytics'); 4 | 5 | exports.route = function(app) { 6 | app.post('/log/error', log_error); 7 | }; 8 | 9 | var log_error = function(req, res) { 10 | var log_data = req.body.data; 11 | var client = req.header('X-Turtl-Client'); 12 | var promise = model.log_error(log_data) 13 | .tap(function(data) { 14 | analytics.track(null, 'error.log', client, {hash: data.hash}); 15 | }); 16 | tres.wrap(res, promise); 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /controllers/feedback.js: -------------------------------------------------------------------------------- 1 | var tres = require('../helpers/tres'); 2 | var model = require('../models/feedback'); 3 | var analytics = require('../models/analytics'); 4 | 5 | exports.route = function(app) { 6 | app.post('/feedback', send_feedback); 7 | }; 8 | 9 | var send_feedback = function(req, res) { 10 | var data = req.body; 11 | var user_id = req.user.id; 12 | var username = req.user.username; 13 | var client = req.header('X-Turtl-Client'); 14 | var promise = model.send(user_id, username, client, data) 15 | .tap(function() { 16 | analytics.track(user_id, 'feedback.send', client); 17 | }); 18 | tres.wrap(res, promise); 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /controllers/files.js: -------------------------------------------------------------------------------- 1 | var tres = require('../helpers/tres'); 2 | var error = require('../helpers/error'); 3 | var note_model = require('../models/note'); 4 | var analytics = require('../models/analytics'); 5 | var config = require('../helpers/config'); 6 | var Promise = require('bluebird'); 7 | 8 | exports.route = function(app) { 9 | app.get('/notes/:note_id/attachment', get_note_file); 10 | app.get('/notes/:note_id/local-attachment-proxy', proxy_local_file); 11 | app.put('/notes/:note_id/attachment', attach_file); 12 | }; 13 | 14 | var get_note_file = function(req, res) { 15 | var user_id = req.user.id; 16 | var note_id = req.params.note_id; 17 | var promise = config.uploads.local && config.uploads.local_proxy ? 18 | Promise.resolve(config.app.api_url+'/notes/'+note_id+'/local-attachment-proxy') : 19 | note_model.get_file_url(user_id, note_id); 20 | tres.wrap(res, promise); 21 | }; 22 | 23 | var proxy_local_file = function(req, res) { 24 | var user_id = req.user.id; 25 | var note_id = req.params.note_id; 26 | return note_model.pipe_local_file(user_id, note_id) 27 | .then(function(stream) { 28 | stream.on('error', function() { stream.end(); }); 29 | stream.pipe(res); 30 | }) 31 | .catch(function(err) { 32 | tres.err(res, err); 33 | }); 34 | }; 35 | 36 | /** 37 | * attach a file to a note using streaming. 38 | * 39 | * just want to say one thing about this function: sorry. sorry to anyone who 40 | * has to try and follow it. i made it as simple as i could, but there are so 41 | * many weird little edge cases when dealing with streaming that it's bound to 42 | * be complicated. but hey, at least i commented it, right?! 43 | */ 44 | var attach_file = function(req, res) { 45 | var user_id = req.user.id; 46 | var note_id = req.params.note_id; 47 | var client = req.header('X-Turtl-Client'); 48 | 49 | // the stream passed back from our file writer plugin 50 | var stream = null; 51 | // true when our incoming stream (req) has finished sending 52 | var done = false; 53 | // true when we've sent a response, any response, to the client 54 | var sent = false; 55 | // the function passed back by our file handler that we call after a 56 | // successful upload occurs (ie, no errors during upload) 57 | var finishfn = false; 58 | // handles errors for us 59 | var errfn = function(err) { 60 | if(sent) return; 61 | if(!(err instanceof Error)) err = new Error(err); 62 | sent = true; 63 | return tres.err(res, err); 64 | }; 65 | // tracks how many active writes we have. this is important because we don't 66 | // want to mark things as finished when we are actively writing to our 67 | // stream. using (active_writes == 0 && done) we can know for certain that 68 | // we are finished and can run our finishfn() 69 | var active_writes = 0; 70 | // track the total size of the file 71 | var total_size = 0; 72 | // handed to our streamer() function as the error-handling callback 73 | var streamcb = function(err, _) { 74 | active_writes--; 75 | if(err) return errfn(err); 76 | // if no error and client is not done sending, do nothing 77 | if(!done) return; 78 | // we're writing to the stream, don't finish or end 79 | if(active_writes > 0) return; 80 | // when our stream is done, we call our finish fn. if there are errors, 81 | // this will never be reached and we'll end up in the errfn. 82 | // NOTE: `uploaded` is an s3-specific event, so we mimick it in the 83 | // local file uploader 84 | stream.on('uploaded', function() { 85 | if(sent) return; 86 | return finishfn(total_size) 87 | .then(function(notedata) { 88 | sent = true; 89 | analytics.track(user_id, 'file.upload', client, {size: total_size}); 90 | return tres.send(res, notedata); 91 | }) 92 | .catch(errfn); 93 | }); 94 | // we're done! mark the stream finished. 95 | stream.end(); 96 | }; 97 | // writes to the stream, and increments our active_writes count, calling the 98 | // streamcb once complete (which in turn decrements active_writes and checks 99 | // if we're done). 100 | var write = function(chunk) { 101 | active_writes++; 102 | stream.write(chunk, streamcb); 103 | }; 104 | 105 | var buffer = []; 106 | var start_upload = function() { 107 | if(sent) return; 108 | // send our buffer into the stream and then clear the buffer 109 | write(Buffer.concat(buffer)); 110 | buffer = []; 111 | }; 112 | req.on('data', function(chunk) { 113 | total_size += chunk.length; 114 | if(sent) return; 115 | // if we don't have a stream (waiting on note model), buffer our writes 116 | if(!stream) return buffer.push(chunk); 117 | write(chunk); 118 | }); 119 | req.on('end', function() { 120 | if(sent) return; 121 | // mark the client upload as done. careful, just becaues this is true 122 | // doesn't mean we're done streaming. we may still have active writers 123 | // on the stream (active_writes > 0) so we need to check both before 124 | // stampeding towards a success response. 125 | done = true; 126 | // in the case the entire upload finished before we even have a stream 127 | // ready, just return. we'll finalize everything once the stream is 128 | // created. 129 | if(!stream) return; 130 | // do an empty write. this gives our streamcb a little nudge in case we 131 | // finish here AFTER the last write finishes (it's possible) 132 | write(Buffer.concat([])); 133 | }); 134 | // ok, here's where we drive things forward. grab the stream/finishfn from 135 | // the note model and start the upload to our destination 136 | return note_model.attach_file(user_id, note_id) 137 | .spread(function(_stream, _finishfn) { 138 | stream = _stream; 139 | finishfn = _finishfn; 140 | stream.on('error', errfn); 141 | // kewll we got a stream, start the upload 142 | return start_upload(); 143 | }) 144 | .catch(errfn); 145 | }; 146 | 147 | -------------------------------------------------------------------------------- /controllers/health.js: -------------------------------------------------------------------------------- 1 | var tres = require('../helpers/tres'); 2 | var user_model = require('../models/user'); 3 | 4 | exports.route = function(app) { 5 | app.get('/health/db', db_health); 6 | }; 7 | 8 | var db_health = function(req, res) { 9 | var userpromise = user_model.get_by_id(1) 10 | .then(function(_user) { return {healthy: true}; }); 11 | return tres.wrap(res, userpromise); 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /controllers/spaces.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var space_model = require('../models/space'); 4 | var invite_model = require('../models/invite'); 5 | var tres = require('../helpers/tres'); 6 | var analytics = require('../models/analytics'); 7 | 8 | exports.route = function(app) { 9 | app.put('/spaces/:space_id/members/:user_id', update_member); 10 | app.delete('/spaces/:space_id/members/:user_id', delete_member); 11 | app.put('/spaces/:space_id/owner/:new_user_id', set_owner); 12 | app.post('/spaces/:space_id/invites', send_invite); 13 | app.put('/spaces/:space_id/invites/:invite_id', update_invite); 14 | app.post('/spaces/:space_id/invites/accepted/:invite_id', accept_invite); 15 | app.delete('/spaces/:space_id/invites/:invite_id', delete_invite); 16 | }; 17 | 18 | var update_member = function(req, res) { 19 | var user_id = req.user.id; 20 | var space_id = req.params.space_id; 21 | var member_user_id = req.params.user_id; 22 | var data = req.body; 23 | var client = req.header('X-Turtl-Client'); 24 | var promise = space_model.update_member(user_id, space_id, member_user_id, data) 25 | .tap(function() { 26 | analytics.track(user_id, 'space.update-member', client, { 27 | space_id: space_id, 28 | member_id: member_user_id, 29 | role: data.role, 30 | }); 31 | }); 32 | tres.wrap(res, promise); 33 | }; 34 | 35 | var delete_member = function(req, res) { 36 | var user_id = req.user.id; 37 | var space_id = req.params.space_id; 38 | var member_user_id = req.params.user_id; 39 | var client = req.header('X-Turtl-Client'); 40 | var promise = space_model.delete_member(user_id, space_id, member_user_id) 41 | .tap(function() { 42 | analytics.track(user_id, 'space.delete-member', client, { 43 | space_id: space_id, 44 | member_id: member_user_id, 45 | }); 46 | }); 47 | tres.wrap(res, promise); 48 | }; 49 | 50 | var set_owner = function(req, res) { 51 | var user_id = req.user.id; 52 | var space_id = req.params.space_id; 53 | var new_user_id = req.params.new_user_id; 54 | var client = req.header('X-Turtl-Client'); 55 | var promise = space_model.set_owner(user_id, space_id, new_user_id) 56 | .tap(function() { 57 | analytics.track(user_id, 'space.set-owner', client, { 58 | space_id: space_id, 59 | member_id: new_user_id, 60 | }); 61 | }); 62 | tres.wrap(res, promise); 63 | }; 64 | 65 | var send_invite = function(req, res) { 66 | var from_user_id = req.user.id; 67 | var data = req.body; 68 | var space_id = req.params.space_id; 69 | var client = req.header('X-Turtl-Client'); 70 | var promise = invite_model.send(from_user_id, space_id, data) 71 | .tap(function() { 72 | analytics.track(from_user_id, 'space.invite-send', client, { 73 | space_id: space_id, 74 | from: from_user_id, 75 | to: data.to_user, 76 | role: data.role, 77 | has_password: data.has_password, 78 | }); 79 | }); 80 | tres.wrap(res, promise); 81 | }; 82 | 83 | var update_invite = function(req, res) { 84 | var user_id = req.user.id; 85 | var space_id = req.params.space_id; 86 | var invite_id = req.params.invite_id; 87 | var data = req.body; 88 | var client = req.header('X-Turtl-Client'); 89 | var promise = invite_model.update(user_id, space_id, invite_id, data) 90 | .tap(function() { 91 | analytics.track(user_id, 'space.update-invite', client, { 92 | space_id: space_id, 93 | role: data.role, 94 | }); 95 | }); 96 | tres.wrap(res, promise); 97 | }; 98 | 99 | var accept_invite = function(req, res) { 100 | var user_id = req.user.id; 101 | var space_id = req.params.space_id; 102 | var invite_id = req.params.invite_id; 103 | var client = req.header('X-Turtl-Client'); 104 | var promise = invite_model.accept(user_id, space_id, invite_id, function(invite) { 105 | analytics.track(user_id, 'space.invite-accept', client, { 106 | space_id: space_id, 107 | from: invite.from_user_id, 108 | to: invite.to_user, 109 | role: invite.data.role, 110 | is_passphrase_protected: invite.data.is_passphrase_protected, 111 | }); 112 | }); 113 | tres.wrap(res, promise); 114 | }; 115 | 116 | var delete_invite = function(req, res) { 117 | var user_id = req.user.id; 118 | var space_id = req.params.space_id; 119 | var invite_id = req.params.invite_id; 120 | var client = req.header('X-Turtl-Client'); 121 | var promise = invite_model.delete(user_id, space_id, invite_id, function(meta) { 122 | var action = meta.is_invitee ? 'space.invite-decline' : 'space.invite-delete'; 123 | analytics.track(user_id, action, client, {space_id: space_id}); 124 | }); 125 | tres.wrap(res, promise); 126 | }; 127 | 128 | -------------------------------------------------------------------------------- /controllers/sync.js: -------------------------------------------------------------------------------- 1 | var tres = require('../helpers/tres'); 2 | var model = require('../models/sync'); 3 | 4 | exports.route = function(app) { 5 | app.get('/sync', partial_sync); 6 | app.get('/sync/full', full_sync); 7 | app.post('/sync', bulk_sync); 8 | }; 9 | 10 | /** 11 | * Given the current user and a sync-id, spits out all data that has changes in 12 | * the user's profile since that sync id. Used by various clients to stay in 13 | * sync with the canonical profile (hosted on the server). 14 | * 15 | * Unlike the /sync/full call, this is stateful...we are syncing actual profile 16 | * changes here and thus depend on syncing the correct data. A mistake here can 17 | * put bad data into the profile that will sit there until the app clears its 18 | * local data. So we have to be careful to sync exactly what the client needs. 19 | * This is easy for tangible things like editing a note or adding a keychain 20 | * because there is a 1:1 mapping of sync record -> action. When things get 21 | * tricky is for 'share' and 'unshare' sync records: we have to create a bunch 22 | * of fake sync records that add the board(s) and their note(s) to the profile 23 | * and make sure they are injected at the correct place in the sync result. 24 | * 25 | * So in the cases where we're fabricating sync items, we have to be cautious 26 | * to add/remove the correct data or the app is going to have a bad time. 27 | */ 28 | var partial_sync = function(req, res) { 29 | const user_id = req.user.id; 30 | const sync_id = parseInt(req.query.sync_id); 31 | const type = req.query.type; 32 | var immediate = req.query.immediate == '1'; 33 | if(type) immediate = (type != 'poll'); 34 | return model.sync_from(user_id, sync_id, !immediate) 35 | .spread(function(sync_records, latest_sync_id, sync_meta) { 36 | tres.send(res, {records: sync_records, sync_id: latest_sync_id, extra: sync_meta}); 37 | }) 38 | .catch(tres.err.bind(tres, res)); 39 | } 40 | 41 | /** 42 | * Called by the client if a user has no local profile data. Returns the profile 43 | * data in the same format as a sync call, allowing the client to process it the 44 | * same way as regular syncing. 45 | * 46 | * It's important to note that this isn't stateful in the sense that we need to 47 | * gather the correct sync items and send them...what we're doing is pulling out 48 | * all the needed data for the profile and returning it as sync 'add' items. Any 49 | * time the app needs a fresh set of *correct* data it can wipe its local data 50 | * and grab this. 51 | */ 52 | var full_sync = function(req, res) { 53 | var user_id = req.user.id; 54 | return tres.wrap(res, model.full_sync(user_id)); 55 | }; 56 | 57 | /** 58 | * Bulk sync API. Accepts any number of sync items and applies the updates to 59 | * the profile of the authed user. 60 | * 61 | * Note that the items are added in sequence and if any one in the sequence 62 | * fails, we abort and send back the successes and failures. This is because 63 | * many of the items need to be added in a specific sequence in order to work 64 | * correctly (for instance, a keychain entry for a board needs to be synced 65 | * before the board itself). Catching a failure in the sequence allows the 66 | * client to try again whilst still preserving the original order of the sync 67 | * items. 68 | */ 69 | var bulk_sync = function(req, res) { 70 | var user_id = req.user.id; 71 | var client = req.header('X-Turtl-Client'); 72 | var sync_records = req.body; 73 | return tres.wrap(res, model.bulk_sync(user_id, sync_records, client)); 74 | }; 75 | 76 | 77 | -------------------------------------------------------------------------------- /controllers/users.js: -------------------------------------------------------------------------------- 1 | var model = require('../models/user'); 2 | var tres = require('../helpers/tres'); 3 | var config = require('../helpers/config'); 4 | var log = require('../helpers/log'); 5 | var analytics = require('../models/analytics'); 6 | var profile_model = require('../models/profile'); 7 | 8 | exports.route = function(app) { 9 | app.post('/users', join); 10 | app.get('/users/:user_id', get_by_id); 11 | app.get('/users/email/:email', get_by_email); 12 | app.post('/auth', authenticate); 13 | app.get('/users/confirm/:email/:token', confirm_user); 14 | app.delete('/users/:user_id', delete_account); 15 | app.post('/users/confirmation/resend', resend_confirmation); 16 | app.put('/users/:user_id', update_user); 17 | app.get('/users/:user_id/profile-size', get_profile_size); 18 | app.get('/users/delete/:email/:token', delete_by_email); 19 | app.post('/users/delete/:email', start_delete_by_email); 20 | }; 21 | 22 | /** 23 | * create a new user account 24 | */ 25 | var join = function(req, res) { 26 | var client = req.header('X-Turtl-Client'); 27 | var data = req.body; 28 | var promise = model.join(data) 29 | .tap(function(user) { 30 | return analytics.track(user.id, 'user.join', client); 31 | }); 32 | tres.wrap(res, promise); 33 | }; 34 | 35 | var get_by_id = function(req, res) { 36 | var user_id = req.params.user_id; 37 | var cur_user_id = req.user.id; 38 | if(user_id != cur_user_id) { 39 | return tres.err(res, new Error('you can\'t grab another user\'s info')); 40 | } 41 | tres.wrap(res, model.get_by_id(user_id, {data: true, profile_size: true})); 42 | }; 43 | 44 | var get_by_email = function(req, res) { 45 | var email = req.params.email; 46 | var promise = model.get_by_email(email, {data: true}) 47 | .tap(function(user) { 48 | if(!user) return user; 49 | delete user.body; 50 | }); 51 | tres.wrap(res, promise); 52 | }; 53 | 54 | /** 55 | * a basic endpoint specifically for authentication 56 | */ 57 | var authenticate = function(req, res) { 58 | var promise = model.update_last_login(req.user.id) 59 | .then(function() { return req.user.id; }); 60 | tres.wrap(res, promise); 61 | }; 62 | 63 | var confirm_user = function(req, res) { 64 | var email = req.params.email; 65 | var token = req.params.token; 66 | return model.confirm_user(email, token) 67 | .then(function() { 68 | tres.redirect(res, config.app.www_url+'/users/confirm/success', {confirmed: true}); 69 | }) 70 | .catch(function(err) { 71 | if(!err.app_error) log.error('confirm user error: ', err); 72 | tres.redirect(res, config.app.www_url+'/users/confirm/error?err='+encodeURIComponent(err.message), {confirmed: false, error: err.message}); 73 | }); 74 | }; 75 | 76 | var resend_confirmation = function(req, res) { 77 | tres.wrap(res, model.resend_confirmation(req.user.id)); 78 | }; 79 | 80 | /** 81 | * removes a user's account and all data owned by only that user 82 | */ 83 | var delete_account = function(req, res) { 84 | var cur_user_id = req.user.id; 85 | var user_id = req.params.user_id; 86 | var client = req.header('X-Turtl-Client'); 87 | var promise = model.delete(cur_user_id, user_id) 88 | .tap(function() { 89 | analytics.track(user_id, 'user.delete', client, {user_id: user_id}); 90 | }); 91 | tres.wrap(res, promise); 92 | }; 93 | 94 | /** 95 | * edit a user. requires a username, an auth token, and the user's entire 96 | * (encrypted) keychain. this specifically goes outside of the sync system 97 | * because this is a change that must be ALL OR NOTHING. 98 | */ 99 | var update_user = function(req, res) { 100 | var cur_user_id = req.user.id; 101 | var user_id = req.params.user_id; 102 | var data = req.body; 103 | tres.wrap(res, model.update(cur_user_id, user_id, data)); 104 | }; 105 | 106 | /** 107 | * grab the current user's profile size in bytes, along with their usage 108 | * percentage 109 | */ 110 | var get_profile_size = function(req, res) { 111 | var cur_user_id = req.user.id; 112 | var user_id = req.params.user_id; 113 | if(user_id != cur_user_id) { 114 | return tres.err(res, new Error('you can\'t get another user\'s profile data')); 115 | } 116 | tres.wrap(res, profile_model.get_profile_size(cur_user_id)); 117 | }; 118 | 119 | const delete_by_email = function(req, res) { 120 | const email = req.params.email; 121 | const token = req.params.token; 122 | const raw = req.query.raw || false; 123 | const promise = model.delete_by_email(email, token); 124 | if(raw) { 125 | return tres.wrap(res, promise); 126 | } 127 | promise 128 | .then(function() { 129 | tres.redirect(res, config.app.www_url+'/users/delete/success/', {confirmed: true}); 130 | }) 131 | .catch(function(err) { 132 | if(!err.app_error) log.error('confirm user error: ', err); 133 | tres.redirect(res, config.app.www_url+'/users/delete/error/?err='+encodeURIComponent(err.message), {confirmed: false, error: err.message}); 134 | }); 135 | }; 136 | 137 | const start_delete_by_email = function(req, res) { 138 | const email = req.params.email; 139 | tres.wrap(res, model.start_delete_by_email(email)); 140 | }; 141 | 142 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | turtl-server: 5 | build: 6 | context: ./ 7 | environment: 8 | TURTL_DB_HOST: postgres-db 9 | TURTL_DB_PORT: 5432 10 | TURTL_DB_DATABASE: turtl 11 | TURTL_DB_USER: turtl 12 | TURTL_DB_PASSWORD: turtl 13 | TURTL_APP_SECURE_HASH_SALT: abuHassN892MMn 14 | ports: 15 | - 8181:8181 16 | depends_on: 17 | postgres-db: 18 | condition: service_healthy 19 | 20 | postgres-db: 21 | image: postgres:11-alpine 22 | environment: 23 | POSTGRES_PASSWORD: turtl 24 | POSTGRES_USER: turtl 25 | POSTGRES_DB: turtl 26 | healthcheck: 27 | test: ["CMD-SHELL", "pg_isready -U turtl"] 28 | interval: 10s 29 | timeout: 5s 30 | retries: 5 31 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -z "${TURTL_APP_SECURE_HASH_SALT}" ]]; then 4 | echo "TURTL_APP_SECURE_HASH_SALT is unset." 5 | exit 1 6 | fi 7 | 8 | ./scripts/init-db.sh 9 | node server.js $@ 10 | -------------------------------------------------------------------------------- /example-plugins/email/main.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const nodemailer = require('nodemailer'); 3 | const log = require('../../helpers/log'); 4 | 5 | var config = {}; 6 | var transporter = null; 7 | 8 | exports.load = function(register, plugin_config) { 9 | config = plugin_config; 10 | if(!config.enabled) return; 11 | if(!config.endpoint) return; 12 | transporter = nodemailer.createTransport(config.endpoint, config.defaults); 13 | register({ 14 | send: send, 15 | }); 16 | }; 17 | 18 | function send(from, to, subject, body) { 19 | return new Promise(function(resolve, reject) { 20 | if(!config.enabled) return resolve({email_disabled: true}); 21 | 22 | var data = { 23 | from: from, 24 | to: to, 25 | subject: subject, 26 | text: body, 27 | }; 28 | 29 | transporter.sendMail(data, function(err, res) { 30 | console.log('ret: ', err, res); 31 | if(err) return reject(err); 32 | resolve(res); 33 | }); 34 | }); 35 | }; 36 | 37 | -------------------------------------------------------------------------------- /example-plugins/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turtl-server-plugins", 3 | "version": "0.1.0", 4 | "description": "PLUGINS FOR Turtl's node-based API server", 5 | "author": "Andrew Lyon", 6 | "license": "AGPL-3.0", 7 | "repository": "https://github.com/turtl/server", 8 | "dependencies": { 9 | "bluebird": "3.4.7", 10 | "nodemailer": "4.6.8" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /helpers/auth.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var user_model = require('../models/user'); 4 | var tres = require('./tres'); 5 | 6 | function add_public_route(routespec) { 7 | public_routes.push(new RegExp('^'+routespec+'$')); 8 | }; 9 | exports.add_public_route = add_public_route; 10 | 11 | var public_routes = []; 12 | [ 13 | 'get /', 14 | 'post /users', 15 | 'get /users/confirm/[^/]+/[a-f0-9]+', 16 | 'post /cla/sign', 17 | 'get /health/[a-z0-9]+', 18 | 'get /users/delete/[^/]+/[a-f0-9]+', 19 | 'post /users/delete/[^/]+', 20 | ].map(add_public_route); 21 | 22 | exports.verify = function(req, res, next) { 23 | if(req.method == 'OPTIONS') return next(); 24 | var auth = req.headers.authorization; 25 | // see if we have a public route 26 | var method_url = req.method.toLowerCase()+' '+req.url; 27 | for(var i = 0, n = public_routes.length; i < n; i++) { 28 | var pub = public_routes[i]; 29 | if(pub.test(method_url)) return next(); 30 | } 31 | return user_model.check_auth(auth) 32 | .then(function(user) { 33 | req.user = user; 34 | next(); 35 | }) 36 | .catch(function(err) { 37 | tres.err(res, err); 38 | }); 39 | }; 40 | 41 | -------------------------------------------------------------------------------- /helpers/config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const log = require('./log'); 4 | const yaml_env = require('yaml-env'); 5 | const URL = require('url'); 6 | 7 | var config_file = 'config.yaml'; 8 | if(process.env['TURTL_CONFIG_FILE']) { 9 | config_file = process.env['TURTL_CONFIG_FILE']; 10 | } 11 | var config = yaml_env.load('TURTL', __dirname+'/../config/'+config_file); 12 | 13 | var db_url = process.env['DATABASE_URL']; 14 | if(db_url && db_url.match(/^postgres:/)) { 15 | var url = URL.parse(db_url); 16 | // to: from 17 | var copy = { 18 | 'host': 'hostname', 19 | 'port': 'port', 20 | 'database': 'pathname', 21 | 'user': 'auth', 22 | 'password': 'auth', 23 | }; 24 | Object.keys(copy).forEach(function(key_to) { 25 | var key_from = copy[key_to]; 26 | var urlval = url[key_from]; 27 | if(urlval) { 28 | if(key_from == 'pathname') { 29 | urlval = urlval.split('/')[1]; 30 | } 31 | if(key_from == 'auth' && key_to == 'user') { 32 | urlval = urlval.split(':')[0]; 33 | } 34 | if(key_from == 'auth' && key_to == 'password') { 35 | urlval = urlval.split(':')[1]; 36 | } 37 | config.db[key_to] = urlval; 38 | } 39 | }); 40 | } 41 | if(process.env['PORT']) { 42 | config.server.port = parseInt(process.env['PORT']); 43 | } 44 | if(process.env['TURTL_CONFIG_OVERRIDE']) { 45 | try { 46 | var override = JSON.parse(process.env['TURTL_CONFIG_OVERRIDE']); 47 | Object.keys(override).forEach(function(key) { 48 | config[key] = override[key]; 49 | }); 50 | } catch(e) { 51 | log.warn('config -- error parsing TURTL_CONFIG_OVERRIDE: ', e); 52 | } 53 | } 54 | module.exports = config; 55 | 56 | -------------------------------------------------------------------------------- /helpers/cors.js: -------------------------------------------------------------------------------- 1 | var allowed_headers = [ 2 | 'Authorization', 3 | 'Content-Type', 4 | 'Accept', 5 | 'Origin', 6 | 'User-Agent', 7 | 'DNT', 8 | 'Cache-Control', 9 | 'X-Mx-ReqToken', 10 | 'Keep-Alive', 11 | 'X-Requested-With', 12 | 'If-Modified-Since', 13 | 'X-Turtl-Client', 14 | ].join(','); 15 | 16 | module.exports = function(req, res, next) { 17 | res.header('Access-Control-Allow-Origin', '*'); 18 | res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,PATCH,OPTIONS'); 19 | res.header('Access-Control-Allow-Headers', allowed_headers); 20 | next(); 21 | }; 22 | 23 | -------------------------------------------------------------------------------- /helpers/db.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * This file provides a very simple CRUD model for querying and saving data in 5 | * postgres. note that the upsert function *requires* postgres >= 9.5. 6 | */ 7 | 8 | const config = require('./config'); 9 | const pg = require('pg'); 10 | const Promise = require('bluebird'); 11 | const log = require('./log'); 12 | const util = require('./util'); 13 | 14 | // create a connection string TAILORED TO YOUR SPECIFIC NEEDS 15 | if(config.db.connstr) { 16 | var connection = config.db.connstr; 17 | } else { 18 | var connection = 'postgres://'+config.db.user+(config.db.password ? ':'+config.db.password : '')+'@'+config.db.host+':'+config.db.port+'/'+config.db.database; 19 | } 20 | 21 | const pool = new pg.Pool({connectionString: connection}); 22 | pool.on('error', function(err, client) { 23 | log.error('pg.Pool() -- ', err); 24 | }); 25 | 26 | /** 27 | * clean db literal strings 28 | */ 29 | var clean = function(lit) { return lit.replace(/[^0-9a-z_"-]/g, ''); }; 30 | 31 | /** 32 | * stringifies data for json storage 33 | */ 34 | exports.json = function(data) { 35 | if(data === undefined) return null; 36 | return JSON.stringify(data); 37 | }; 38 | 39 | /** 40 | * build a query by replacing templated values inside of it with positional 41 | * markers that can be handed off to postgres. 42 | * 43 | * SELECT question FROM jokes WHERE punchline = {{punchline}} AND {{where|raw}} OR date < {{now}} 44 | * {punchline: 'your mom', where: 'num_uses < 5', now: db.literal('now()')} 45 | * 46 | * into 47 | * 48 | * SELECT question FROM jokes WHERE punchline = $1 AND num_uses < 5 OR date < now() 49 | * ['your mom'] 50 | * 51 | * note that there are two ways of specifying literal values...one within the 52 | * query string itself {{varname|raw}} and one withing the actual query_data, 53 | * via {varname: db.literal('now()')} 54 | */ 55 | var builder = function(qry, query_data) { 56 | query_data || (query_data = {}); 57 | var val_arr = []; 58 | qry = qry.replace(/\{\{([0-9a-z_-]+)(\|raw)?\}\}/gi, function(_, key, raw) { 59 | var val = (typeof(query_data[key]) == 'undefined' ? '' : query_data[key]); 60 | // return literal values verbatim 61 | if(val && val._omg_literally) return val._omg_literally; 62 | 63 | // do some data massaging 64 | if(val !== null) { 65 | if(typeof(val) == 'object') val = exports.json(val); 66 | else val = val.toString(); 67 | } 68 | 69 | // return raw values directly into the query 70 | if(raw) return val; 71 | 72 | // not literal, not a raw, run the query replacerment and push the val 73 | // onto our val_arr 74 | val_arr.push(val); 75 | return '$'+(val_arr.length); 76 | }); 77 | return {query: qry, vals: val_arr}; 78 | }; 79 | 80 | // use this to wrap your arguments to be injected as literals. literally. 81 | exports.literal = function(val) { return {_omg_literally: val}; }; 82 | 83 | var make_client = function(client, release) { 84 | return { 85 | query: function(qry, query_data, options) { 86 | options || (options = {}); 87 | var query_type = options.type; 88 | var built = builder(qry, query_data); 89 | var built_qry = built.query; 90 | var vals = built.vals; 91 | 92 | log.debug('db: query: ', built_qry, vals); 93 | return new Promise(function(resolve, reject) { 94 | client.query(built_qry, vals, function(err, result) { 95 | if(err) return reject(err); 96 | if((query_type || result.command).toLowerCase() == 'select') { 97 | resolve(result.rows); 98 | } else { 99 | resolve(result); 100 | } 101 | }); 102 | }); 103 | }, 104 | 105 | close: function() { 106 | return release(); 107 | } 108 | }; 109 | }; 110 | 111 | exports.client = function() { 112 | return new Promise(function(resolve, reject) { 113 | pool.connect(function(err, client, release) { 114 | if(err) return reject(err); 115 | resolve(make_client(client, release)); 116 | }); 117 | }); 118 | }; 119 | 120 | /** 121 | * run a query, using a pooled connection, and return the result as a finished 122 | * promise. 123 | */ 124 | exports.query = function(qry, query_data, options) { 125 | var client = null; 126 | return exports.client() 127 | .then(function(_client) { 128 | client = _client; 129 | return client.query(qry, query_data, options); 130 | }) 131 | .finally(function() { 132 | return client && client.close(); 133 | }); 134 | }; 135 | 136 | /** 137 | * wraps query(), pulls out the first record 138 | */ 139 | exports.first = function(qry, query_data, options) { 140 | options || (options = {}); 141 | return exports.query(qry, query_data, options) 142 | .then(function(res) { return res[0]; }); 143 | }; 144 | 145 | /** 146 | * get an item by id 147 | */ 148 | exports.by_id = function(table, id, options) { 149 | options || (options = {}); 150 | var fields = options.fields; 151 | 152 | var qry_fields = fields ? fields.map(clean) : ['*']; 153 | return exports.first('SELECT '+qry_fields.join(',')+' FROM '+clean(table)+' WHERE id = {{id}} LIMIT 1', {id: id}); 154 | }; 155 | 156 | /** 157 | * grab items from a table by id 158 | */ 159 | exports.by_ids = function(table, ids, options) { 160 | options || (options = {}); 161 | var fields = options.fields; 162 | var id_field = options.id_field || 'id'; 163 | 164 | // make sure a blank id list returns a blank result set =] 165 | if(!ids || ids.length == 0) return Promise.resolve([]); 166 | 167 | var id_data = {}; 168 | var qry_ids = []; 169 | ids.forEach(function(id, i) { 170 | id_data['--id-'+i] = id; 171 | qry_ids.push('{{--id-'+i+'}}') 172 | }); 173 | var qry_fields = fields ? fields.map(clean) : ['*']; 174 | return exports.query('SELECT '+qry_fields.join(',')+' FROM '+clean(table)+' WHERE '+clean(id_field)+' IN ( '+qry_ids.join(',')+' )', id_data); 175 | }; 176 | 177 | /** 178 | * build a (possibly bulk) insert query, given a data object OR an array of data 179 | * objects lol 180 | */ 181 | var build_insert = function(table, data) { 182 | if(!Array.isArray(data)) data = [data]; 183 | else if(data.length == 0) throw new Error('empty data given to db.build_insert'); 184 | 185 | var keys = Object.keys(data[0]); 186 | var qry_keys = keys.map(function(k) { return '"'+clean(k)+'"'; }); 187 | var qry_vals = []; 188 | data.forEach(function(_, rownum) { 189 | qry_vals.push('('+keys.map(function(_, i) { return '{{--insert-val-row'+rownum+'-'+i+'}}'; })+')'); 190 | }); 191 | 192 | var vals = {}; 193 | data.forEach(function(row, rownum) { 194 | keys.forEach(function(key, i) { 195 | vals['--insert-val-row'+rownum+'-'+i] = row[key]; 196 | }); 197 | }); 198 | var qry = 'INSERT INTO '+clean(table)+' ('+qry_keys.join(',')+') VALUES '+qry_vals.join(','); 199 | return {query: qry, vals: vals}; 200 | }; 201 | 202 | /** 203 | * insert an object into the given table. if `data` is an array, will do a bulk 204 | * insert and return ALL inserted data. if `data` is a plain old object, then it 205 | * just does the one insert and returns just one data object. adaptive. smart. 206 | * stylish. don't leave home without the insert function in your pocket. 207 | * 208 | * to learn more about this operation, see https://youtu.be/AW-iVH9xIEs?t=1m1s 209 | */ 210 | exports.insert = function(table, data) { 211 | try { 212 | var built = build_insert(table, data); 213 | } catch(err) { 214 | return Promise.reject(err); 215 | } 216 | var qry = built.query+' RETURNING '+clean(table)+'.*;'; 217 | return exports.query(qry, built.vals, {type: 'select'}) 218 | .then(function(res) { 219 | if(Array.isArray(data)) return res; 220 | else return res[0]; 221 | }); 222 | }; 223 | 224 | /** 225 | * update an object in a table by id. 226 | */ 227 | exports.update = function(table, id, data) { 228 | var qry_sets = Object.keys(data).map(function(key) { 229 | return key+' = {{'+key+'}}'; 230 | }); 231 | qry_sets.push('updated = NOW()'); 232 | var qry = 'UPDATE '+clean(table)+' SET '+qry_sets.join(', ')+' WHERE '+clean('id')+' = {{id}} RETURNING *'; 233 | var copy = util.clone(data); 234 | copy.id = id; 235 | return exports.query(qry, copy, {type: 'select'}) 236 | .then(function(res) { return res[0]; }); 237 | }; 238 | 239 | /** 240 | * does an upsert and returns the latest version of the object (whether inserted 241 | * or updated). requires postgres >= 9.5. 242 | * 243 | * does not support bulk upserts SO EVERYONE STOP FUCKING ASKING ABOUT IT 244 | */ 245 | exports.upsert = function(table, data, key) { 246 | if(!data[key]) return Promise.reject(new Error('db: upsert: `key` field not present in `data`')); 247 | if(Array.isArray(data)) return Promise.reject(new Error('db: upsert: `data` cannot be an array.')); 248 | 249 | var keys = Object.keys(data); 250 | try 251 | { 252 | var built = build_insert(table, data); 253 | } 254 | catch(err) 255 | { 256 | return Promise.reject(err); 257 | } 258 | var qry = built.query; 259 | var vals = built.vals; 260 | 261 | qry += ' ON CONFLICT ('+clean(key)+') '; 262 | qry += 'DO UPDATE SET '; 263 | qry += keys.map(function(col, i) { 264 | var tplvar = '--upsert-var-'+i; 265 | vals[tplvar] = data[col]; 266 | return col+' = {{'+tplvar+'}}' 267 | }).join(', '); 268 | qry += ', updated = NOW()'; 269 | qry += ' RETURNING '+clean(table)+'.*;'; 270 | 271 | return exports.query(qry, vals, {type: 'select'}) 272 | .then(function(res) { 273 | return res[0]; 274 | }); 275 | }; 276 | 277 | /** 278 | * delete an object by id 279 | */ 280 | exports.delete = function(table, id) { 281 | return exports.query('DELETE FROM '+clean(table)+' WHERE id = {{id}}', {id: id}); 282 | }; 283 | 284 | -------------------------------------------------------------------------------- /helpers/error.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var make_err_fn = function(status) { 4 | return function(msg, extra) { 5 | var err = new Error(msg); 6 | err.status = status; 7 | err.app_error = true; 8 | err.extra = extra || false; 9 | return err; 10 | }; 11 | }; 12 | 13 | exports.bad_request = make_err_fn(400); 14 | exports.unauthorized = make_err_fn(401); 15 | exports.payment_required = make_err_fn(402); 16 | exports.forbidden = make_err_fn(403); 17 | exports.not_found = make_err_fn(404); 18 | exports.conflict = make_err_fn(409); 19 | 20 | exports.internal = make_err_fn(500); 21 | 22 | // some utils for skipping over promise chains 23 | exports.promise_throw = function(reason, data) { 24 | var obj = {}; 25 | obj[reason] = data || true; 26 | throw obj; 27 | }; 28 | exports.promise_catch = function(reason) { 29 | return function(obj) { 30 | return typeof(obj[reason]) != 'undefined'; 31 | }; 32 | }; 33 | 34 | -------------------------------------------------------------------------------- /helpers/log.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var winston = require('winston'); 4 | var config = require('./config'); 5 | 6 | winston.exitOnError = false; 7 | winston.level = config.loglevel; 8 | module.exports = winston; 9 | 10 | -------------------------------------------------------------------------------- /helpers/plugins.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const plugins = {}; 3 | 4 | exports.register = function(name, spec) { 5 | plugins[name] = spec; 6 | }; 7 | 8 | exports.with = function(name, exists_fn, no_exists_fn) { 9 | var plugin = plugins[name]; 10 | if(plugin) { 11 | return exists_fn(plugin); 12 | } else { 13 | return no_exists_fn ? no_exists_fn() : Promise.resolve(); 14 | } 15 | }; 16 | 17 | -------------------------------------------------------------------------------- /helpers/tres.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var log = require('./log'); 4 | 5 | exports.send = function(res, data, options) { 6 | options || (options = {}); 7 | var status = options.status || 200; 8 | var content = options.content_type || 'application/json'; 9 | res.setHeader('Content-Type', content); 10 | return res.status(status).send(options.raw ? data : JSON.stringify(data)); 11 | }; 12 | 13 | exports.redirect = function(res, url, data, options) { 14 | options || (options = {}); 15 | var status = options.status || 307; 16 | var content = options.content_type || 'application/json'; 17 | res.setHeader('Content-Type', content); 18 | res.setHeader('Location', url); 19 | return res.status(status).send(options.raw ? data : JSON.stringify(data)); 20 | }; 21 | 22 | exports.err = function(res, err, options) { 23 | options || (options = {}); 24 | err || (err = {}); 25 | var status = options.status || err.status || 500; 26 | var content = options.content_type || 'application/json'; 27 | res.setHeader('Content-Type', content); 28 | var errobj = { 29 | error: {message: err.message} 30 | }; 31 | if(err.extra) errobj.error.extra = err.extra; 32 | var uid = null; 33 | try { uid = res.req.user.id; } catch(_) {} 34 | log.error('tres.err -- (uid '+uid+'):', status == 500 ? err : err.message); 35 | return res.status(status).send(JSON.stringify(errobj)); 36 | }; 37 | 38 | exports.wrap = function(res, promise, options) { 39 | return promise 40 | .then(function(data) { 41 | return exports.send(res, data, options); 42 | }) 43 | .catch(function(err) { 44 | return exports.err(res, err, options); 45 | }); 46 | }; 47 | 48 | -------------------------------------------------------------------------------- /helpers/util.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var Promise = require('bluebird'); 4 | 5 | /** 6 | * Run a deep clone of any JSON-serializable object herrp 7 | */ 8 | exports.clone = function(data) { 9 | return JSON.parse(JSON.stringify(data)); 10 | }; 11 | 12 | /** 13 | * Dedupe the values in an array 14 | */ 15 | exports.dedupe = function(arr) { 16 | var seen = {}; 17 | return arr.filter(function(item) { 18 | if(seen[item]) return false; 19 | seen[item] = true; 20 | return true; 21 | }); 22 | }; 23 | 24 | /** 25 | * Flatten a multi-dimensional array 26 | */ 27 | exports.flatten = function(arr, options, cur_level) { 28 | options || (options = {}); 29 | cur_level || (cur_level = 0); 30 | var max_level = options.max_level || 3; 31 | if(!Array.isArray(arr)) return arr; 32 | if(cur_level > max_level) return arr; 33 | var flattened = []; 34 | arr.forEach(function(item) { 35 | if(Array.isArray(item)) { 36 | flattened = flattened.concat(exports.flatten(item, options, cur_level + 1)); 37 | } else { 38 | flattened.push(item); 39 | } 40 | }); 41 | return flattened; 42 | }; 43 | 44 | /** 45 | * A promise-based delay 46 | */ 47 | exports.delay = function(ms, val) { 48 | return new Promise(function(resolve) { 49 | setTimeout(function() { 50 | resolve(val); 51 | }, ms); 52 | }); 53 | }; 54 | 55 | -------------------------------------------------------------------------------- /helpers/validator.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * vlad the validator 5 | */ 6 | 7 | var error = require('./error'); 8 | 9 | var mappings = {}; 10 | 11 | var types = { 12 | server_id: function(e) { return types.int(d); }, 13 | client_id: function(d) { return d.toString().match(/^[a-f0-9]+$/i); }, 14 | int: function(d) { return !!parseInt(d); }, 15 | array: function(d) { return Array.isArray(d); }, 16 | string: function(d) { return typeof(d) == 'string'; }, 17 | email: function(d) { return d.toString().match(/.@./); }, 18 | object: function(d) { return typeof(d) == 'object' && !Array.isArray(d); }, 19 | float: function(d) { return !!parseFloat(d); }, 20 | bool: function(d) { return d === true || d === false; }, 21 | // recursive vlad type 22 | vlad: function(type) { 23 | return function(d) { return exports.validate(type, d); }; 24 | }, 25 | }; 26 | exports.type = types; 27 | 28 | exports.define = function(type, mapping) { 29 | mappings[type] = mapping; 30 | }; 31 | 32 | /** 33 | * validate an object type against a set of data 34 | */ 35 | exports.validate = function(type, data) { 36 | var mapping = mappings[type]; 37 | if(!mapping) throw new error.internal('unknown validation type: `'+type+'`'); 38 | if(!data) throw new error.internal('bad data passed to validator: '+typeof(data)); 39 | Object.keys(mapping).forEach(function(map_key) { 40 | var field = mapping[map_key]; 41 | var val = data[map_key]; 42 | // treat null/undefined as the same 43 | var is_empty = (val === undefined || val === null); 44 | // if required and missing, complain 45 | if(field.required && is_empty) { 46 | throw new error.bad_request(type+' object failed validation: missing required field `'+map_key+'`'); 47 | } 48 | // if missing and not required, set default if we have it, otherwise 49 | // nothing to see here 50 | if(is_empty) { 51 | if(field.default) { 52 | if(field.default instanceof Function) { 53 | data[map_key] = field.default(data); 54 | } else { 55 | data[map_key] = field.default; 56 | } 57 | } 58 | return; 59 | } 60 | 61 | // if we have a type mismatch, complain 62 | if(!field.type(val)) { 63 | throw new error.bad_request(type+' object failed validation: field `'+map_key+'` is not the right type'); 64 | } 65 | }); 66 | Object.keys(data).forEach(function(data_key) { 67 | // remove data that's not in our schema 68 | if(!mapping[data_key]) delete data[data_key]; 69 | }); 70 | return data; 71 | }; 72 | 73 | -------------------------------------------------------------------------------- /models/analytics.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var Promise = require('bluebird'); 4 | var plugins = require('../helpers/plugins'); 5 | var config = require('../helpers/config'); 6 | var log = require('../helpers/log'); 7 | 8 | /** 9 | * Track an analytics event 10 | */ 11 | exports.track = function(user_id, action, client, data) { 12 | return plugins.with('analytics', function(analytics) { 13 | return analytics.track(user_id, action, client, data); 14 | }, Promise.resolve); 15 | }; 16 | 17 | /** 18 | * Lets analytics know about a new user 19 | */ 20 | exports.join = function(user_id, userdata) { 21 | return plugins.with('analytics', function(analytics) { 22 | return analytics.join(user_id, userdata); 23 | }, Promise.resolve); 24 | }; 25 | 26 | -------------------------------------------------------------------------------- /models/board.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var db = require('../helpers/db'); 4 | var sync_model = require('./sync'); 5 | var space_model = require('./space'); 6 | var note_model = require('./note'); 7 | var vlad = require('../helpers/validator'); 8 | var util = require('../helpers/util'); 9 | var Promise = require('bluebird'); 10 | 11 | vlad.define('board', { 12 | id: {type: vlad.type.client_id, required: true}, 13 | space_id: {type: vlad.type.client_id, required: true}, 14 | user_id: {type: vlad.type.int, required: true}, 15 | keys: {type: vlad.type.array}, 16 | body: {type: vlad.type.string}, 17 | }); 18 | 19 | /** 20 | * get a board's data by id 21 | */ 22 | var get_by_id = function(board_id) { 23 | return db.by_id('boards', board_id) 24 | .then(function(board) { return board && board.data; }); 25 | }; 26 | 27 | exports.get_by_spaces = function(space_ids) { 28 | if(space_ids.length == 0) return Promise.resolve([]); 29 | return db.by_ids('boards', space_ids, {id_field: 'space_id'}) 30 | .then(function(boards) { 31 | return boards.map(function(b) { return b.data; }); 32 | }); 33 | }; 34 | 35 | exports.get_by_space_id = function(space_id) { 36 | return exports.get_by_spaces([space_id]); 37 | }; 38 | 39 | var add = space_model.simple_add( 40 | 'board', 41 | 'boards', 42 | space_model.permissions.add_board, 43 | function(data) { return {id: data.id, space_id: data.space_id, data: db.json(data)}; } 44 | ); 45 | 46 | var edit = space_model.simple_edit( 47 | 'board', 48 | 'boards', 49 | space_model.permissions.edit_board, 50 | get_by_id, 51 | function(data) { return {id: data.id, space_id: data.space_id, data: db.json(data)}; } 52 | ); 53 | 54 | var del = space_model.simple_delete( 55 | 'board', 56 | 'boards', 57 | space_model.permissions.delete_board, 58 | get_by_id 59 | ); 60 | exports.delete_board = del; 61 | 62 | // NOTE: we don't move the notes in the post_move function because we need to 63 | // re-encrypt the notes once they move to the new space (with the new space's 64 | // key), and that cannot happen server side. 65 | var move_space = space_model.simple_move_space( 66 | 'board', 67 | 'boards', 68 | space_model.permissions.delete_board, 69 | space_model.permissions.add_board, 70 | get_by_id 71 | ); 72 | 73 | var link = function(ids) { 74 | return db.by_ids('boards', ids, {fields: ['data']}) 75 | .then(function(items) { 76 | return items.map(function(i) { return i.data;}); 77 | }); 78 | }; 79 | 80 | sync_model.register('board', { 81 | 'add': add, 82 | 'edit': edit, 83 | 'delete': del, 84 | 'move-space': move_space, 85 | 'link': link, 86 | }); 87 | 88 | -------------------------------------------------------------------------------- /models/bookmark.js: -------------------------------------------------------------------------------- 1 | var Promise = require('bluebird'); 2 | var request = require('request'); 3 | 4 | exports.proxy_url = function(url) { 5 | return new Promise(function(resolve, reject) { 6 | request({uri: url, method: 'get'}, function(err, res) { 7 | if(err) return reject(err); 8 | resolve(res.body); 9 | }); 10 | }); 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /models/cla.js: -------------------------------------------------------------------------------- 1 | var db = require('../helpers/db'); 2 | var config = require('../helpers/config'); 3 | var Promise = require('bluebird'); 4 | var error = require('../helpers/error'); 5 | var vlad = require('../helpers/validator'); 6 | var email_model = require('./email'); 7 | 8 | vlad.define('cla', { 9 | type: {type: vlad.type.string, required: true}, 10 | entity: {type: vlad.type.string}, 11 | fullname: {type: vlad.type.string, required: true}, 12 | email: {type: vlad.type.string, required: true}, 13 | address1: {type: vlad.type.string, required: true}, 14 | address2: {type: vlad.type.string}, 15 | city: {type: vlad.type.string, required: true}, 16 | state: {type: vlad.type.string}, 17 | zip: {type: vlad.type.string}, 18 | country: {type: vlad.type.string, required: true}, 19 | github: {type: vlad.type.string, required: true}, 20 | sign: {type: vlad.type.string, required: true}, 21 | }); 22 | 23 | exports.sign = function(sig) { 24 | if(sig.sign != 'I AGREE') { 25 | return Promise.reject(error.bad_request('Please type \"I AGREE\" into the signature field.')) 26 | } 27 | if(sig.type == 'ecla' && sig.entity == '') { 28 | return Promise.reject(error.bad_request('Please enter the Company/Organization/Entity name.')); 29 | } 30 | try { 31 | sig = vlad.validate('cla', sig); 32 | } catch(err) { 33 | return Promise.reject(err); 34 | } 35 | return db.insert('cla', {fullname: sig.fullname, email: sig.email, sigdata: db.json(sig)}) 36 | .then(function() { 37 | var subject = 'CLA signature'; 38 | var body = [ 39 | 'Someone signed the CLA:', 40 | '', 41 | ].concat(Object.keys(sig).map(function(key) { return key+': '+sig[key]; })); 42 | return email_model.send('cla@turtlapp.com', config.app.emails.admin, subject, body.join('\n')); 43 | }); 44 | }; 45 | 46 | -------------------------------------------------------------------------------- /models/email.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Promise = require('bluebird'); 4 | const plugins = require('../helpers/plugins'); 5 | 6 | exports.send = function(from, to, subject, body) { 7 | return plugins.with('email', function(email) { 8 | return email.send(from, to, subject, body); 9 | }, Promise.resolve); 10 | }; 11 | 12 | -------------------------------------------------------------------------------- /models/errlog.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var Promise = require('bluebird'); 4 | var crypto = require('crypto'); 5 | var db = require('../helpers/db'); 6 | 7 | var hash_log = function(logdata) { 8 | var ensure_string = function(x) { return typeof(x) == 'string' ? x : x.toString(); }; 9 | var hashable = [ 10 | logdata.msg, 11 | logdata.url, 12 | logdata.line, 13 | logdata.version, 14 | ].map(ensure_string).join(''); 15 | return crypto.createHash('md5').update(hashable).digest('hex'); 16 | }; 17 | 18 | exports.log_error = function(logdata) { 19 | if(typeof(logdata) == 'string') { 20 | try { 21 | logdata = JSON.parse(logdata); 22 | } catch(e) { 23 | return Promise.reject(e); 24 | } 25 | } 26 | if(!logdata) return Promise.resolve({}); 27 | var client_version = logdata.version; 28 | if(!client_version) return Promise.resolve({}); 29 | logdata.url = logdata.url.replace(/^.*\/data\/app/, '/data/app'); 30 | var hash = hash_log(logdata); 31 | return db.upsert('errorlog', {id: hash, data: logdata}, 'id') 32 | .then(function() { 33 | return {hash: hash}; 34 | }); 35 | }; 36 | 37 | -------------------------------------------------------------------------------- /models/feedback.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var email_model = require('./email'); 4 | var error = require('../helpers/error'); 5 | var config = require('../helpers/config'); 6 | var Promise = require('bluebird'); 7 | 8 | exports.send = function(user_id, username, client, data) { 9 | var body = data.body || false; 10 | if(!body) return Promise.reject(error.bad_request('no feedback given')); 11 | var subject = 'New Turtl feedback from '+username+' ('+user_id+')'; 12 | var email_body = [ 13 | 'You have received feedback from '+username+' (user id '+user_id+', client '+client+'):', 14 | '', 15 | '************', 16 | '', 17 | body, 18 | ]; 19 | return email_model.send(username, config.app.emails.admin, subject, email_body.join('\n')) 20 | .then(function() { 21 | return true; 22 | }) 23 | .catch(function(err) { 24 | throw error.internal('problem sending confirmation email: '+err.message); 25 | }); 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /models/file.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var Promise = require('bluebird'); 4 | var config = require('../helpers/config'); 5 | var error = require('../helpers/error'); 6 | var fs = require('fs'); 7 | var AWS = require('aws-sdk'); 8 | AWS.config.update({ 9 | accessKeyId: config.s3.token, 10 | secretAccessKey: config.s3.secret, 11 | s3: { 12 | endpoint: config.s3.endpoint, 13 | s3ForcePathStyle: config.s3.pathstyle, 14 | }, 15 | }); 16 | var s3_stream = require('s3-upload-stream')(new AWS.S3()); 17 | 18 | /** 19 | * returns the uploading interface for a local file 20 | */ 21 | var upload_local = function(file_id) { 22 | var stream = fs.createWriteStream(config.uploads.local+'/'+file_id); 23 | // mimick the s3 uploader's event 24 | stream.on('finish', function() { 25 | stream.emit('uploaded'); 26 | }); 27 | return stream; 28 | }; 29 | 30 | /** 31 | * returns the uploading interface for storing on S3 32 | */ 33 | var upload_s3 = function(file_id) { 34 | return s3_stream.upload({ 35 | Bucket: config.s3.bucket, 36 | ACL: 'private', 37 | ContentType: 'application/octet-stream', 38 | Key: 'files/'+file_id, 39 | }); 40 | }; 41 | 42 | /** 43 | * returns the url for a local upload 44 | */ 45 | var geturl_local = function(file_id) { 46 | return Promise.resolve(config.uploads.url+'/'+file_id); 47 | }; 48 | 49 | /** 50 | * returns the url for an s3 upload 51 | */ 52 | var geturl_s3 = function(file_id) { 53 | var params = { 54 | Bucket: config.s3.bucket, 55 | Key: 'files/'+file_id, 56 | Expires: 900 57 | }; 58 | var s3 = new AWS.S3(); 59 | return Promise.resolve(s3.getSignedUrl('getObject', params)); 60 | }; 61 | 62 | /** 63 | * deletes a file locally, returns a Promise resolving when finished 64 | */ 65 | var delete_local = function(file_id) { 66 | return new Promise(function(resolve, reject) { 67 | fs.unlink(config.uploads.local+'/'+file_id, function(err, _) { 68 | if(err && !err.message.match(/ENOENT/)) { 69 | return reject(err); 70 | } 71 | resolve(true); 72 | }); 73 | }); 74 | }; 75 | 76 | /** 77 | * deletes a file on s3, returns a Promise resolving when finished 78 | */ 79 | var delete_s3 = function(file_id) { 80 | return new Promise(function(resolve, reject) { 81 | var params = { 82 | Bucket: config.s3.bucket, 83 | Key: 'files/'+file_id, 84 | }; 85 | var s3 = new AWS.S3(); 86 | s3.deleteObject(params, function(err, _) { 87 | if(err) return reject(err); 88 | resolve(true); 89 | }); 90 | }); 91 | }; 92 | 93 | /** 94 | * attach a file to a note. assumes all permissions checks are completed. 95 | * returns a streaming function that will save the data to the proper location. 96 | */ 97 | exports.attach = function(note_id) { 98 | if(config.uploads.local) { 99 | return upload_local(note_id); 100 | } else { 101 | return upload_s3(note_id); 102 | } 103 | }; 104 | 105 | exports.file_url = function(note_id) { 106 | if(config.uploads.local) { 107 | return geturl_local(note_id); 108 | } else { 109 | return geturl_s3(note_id); 110 | } 111 | }; 112 | 113 | exports.stream_local = function(note_id) { 114 | return new Promise(function(resolve, reject) { 115 | var path = config.uploads.local+'/'+note_id; 116 | fs.exists(path, function(exists) { 117 | if(!exists) return reject(error.not_found('local file for note '+note_id+' not found')); 118 | resolve(fs.createReadStream(path)); 119 | }); 120 | }); 121 | }; 122 | 123 | /** 124 | * remove an attachment from a note. this assumes all permissions checks are 125 | * complete, and is really just responsible for the dirty work. 126 | */ 127 | exports.delete_attachment = function(note_id) { 128 | if(config.uploads.local) { 129 | return delete_local(note_id); 130 | } else { 131 | return delete_s3(note_id); 132 | } 133 | }; 134 | 135 | -------------------------------------------------------------------------------- /models/invite.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var db = require('../helpers/db'); 4 | var Promise = require('bluebird'); 5 | var error = require('../helpers/error'); 6 | var config = require('../helpers/config'); 7 | var sync_model = require('./sync'); 8 | var space_model = require('./space'); 9 | var user_model = require('./user'); 10 | var vlad = require('../helpers/validator'); 11 | var crypto = require('crypto'); 12 | var email_model = require('./email'); 13 | var analytics = require('./analytics'); 14 | var util = require('../helpers/util'); 15 | const plugins = require('../helpers/plugins'); 16 | 17 | vlad.define('invite', { 18 | id: {type: vlad.type.client_id, required: true}, 19 | space_id: {type: vlad.type.client_id, required: true}, 20 | to_user: {type: vlad.type.email, required: true}, 21 | role: {type: vlad.type.string, required: true}, 22 | is_passphrase_protected: {type: vlad.type.bool, required: true}, 23 | is_pubkey_protected: {type: vlad.type.bool, required: true}, 24 | title: {type: vlad.type.string, required: true}, 25 | body: {type: vlad.type.string}, 26 | }); 27 | 28 | var get_by_id = function(space_id, invite_id) { 29 | var qry = 'SELECT * FROM spaces_invites WHERE id = {{id}} AND space_id = {{space_id}} LIMIT 1'; 30 | return db.first(qry, {id: invite_id, space_id: space_id}); 31 | }; 32 | 33 | /** 34 | * check if an invite to a given user for a space already exists (returns the 35 | * entire invite object if so) 36 | */ 37 | var invite_exists = function(space_id, to_email) { 38 | var qry = 'SELECT * FROM spaces_invites WHERE to_user = {{to_user}} AND space_id = {{space_id}}'; 39 | return db.first(qry, {to_user: to_email, space_id: space_id}); 40 | }; 41 | 42 | var clean = function(invite) { 43 | delete invite.token_server; 44 | return invite; 45 | }; 46 | 47 | var delete_invite = function(space_id, invite_id) { 48 | var qry = 'DELETE FROM spaces_invites WHERE id = {{invite_id}} AND space_id = {{space_id}}'; 49 | return db.query(qry, {invite_id: invite_id, space_id: space_id}); 50 | }; 51 | 52 | var create_outgoing_invite_sync_record = function(user_id, space_id, invite_id, action) { 53 | return get_by_id(space_id, invite_id) 54 | .then(function(invite) { 55 | if(!invite) error.promise_throw('invite_empty'); 56 | return user_model.get_by_email(invite.to_user); 57 | }) 58 | .then(function(to_user) { 59 | if(!to_user) error.promise_throw('invite_empty'); 60 | var user_ids = [to_user.id]; 61 | return sync_model.add_record(user_ids, user_id, 'invite', invite_id, action); 62 | }) 63 | .catch(error.promise_catch('invite_empty'), function(err) { 64 | return []; 65 | }); 66 | }; 67 | 68 | exports.create_sync_records_for_email = function(user_id, email) { 69 | var qry = 'SELECT * FROM spaces_invites WHERE to_user = {{email}}'; 70 | return db.query(qry, {email: email}) 71 | .then(function(invites) { 72 | if(!invites || !invites.length) return []; 73 | return Promise.all(invites.map(function(invite) { 74 | return sync_model.add_record([user_id], user_id, 'invite', invite.id, 'add'); 75 | })); 76 | }); 77 | }; 78 | 79 | exports.send = function(user_id, space_id, data) { 80 | var invite; 81 | try { 82 | data = vlad.validate('invite', data); 83 | } catch(e) { 84 | return Promise.reject(e); 85 | } 86 | 87 | if(space_id != data.space_id) return Promise.reject(error.bad_request('space_id passed does not match space_id in data')); 88 | 89 | var to_user_email = data.to_user; 90 | return user_model.get_by_id(user_id) 91 | .then(function(user) { 92 | if(!user.confirmed && !config.app.allow_unconfirmed_invites) { 93 | throw error.forbidden('you must confirm your account to send invites'); 94 | } 95 | return space_model.permissions_check(user_id, space_id, space_model.permissions.add_space_invite) 96 | }) 97 | .then(function() { 98 | return plugins.with('sync', function(syncer) { 99 | return syncer.can_invite(space_id); 100 | }); 101 | }) 102 | .then(function() { 103 | return Promise.all([ 104 | invite_exists(space_id, to_user_email), 105 | space_model.member_exists(space_id, to_user_email), 106 | ]); 107 | }) 108 | .spread(function(invite_exists, member_exists) { 109 | // don't allow inviting a current member. that's jsut stupid. 110 | if(member_exists) throw error.bad_request('that user is already a member of this space'); 111 | // don't re-create an existing invite. skip it, don't email, etc etc 112 | if(invite_exists) error.promise_throw('already_exists', invite_exists); 113 | 114 | return db.insert('spaces_invites', { 115 | id: data.id, 116 | space_id: space_id, 117 | from_user_id: user_id, 118 | to_user: to_user_email, 119 | data: db.json(data), 120 | }); 121 | }) 122 | .then(function(_invite) { 123 | // store the invite in our top-level binding 124 | invite = _invite; 125 | return Promise.all([ 126 | user_model.get_by_email(to_user_email), 127 | user_model.get_by_id(user_id), 128 | ]); 129 | }) 130 | .spread(function(to_user, from_user) { 131 | var invite_title = data.title; 132 | var subject = 'You have been invited to a Turtl space by '+from_user.username; 133 | var name = (from_user.data || {}).name; 134 | name = name ? name + ' ('+from_user.username+')' : from_user.username; 135 | var action = ''; 136 | if(to_user) { 137 | action = [ 138 | 'To accept this invite, log into your account ('+to_user.username+')', 139 | 'and open "Sharing" from the Turtl menu.' 140 | ].join(' '); 141 | } else { 142 | action = [ 143 | 'To accept this invite, download Turtl (https://turtlapp.com/download/)', 144 | 'and create a new account using this email ('+to_user_email+').', 145 | '\n\nIf you already have an existing account, you can ask '+name, 146 | 'to re-invite you on your existing email.', 147 | '\n\nIf you don\'t care about any of this, feel free to', 148 | 'ignore this message. Nothing good or bad will happen.', 149 | ].join(' '); 150 | } 151 | var body = [ 152 | 'Hello. You have been sent an invite by '+name+': '+invite_title, 153 | '', 154 | action, 155 | '', 156 | 'Have a nice day!', 157 | '- Turtl team', 158 | ].join('\n'); 159 | return email_model.send(config.app.emails.invites, to_user_email, subject, body) 160 | .then(function() { return to_user; }); 161 | }) 162 | .then(function(to_user) { 163 | return space_model.get_space_user_ids(space_id) 164 | .then(function(space_user_ids) { 165 | var to_promise = to_user ? 166 | sync_model.add_record([to_user.id], user_id, 'invite', invite.id, 'add') : 167 | []; 168 | return Promise.all([ 169 | sync_model.add_record(space_user_ids, user_id, 'space', space_id, 'edit'), 170 | to_promise, 171 | ]); 172 | }); 173 | }) 174 | .spread(function(space_sync_ids, invite_sync_ids) { 175 | var inv = invite.data; 176 | inv.sync_ids = space_sync_ids.concat(invite_sync_ids); 177 | return inv; 178 | }) 179 | .catch(error.promise_catch('already_exists'), function(err) { 180 | var inv = err.already_exists.data; 181 | inv.sync_ids = []; 182 | return inv; 183 | }); 184 | }; 185 | 186 | exports.accept = function(user_id, space_id, invite_id, post_accept_fn) { 187 | var invite; 188 | return get_by_id(space_id, invite_id) 189 | .tap(function(_invite) { 190 | invite = _invite; 191 | if(!invite) throw error.not_found('that invite doesn\'t exist'); 192 | return user_model.get_by_id(user_id) 193 | .then(function(user) { 194 | if(user.username != invite.to_user) throw error.forbidden('that invite wasn\'t sent to your email ('+user.username+')'); 195 | if(!user.confirmed) throw error.forbidden('you must confirm your account to accept an invite'); 196 | return space_model.user_is_in_space(user_id, space_id); 197 | }) 198 | .then(function(spaceuser) { 199 | if(!spaceuser) return; 200 | throw error.conflict('you are already a member of space '+space_id); 201 | }); 202 | }) 203 | .tap(function(invite) { 204 | return space_model.create_space_user_record(space_id, user_id, invite.data.role); 205 | }) 206 | .tap(function(invite) { 207 | return delete_invite(space_id, invite_id); 208 | }) 209 | .then(function(invite) { 210 | return space_model.get_by_id(space_id, {populate: true}) 211 | }) 212 | .then(function(space) { 213 | space = space.data; 214 | return space_model.get_space_user_ids(space_id) 215 | .tap(function(space_users) { 216 | return Promise.all([ 217 | sync_model.add_record([user_id], user_id, 'space', space_id, 'share'), 218 | sync_model.add_record([user_id], user_id, 'invite', invite_id, 'delete'), 219 | sync_model.add_record(space_users, user_id, 'space', space_id, 'edit'), 220 | ]); 221 | }) 222 | .then(function(sync_ids_arr) { 223 | var sync_ids = util.flatten(sync_ids_arr); 224 | space.sync_ids = sync_ids; 225 | return space; 226 | }); 227 | }) 228 | .tap(function(_invite) { 229 | if(post_accept_fn) post_accept_fn(invite); 230 | return {accepted: true}; 231 | }); 232 | }; 233 | 234 | exports.update = function(user_id, space_id, invite_id, data) { 235 | return space_model.permissions_check(user_id, space_id, space_model.permissions.edit_space_invite) 236 | .then(function() { 237 | return get_by_id(space_id, invite_id); 238 | }) 239 | .then(function(invite) { 240 | if(!invite) throw error.not_found('invite '+invite_id+' (in space '+space_id+') not found'); 241 | var invite_data = invite.data; 242 | invite_data.role = data.role; 243 | var update = { 244 | data: invite_data 245 | }; 246 | return db.update('spaces_invites', invite_id, update) 247 | .then(function() { 248 | return link([invite_id]) 249 | .then(function(invites) { return invites[0]; }); 250 | }) 251 | .then(function(inv) { 252 | return space_model.get_space_user_ids(space_id) 253 | .then(function(user_ids) { 254 | // do an "edit" sync on the space, not the invite. 255 | return Promise.all([ 256 | sync_model.add_record(user_ids, user_id, 'space', space_id, 'edit'), 257 | create_outgoing_invite_sync_record(user_id, space_id, invite_id, 'edit'), 258 | ]); 259 | }) 260 | .spread(function(sync_ids, invite_sync_ids) { 261 | inv.sync_ids = sync_ids.concat(invite_sync_ids); 262 | return inv; 263 | }); 264 | }); 265 | }); 266 | }; 267 | 268 | exports.delete = function(user_id, space_id, invite_id, post_delete_fn) { 269 | var invitee = null; 270 | var promises = [ 271 | space_model.user_has_permission(user_id, space_id, space_model.permissions.delete_space_invite), 272 | user_model.get_by_id(user_id) 273 | .then(function(user) { 274 | if(!user) return false; 275 | return invite_exists(space_id, user.username); 276 | }) 277 | .then(function(invite) { 278 | return invite && invite.id == invite_id; 279 | }), 280 | get_by_id(space_id, invite_id) 281 | .then(function(invite) { 282 | if(!invite) return false; 283 | return user_model.get_by_email(invite.to_user) 284 | .then(function(invitee_) { 285 | invitee = invitee_; 286 | }); 287 | }), 288 | ]; 289 | var is_invitee; 290 | return Promise.all(promises) 291 | .spread(function(has_perm, is_invitee_) { 292 | is_invitee = is_invitee_; 293 | if(!has_perm && !is_invitee) { 294 | throw error.forbidden('you do not have access to delete that invite'); 295 | } 296 | return delete_invite(space_id, invite_id); 297 | }) 298 | .tap(function() { 299 | return space_model.get_space_user_ids(space_id) 300 | .then(function(user_ids) { 301 | var user_ids_plus_invitee = user_ids.slice(0); 302 | if(invitee && invitee.id) { 303 | user_ids_plus_invitee.push(invitee.id); 304 | } 305 | return Promise.all([ 306 | sync_model.add_record(user_ids, user_id, 'space', space_id, 'edit'), 307 | sync_model.add_record(user_ids_plus_invitee, user_id, 'invite', invite_id, 'delete'), 308 | ]); 309 | }); 310 | }) 311 | .tap(function() { 312 | if(post_delete_fn) post_delete_fn({is_invitee: is_invitee}); 313 | }) 314 | .then(function() { 315 | return true; 316 | }); 317 | }; 318 | 319 | exports.get_by_to_email = function(to_email) { 320 | var qry = 'SELECT id FROM spaces_invites WHERE to_user = {{email}}'; 321 | return db.query(qry, {email: to_email}) 322 | .then(function(invites) { 323 | if(invites.length == 0) return []; 324 | return link(invites.map(function(i) { return i.id; })); 325 | }); 326 | }; 327 | 328 | /** 329 | * get all invites for a particular space 330 | */ 331 | exports.get_by_space_id = function(space_id) { 332 | return db.query('SELECT data FROM spaces_invites WHERE space_id = {{space_id}}', {space_id: space_id}) 333 | .then(function(invites) { 334 | return invites.map(function(i) { return i.data; }); 335 | }); 336 | }; 337 | 338 | /** 339 | * grab all invites for a given set of space ids 340 | */ 341 | exports.get_by_spaces_ids = function(space_ids) { 342 | return db.by_ids('spaces_invites', space_ids, {fields: ['id'], id_field: 'space_id'}) 343 | .map(function(invite) { return invite.id; }) 344 | .then(link); 345 | }; 346 | 347 | var link = function(ids) { 348 | return db.by_ids('spaces_invites', ids, {fields: ['from_user_id', 'data']}) 349 | .then(function(items) { 350 | var user_ids = items.map(function(i) { return i.from_user_id; }); 351 | return user_model.get_by_ids(user_ids) 352 | .then(function(users) { 353 | var user_idx = {}; 354 | users.forEach(function(user) { user_idx[user.id] = user; }); 355 | return items.map(function(i) { 356 | var data = i.data; 357 | var user = user_idx[i.from_user_id] || {}; 358 | data.from_user_id = user.id; 359 | data.from_username = user.username; 360 | return data; 361 | }); 362 | }); 363 | }); 364 | }; 365 | 366 | sync_model.register('invite', { 367 | link: link, 368 | }); 369 | 370 | -------------------------------------------------------------------------------- /models/keychain.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var Promise = require('bluebird'); 4 | var db = require('../helpers/db'); 5 | var sync_model = require('./sync'); 6 | var error = require('../helpers/error'); 7 | var vlad = require('../helpers/validator'); 8 | 9 | vlad.define('keychain', { 10 | id: {type: vlad.type.client_id, required: true}, 11 | type: {type: vlad.type.string, required: true}, 12 | item_id: {type: vlad.type.client_id, required: true}, 13 | user_id: {type: vlad.type.int, required: true}, 14 | body: {type: vlad.type.string, required: true}, 15 | }); 16 | 17 | /** 18 | * Delete a keychain entry given a user id/item id. Be careful with this: it 19 | * doesn't check any ownership/permissions, nor does it create sync records so 20 | * use selectively. 21 | */ 22 | exports.delete_by_user_item = function(user_id, item_id, options) { 23 | options || (options = {}); 24 | if(!user_id || !item_id) return Promise.resolve([]); 25 | 26 | var qry = 'SELECT * FROM keychain WHERE user_id = {{user_id}} AND item_id = {{item_id}} LIMIT 1'; 27 | return db.first(qry, {user_id: user_id, item_id: item_id}) 28 | .then(function(entry) { 29 | if(!entry) return []; 30 | return del(user_id, entry.id); 31 | }); 32 | }; 33 | 34 | /** 35 | * get a keychain entry's data by id 36 | */ 37 | var get_by_id = function(keychain_id) { 38 | return db.by_id('keychain', keychain_id) 39 | .then(function(entry) { return entry && entry.data; }); 40 | }; 41 | 42 | /** 43 | * get all keychain entries for an item by id. 44 | */ 45 | exports.get_by_item_id = function(item_id) { 46 | return db.by_ids('keychain', [item_id], {id_field: 'item_id'}) 47 | }; 48 | 49 | exports.get_by_user = function(user_id) { 50 | var qry = 'SELECT * FROM keychain WHERE user_id = {{user_id}}'; 51 | return db.query(qry, {user_id: user_id}) 52 | .then(function(keychain) { 53 | return (keychain || []).map(function(entry) { 54 | return entry.data; 55 | }); 56 | }); 57 | }; 58 | 59 | var add = function(user_id, data) { 60 | data.user_id = user_id; 61 | data = vlad.validate('keychain', data); 62 | return db.upsert('keychain', {id: data.id, user_id: user_id, item_id: data.item_id, data: data}, 'id') 63 | .tap(function(item) { 64 | return sync_model.add_record([user_id], user_id, 'keychain', item.id, 'add') 65 | .then(function(sync_ids) { 66 | item.sync_ids = sync_ids; 67 | }); 68 | }); 69 | }; 70 | 71 | var edit = function(user_id, data) { 72 | data = vlad.validate('keychain', data); 73 | return get_by_id(data.id) 74 | .then(function(item_data) { 75 | if(!item_data) throw error.not_found('that keychain entry is missing'); 76 | // preserve user_id 77 | if(user_id != item_data.user_id) { 78 | throw error.forbidden('you can\'t edit a keychain entry you don\'t own'); 79 | } 80 | data.user_id = user_id; 81 | return db.update('keychain', data.id, {item_id: data.item_id, data: data}); 82 | }) 83 | .tap(function(item) { 84 | return sync_model.add_record([user_id], user_id, 'keychain', item.id, 'edit') 85 | .then(function(sync_ids) { 86 | item.sync_ids = sync_ids; 87 | }); 88 | }); 89 | }; 90 | 91 | var del = function(user_id, keychain_id) { 92 | return get_by_id(keychain_id) 93 | .then(function(item_data) { 94 | if(!item_data) error.promise_throw('missing_keychain'); 95 | if(user_id != item_data.user_id) { 96 | throw error.forbidden('you can\'t delete a keychain entry you don\'t own'); 97 | } 98 | return db.delete('keychain', keychain_id) 99 | }) 100 | .then(function(_) { 101 | return sync_model.add_record([user_id], user_id, 'keychain', keychain_id, 'delete') 102 | }) 103 | .catch(error.promise_catch('missing_keychain'), function() { return []; }); 104 | }; 105 | 106 | var link = function(ids) { 107 | return db.by_ids('keychain', ids, {fields: ['data']}) 108 | .then(function(items) { 109 | return items.map(function(i) { return i.data;}); 110 | }); 111 | }; 112 | 113 | sync_model.register('keychain', { 114 | add: add, 115 | edit: edit, 116 | delete: del, 117 | link: link, 118 | }); 119 | 120 | -------------------------------------------------------------------------------- /models/note.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var db = require('../helpers/db'); 4 | var sync_model = require('./sync'); 5 | var vlad = require('../helpers/validator'); 6 | var error = require('../helpers/error'); 7 | var config = require('../helpers/config'); 8 | var space_model = require('./space'); 9 | var file_model = require('./file'); 10 | var analytics = require('./analytics'); 11 | var util = require('../helpers/util'); 12 | 13 | vlad.define('note', { 14 | id: {type: vlad.type.client_id, required: true}, 15 | space_id: {type: vlad.type.client_id, required: true}, 16 | board_id: {type: vlad.type.client_id}, 17 | user_id: {type: vlad.type.int, required: true}, 18 | has_file: {type: vlad.type.bool, default: false}, 19 | file: {type: vlad.type.vlad('note-file')}, 20 | mod: {type: vlad.type.int}, 21 | keys: {type: vlad.type.array}, 22 | body: {type: vlad.type.string}, 23 | }); 24 | 25 | vlad.define('note-file', { 26 | size: {type: vlad.type.int}, 27 | body: {type: vlad.type.string}, 28 | }); 29 | 30 | var get_by_id = function(note_id) { 31 | return db.by_id('notes', note_id) 32 | .then(function(note) { return note && note.data; }); 33 | }; 34 | exports.get_by_id = get_by_id; 35 | 36 | exports.get_by_spaces = function(space_ids) { 37 | if(space_ids.length == 0) return Promise.resolve([]); 38 | return db.by_ids('notes', space_ids, {id_field: 'space_id'}) 39 | .then(function(notes) { 40 | return notes.map(function(b) { return b.data; }); 41 | }); 42 | }; 43 | 44 | exports.get_by_space_id = function(space_id) { 45 | return exports.get_by_spaces([space_id]); 46 | }; 47 | 48 | /** 49 | * makes sure user has access to attach a file, then returns a streaming 50 | * function we can use to send the file data to. 51 | */ 52 | exports.attach_file = function(user_id, note_id) { 53 | var space_id; 54 | return db.by_id('notes', note_id) 55 | .then(function(note) { 56 | if(!note) throw error.not_found('that note doesn\'t exist'); 57 | space_id = note.space_id; 58 | return space_model.permissions_check(user_id, space_id, space_model.permissions.edit_note); 59 | }) 60 | .then(function() { 61 | return file_model.attach(note_id); 62 | }) 63 | .then(function(stream) { 64 | var finishfn = function(file_size) { 65 | var note = null; 66 | return db.by_id('notes', note_id) 67 | .then(function(_note) { 68 | note = _note; 69 | if(!note) throw error.not_found('that note doesn\'t exist'); 70 | note.data.has_file = true; 71 | var file = note.data.file || {}; 72 | file.size = file_size; 73 | note.data.file = file; 74 | return db.update('notes', note_id, {data: note.data}); 75 | }) 76 | .then(function() { 77 | return space_model.get_space_user_ids(space_id); 78 | }) 79 | .then(function(user_ids) { 80 | return Promise.all([ 81 | sync_model.add_record(user_ids, user_id, 'note', note_id, 'edit'), 82 | sync_model.add_record(user_ids, user_id, 'file', note_id, 'add'), 83 | ]); 84 | }) 85 | .then(function(sync_ids) { 86 | note.data.sync_ids = util.flatten(sync_ids); 87 | return note.data; 88 | }); 89 | }; 90 | return [ 91 | stream, 92 | finishfn, 93 | ] 94 | }); 95 | }; 96 | 97 | /** 98 | * grab a note's attachment (URL) 99 | */ 100 | exports.get_file_url = function(user_id, note_id) { 101 | var space_id; 102 | return db.by_id('notes', note_id) 103 | .then(function(note) { 104 | if(!note) throw error.not_found('that note doesn\'t exist'); 105 | space_id = note.space_id; 106 | return space_model.permissions_check(user_id, space_id, space_model.permissions.edit_note); 107 | }) 108 | .then(function() { 109 | return file_model.file_url(note_id); 110 | }); 111 | }; 112 | 113 | /** 114 | * grab a local file upload and return the stream 115 | */ 116 | exports.pipe_local_file = function(user_id, note_id) { 117 | var space_id; 118 | return db.by_id('notes', note_id) 119 | .then(function(note) { 120 | if(!note) throw error.not_found('that note doesn\'t exist'); 121 | space_id = note.space_id; 122 | return space_model.permissions_check(user_id, space_id, space_model.permissions.edit_note); 123 | }) 124 | .then(function() { 125 | return file_model.stream_local(note_id); 126 | }); 127 | }; 128 | 129 | var add = space_model.simple_add( 130 | 'note', 131 | 'notes', 132 | space_model.permissions.add_note, 133 | function(data) { 134 | delete data.has_file; 135 | return {id: data.id, space_id: data.space_id, board_id: data.board_id, data: db.json(data)}; 136 | } 137 | ); 138 | 139 | var edit = space_model.simple_edit( 140 | 'note', 141 | 'notes', 142 | space_model.permissions.edit_note, 143 | get_by_id, 144 | function(data, existing) { 145 | data.has_file = existing.has_file; 146 | return {id: data.id, space_id: data.space_id, board_id: data.board_id, data: db.json(data)}; 147 | } 148 | ); 149 | 150 | var delete_note = space_model.simple_delete( 151 | 'note', 152 | 'notes', 153 | space_model.permissions.delete_note, 154 | get_by_id 155 | ); 156 | 157 | // wrap `delete_note`/simple_delete to also remove the note's file AND create a 158 | // corresponding file.delete sync record 159 | var del = function(user_id, note_id) { 160 | var sync_ids = []; 161 | var note = null; 162 | return get_by_id(note_id) 163 | .then(function(_note) { 164 | note = _note; 165 | return delete_note(user_id, note_id); 166 | }) 167 | .then(function(_sync_ids) { 168 | sync_ids = _sync_ids; 169 | if(!note) throw error.promise_throw('doesnt_exist'); 170 | return delete_note_file_sync(user_id, note.space_id, note_id); 171 | }) 172 | .then(function(delete_sync_ids) { 173 | return sync_ids.concat(delete_sync_ids || []); 174 | }) 175 | .catch(error.promise_catch('doesnt_exist'), function() { 176 | return sync_ids; 177 | }); 178 | }; 179 | exports.delete_note = del; 180 | 181 | var move_space = space_model.simple_move_space( 182 | 'note', 183 | 'notes', 184 | space_model.permissions.delete_note, 185 | space_model.permissions.add_note, 186 | get_by_id 187 | ); 188 | 189 | var link = function(ids) { 190 | return db.by_ids('notes', ids, {fields: ['data']}) 191 | .then(function(items) { 192 | return items.map(function(i) { return i.data;}); 193 | }); 194 | }; 195 | 196 | /** 197 | * delete a note's file, no permission checks or note editing. this is mainly 198 | * called when a note is being deleted and we want to a) delete the note's file 199 | * along with the note and b) create a `file.delete` sync record so the client 200 | * doesn't have to manage creating sync records for child objects. 201 | */ 202 | var delete_note_file_sync = function(user_id, space_id, note_id) { 203 | return file_model.delete_attachment(note_id) 204 | .then(function() { 205 | return space_model.get_space_user_ids(space_id); 206 | }) 207 | .then(function(user_ids) { 208 | return sync_model.add_record(user_ids, user_id, 'file', note_id, 'delete'); 209 | }); 210 | }; 211 | 212 | /** 213 | * delete a note's file, meant to be called from the sync system. this does NOT 214 | * create a file.delete sync record because that sync record already exists =] 215 | */ 216 | var delete_note_file = function(user_id, note_id) { 217 | return db.by_id('notes', note_id) 218 | .tap(function(note) { 219 | if(!note) throw error.promise_throw('missing_note'); 220 | return space_model.permissions_check(user_id, note.space_id, space_model.permissions.edit_note); 221 | }) 222 | .tap(function(note) { 223 | var data = note.data || {}; 224 | if(!data.has_file) error.promise_throw('missing_file'); 225 | return file_model.delete_attachment(note_id); 226 | }) 227 | .tap(function(note) { 228 | // remove the attachment from data 229 | var data = note.data || {}; 230 | data.has_file = false; 231 | delete data.file; 232 | return db.update('notes', note_id, {data: data}); 233 | }) 234 | .then(function(note) { 235 | return space_model.get_space_user_ids(note.space_id) 236 | .then(function(user_ids) { 237 | return sync_model.add_record(user_ids, user_id, 'note', note.id, 'edit'); 238 | }); 239 | }) 240 | .catch(error.promise_catch('missing_note'), function(err) { return []; }) 241 | .catch(error.promise_catch('missing_file'), function(err) { return []; }); 242 | }; 243 | 244 | sync_model.register('note', { 245 | 'add': add, 246 | 'edit': edit, 247 | 'delete': del, 248 | 'move-space': move_space, 249 | 'link': link, 250 | }); 251 | 252 | sync_model.register('file', { 253 | delete: delete_note_file, 254 | link: link, 255 | }); 256 | 257 | -------------------------------------------------------------------------------- /models/profile.js: -------------------------------------------------------------------------------- 1 | var error = require('../helpers/error'); 2 | var space_model = require('./space'); 3 | 4 | exports.get_profile_size = function(user_id) { 5 | // grab the user's owned spaces 6 | return space_model.get_by_user_id(user_id, {role: space_model.roles.owner}) 7 | .then(function(owned_spaces) { 8 | // grab the size for each space 9 | return Promise.all(owned_spaces.map(function(space) { 10 | return space_model.get_space_size(space.id); 11 | })); 12 | }) 13 | .then(function(space_sizes) { 14 | return space_sizes.reduce(function(acc, x) { return acc + x; }, 0); 15 | }); 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /models/space.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var db = require('../helpers/db'); 4 | var Promise = require('bluebird'); 5 | var sync_model = require('./sync'); 6 | var user_model = require('./user'); 7 | var keychain_model = require('./keychain'); 8 | var vlad = require('../helpers/validator'); 9 | var error = require('../helpers/error'); 10 | var invite_model = require('./invite'); 11 | var util = require('../helpers/util'); 12 | var libperm = require('turtl-lib-permissions'); 13 | 14 | vlad.define('space', { 15 | id: {type: vlad.type.client_id, required: true}, 16 | user_id: {type: vlad.type.int, required: true}, 17 | body: {type: vlad.type.string}, 18 | }); 19 | 20 | vlad.define('space-member', { 21 | role: {type: vlad.type.string, required: true}, 22 | }); 23 | 24 | // our roles 25 | var roles = libperm.roles; 26 | var permissions = libperm.permissions; 27 | var role_permissions = libperm.role_permissions; 28 | exports.permissions = permissions; 29 | exports.roles = roles; 30 | 31 | /** 32 | * make sure the given user has the ability to perform the given action. this 33 | * function throws a forbidden error if the user doesn't have access. if you 34 | * want a boolean yes/no, see user_has_permission() 35 | */ 36 | exports.permissions_check = function(user_id, space_id, permission) { 37 | return get_space_user_record(user_id, space_id) 38 | .then(function(space_user) { 39 | if(!space_user) throw error.forbidden('you don\'t have access to space '+space_id); 40 | var role = space_user.role; 41 | var permissions = role_permissions[role]; 42 | if(permissions.indexOf(permission) >= 0) return true; 43 | throw error.forbidden('you don\'t have `'+permission+'` permissions on space '+space_id); 44 | }); 45 | }; 46 | 47 | /** 48 | * wraps permissions_check, and catches errors to return a boolean true/false 49 | */ 50 | exports.user_has_permission = function(user_id, space_id, permission) { 51 | return exports.permissions_check(user_id, space_id, permission) 52 | .then(function() { 53 | return true; 54 | }) 55 | // catch `forbidden` errors and return false 56 | .catch(function(err) { return err.status == 403 && err.app_error === true; }, function(err) { 57 | return false; 58 | }); 59 | }; 60 | 61 | /** 62 | * does this user have any kind of access to this space? anyone who has access 63 | * to the space can READ anything in the space, regardless of permissions (ie, 64 | * guest permissions). 65 | */ 66 | exports.user_is_in_space = function(user_id, space_id) { 67 | return get_space_user_record(user_id, space_id); 68 | }; 69 | 70 | /** 71 | * Checks if a user is current in a space (by their email). Mainly used to keep 72 | * from sending invites to existing members. 73 | */ 74 | exports.member_exists = function(space_id, email) { 75 | var qry = [ 76 | 'SELECT', 77 | ' su.id', 78 | 'FROM', 79 | ' spaces_users su,', 80 | ' users u', 81 | 'WHERE', 82 | ' su.space_id = {{space_id}} AND', 83 | ' su.user_id = u.id AND', 84 | ' u.username = {{email}}', 85 | 'LIMIT 1', 86 | ]; 87 | return db.first(qry.join('\n'), {space_id: space_id, email: email}) 88 | .then(function(rec) { 89 | if(rec) return true; 90 | return false; 91 | }); 92 | }; 93 | 94 | /** 95 | * populates member data for a set of spaces 96 | */ 97 | var populate_members = function(spaces, options) { 98 | options || (options = {}); 99 | var skip_invites = options.skip_invites; 100 | 101 | if(spaces.length == 0) return Promise.resolve(spaces); 102 | var space_ids = spaces.map(function(s) { return s.id; }); 103 | var member_promise = db.by_ids('spaces_users', space_ids, {id_field: 'space_id'}) 104 | .then(function(members) { 105 | var user_ids = members.map(function(m) { return m.user_id; }); 106 | return user_model.get_by_ids(user_ids) 107 | .then(function(users) { 108 | var user_idx = {}; 109 | users.forEach(function(u) { user_idx[u.id] = u; }); 110 | // filter our members that don't exist anymore 111 | members = members.filter(function(member) { 112 | var user = user_idx[member.user_id]; 113 | if(!user) return false; 114 | member.username = user.username; 115 | return true; 116 | }); 117 | return members; 118 | }); 119 | }); 120 | var invite_promise = skip_invites ? 121 | Promise.resolve([]) : 122 | invite_model.get_by_spaces_ids(space_ids); 123 | var promises = [ 124 | member_promise, 125 | invite_promise, 126 | ]; 127 | return Promise.all(promises) 128 | .spread(function(space_users, space_invites) { 129 | var space_idx = {}; 130 | spaces.forEach(function(space) { space_idx[space.id] = space; }); 131 | 132 | space_users.forEach(function(user) { 133 | var space = space_idx[user.space_id]; 134 | if(!space) return; 135 | if(!space.data) space.data = {}; 136 | if(!space.data.members) space.data.members = []; 137 | space.data.members.push(user); 138 | }); 139 | space_invites.forEach(function(invite) { 140 | var space = space_idx[invite.space_id]; 141 | if(!space) return; 142 | if(!space.data) space.data = {}; 143 | if(!space.data.invites) space.data.invites = []; 144 | space.data.invites.push(invite); 145 | }); 146 | return spaces; 147 | }); 148 | }; 149 | 150 | /** 151 | * grab a space by id 152 | */ 153 | var get_by_id = function(space_id, options) { 154 | options || (options = {}); 155 | return db.by_id('spaces', space_id) 156 | .then(function(space) { 157 | if(!space) return false; 158 | if(options.populate) { 159 | return populate_members([space]) 160 | .then(function(spaces) { return spaces[0]; }); 161 | } 162 | if(options.raw) return space; 163 | return space.data; 164 | }); 165 | }; 166 | exports.get_by_id = get_by_id; 167 | 168 | /** 169 | * given a space id, pull out all user_ids accociated with the spaces. 170 | * 171 | * this is GREAT for generating sync records for boards/notes/invites 172 | */ 173 | exports.get_space_user_ids = function(space_id) { 174 | var qry = 'SELECT user_id FROM spaces_users WHERE space_id = {{space_id}}'; 175 | return db.query(qry, {space_id: space_id}) 176 | .then(function(res) { 177 | return res.map(function(rec) { return rec.user_id; }); 178 | }); 179 | }; 180 | 181 | /** 182 | * Given a user id, grab all users attached to the spaces that user is in. 183 | */ 184 | exports.get_members_from_users_spaces = function(user_id) { 185 | var qry = [ 186 | 'SELECT', 187 | ' su.user_id, su.space_id', 188 | 'FROM', 189 | ' spaces_users su', 190 | 'WHERE', 191 | ' su.space_id IN (SELECT su2.space_id FROM spaces_users su2 WHERE su2.user_id = {{user_id}})', 192 | ]; 193 | return db.query(qry.join('\n'), {user_id: user_id}); 194 | }; 195 | 196 | /** 197 | * get all spaces attached to a user 198 | */ 199 | exports.get_by_user_id = function(user_id, options) { 200 | options || (options = {}); 201 | var role = options.role; 202 | var qry = [ 203 | 'SELECT', 204 | ' s.*', 205 | 'FROM', 206 | ' spaces s,', 207 | ' spaces_users su', 208 | 'WHERE', 209 | ' s.id = su.space_id AND', 210 | ' su.user_id = {{uid}}', 211 | ]; 212 | var params = {uid: user_id}; 213 | if(role) { 214 | qry.push(' AND su.role = {{role}}'); 215 | params.role = role; 216 | } 217 | return db.query(qry.join('\n'), params) 218 | .then(populate_members); 219 | }; 220 | 221 | exports.create_space_user_record = function(space_id, user_id, role) { 222 | return db.insert('spaces_users', {space_id: space_id, user_id: user_id, role: role}); 223 | }; 224 | 225 | /** 226 | * get a space <--> user link record (which includes the space-user permissions) 227 | */ 228 | var get_space_user_record = function(user_id, space_id) { 229 | var qry = 'SELECT * FROM spaces_users WHERE space_id = {{space_id}} AND user_id = {{user_id}}'; 230 | return db.first(qry, {space_id: space_id, user_id: user_id}); 231 | }; 232 | 233 | /** 234 | * Get all invite records for this space 235 | */ 236 | var get_space_invites = function(space_id) { 237 | var qry = 'SELECT * FROM spaces_invites WHERE space_id = {{space_id}}'; 238 | return db.query(qry, {space_id: space_id}); 239 | }; 240 | 241 | /** 242 | * get the data tree for a space (all the boards/notes/invites contained in it). 243 | */ 244 | exports.get_data_tree = function(space_id, options) { 245 | options || (options = {}); 246 | 247 | // ------------------------------------------------------------------------- 248 | // NOTE: we load our models inside this function because they both require 249 | // some function defined below here, and i'm certainly not going to put the 250 | // requires at the bottom of the file just to support this one function. 251 | // ------------------------------------------------------------------------- 252 | var board_model = require('./board'); 253 | var note_model = require('./note'); 254 | // ------------------------------------------------------------------------- 255 | 256 | var space_promise = get_by_id(space_id, {raw: true}) 257 | .then(function(space) { 258 | if(!space) return false; 259 | return populate_members([space], options); 260 | }) 261 | .then(function(spaces) { 262 | return spaces && spaces[0].data; 263 | }); 264 | return Promise.all([ 265 | space_promise, 266 | board_model.get_by_space_id(space_id), 267 | note_model.get_by_space_id(space_id), 268 | ]) 269 | }; 270 | 271 | exports.update_member = function(user_id, space_id, member_user_id, data) { 272 | try { 273 | data = vlad.validate('space-member', data); 274 | } catch(e) { 275 | return Promise.reject(e); 276 | } 277 | return exports.permissions_check(user_id, space_id, permissions.edit_space_member) 278 | .then(function() { 279 | return get_space_user_record(member_user_id, space_id); 280 | }) 281 | .then(function(member) { 282 | if(!member) { 283 | throw error.bad_request('that member wasn\'t found'); 284 | } 285 | if(member.role == roles.owner) { 286 | throw error.bad_request('you cannot edit the owner'); 287 | } 288 | return db.update('spaces_users', member.id, data); 289 | }) 290 | .tap(function(member) { 291 | return user_model.get_by_id(member.user_id) 292 | .then(function(user) { 293 | member.username = user.username; 294 | }); 295 | }) 296 | .tap(function() { 297 | return exports.get_space_user_ids(space_id) 298 | .then(function(user_ids) { 299 | return sync_model.add_record(user_ids, user_id, 'space', space_id, 'edit'); 300 | }) 301 | .then(function(sync_ids) { 302 | data.sync_ids = sync_ids; 303 | }); 304 | }) 305 | }; 306 | 307 | exports.delete_member = function(user_id, space_id, member_user_id) { 308 | return exports.user_has_permission(user_id, space_id, permissions.delete_space_member) 309 | .then(function(has_perm) { 310 | if(!has_perm && user_id != member_user_id) { 311 | throw error.forbidden('you do not have permission to remove that user'); 312 | } 313 | return get_space_user_record(member_user_id, space_id); 314 | }) 315 | .then(function(member) { 316 | if(member.role == roles.owner) { 317 | throw error.bad_request('you cannot delete the owner'); 318 | } 319 | return db.delete('spaces_users', member.id); 320 | }) 321 | .then(function() { 322 | return exports.get_space_user_ids(space_id) 323 | .then(function(user_ids) { 324 | return Promise.all([ 325 | keychain_model.delete_by_user_item(member_user_id, space_id), 326 | sync_model.add_record(user_ids, user_id, 'space', space_id, 'edit'), 327 | sync_model.add_record([member_user_id], user_id, 'space', space_id, 'unshare'), 328 | ]); 329 | }) 330 | }) 331 | .then(function(sync_ids) { 332 | return {sync_ids: util.flatten(sync_ids)}; 333 | }); 334 | }; 335 | 336 | exports.set_owner = function(user_id, space_id, new_user_id) { 337 | return exports.permissions_check(user_id, space_id, permissions.set_space_owner) 338 | .then(function() { 339 | return Promise.all([ 340 | get_by_id(space_id), 341 | get_space_user_record(user_id, space_id), 342 | get_space_user_record(new_user_id, space_id), 343 | ]); 344 | }) 345 | .spread(function(space, cur_owner_member, new_owner_member) { 346 | if(!space) throw error.not_found('that space was not found'); 347 | if(!cur_owner_member) throw error.not_found('that space owner was not found'); 348 | if(!new_owner_member) throw error.not_found('that space member was not found'); 349 | space.user_id = new_user_id; 350 | return db.update('spaces', space_id, {data: db.json(space)}) 351 | .tap(function(_space) { 352 | return Promise.all([ 353 | db.update('spaces_users', cur_owner_member.id, {role: roles.admin}), 354 | db.update('spaces_users', new_owner_member.id, {role: roles.owner}), 355 | ]); 356 | }); 357 | }) 358 | .tap(function(space) { 359 | return exports.get_space_user_ids(space_id) 360 | .then(function(user_ids) { 361 | return sync_model.add_record(user_ids, user_id, 'space', space_id, 'edit'); 362 | }) 363 | .then(function(sync_ids) { 364 | space.data.sync_ids = sync_ids; 365 | }); 366 | }) 367 | .tap(function(space) { 368 | return populate_members([space]); 369 | }) 370 | .then(function(space) { 371 | return space.data; 372 | }); 373 | }; 374 | 375 | var add = function(user_id, data) { 376 | data.user_id = user_id; 377 | data = vlad.validate('space', data); 378 | return db.upsert('spaces', {id: data.id, data: data}, 'id') 379 | .tap(function(space) { 380 | return exports.create_space_user_record(space.id, user_id, roles.owner); 381 | }) 382 | .tap(function(space) { 383 | return sync_model.add_record([user_id], user_id, 'space', space.id, 'add') 384 | .then(function(sync_ids) { 385 | space.sync_ids = sync_ids; 386 | }); 387 | }) 388 | .tap(function(space) { 389 | return populate_members([space]); 390 | }); 391 | }; 392 | 393 | var edit = function(user_id, data) { 394 | var space_id = data.id; 395 | data = vlad.validate('space', data); 396 | return exports.permissions_check(user_id, space_id, permissions.edit_space) 397 | .then(function(_) { 398 | return get_by_id(space_id) 399 | .then(function(space_data) { 400 | // preserve user_id 401 | data.user_id = space_data.user_id; 402 | return db.update('spaces', space_id, {data: data}); 403 | }); 404 | }) 405 | .tap(function(space) { 406 | return exports.get_space_user_ids(space_id) 407 | .then(function(user_ids) { 408 | return sync_model.add_record(user_ids, user_id, 'space', space_id, 'edit'); 409 | }) 410 | .then(function(sync_ids) { 411 | space.sync_ids = sync_ids; 412 | }); 413 | }) 414 | .tap(function(space) { 415 | return populate_members([space]); 416 | }); 417 | }; 418 | 419 | var del = function(user_id, space_id) { 420 | // ------------------------------------------------------------------------- 421 | // NOTE: we load our models inside this function because they both require 422 | // some function defined below here, and i'm certainly not going to put the 423 | // requires at the bottom of the file just to support this one function. 424 | // ------------------------------------------------------------------------- 425 | const board_model = require('./board'); 426 | const note_model = require('./note'); 427 | // ------------------------------------------------------------------------- 428 | var affected_users = null; 429 | return get_by_id(space_id, {raw: true}) 430 | .then(function(space_exists) { 431 | if(!space_exists) error.promise_throw('space_missing'); 432 | return exports.permissions_check(user_id, space_id, permissions.delete_space); 433 | }) 434 | .tap(function() { 435 | return exports.get_space_user_ids(space_id) 436 | .then(function(user_ids) { affected_users = user_ids; }); 437 | }) 438 | .then(function(_) { 439 | var params = {space_id: space_id}; 440 | return Promise.all([ 441 | db.query('SELECT id FROM notes WHERE space_id = {{space_id}}', params), 442 | db.query('SELECT id FROM boards WHERE space_id = {{space_id}}', params), 443 | ]); 444 | }) 445 | .spread(function(note_ids, board_ids) { 446 | var note_delete = Promise.map(note_ids, function(note) { 447 | return note_model.delete_note(user_id, note.id); 448 | }, {concurrency: 8}); 449 | var board_delete = Promise.map(board_ids, function(board) { 450 | return board_model.delete_board(user_id, board.id); 451 | }, {concurrency: 8}); 452 | return Promise.all([note_delete, board_delete]); 453 | }) 454 | .then(function() { 455 | // build/save sync records for all our deleted invites 456 | var inv_map = {}; 457 | return get_space_invites(space_id) 458 | .then(function(invites) { 459 | let usernames = invites.map(function(i) { 460 | inv_map[i.to_user] = i; 461 | return i.to_user; 462 | }); 463 | return user_model.get_by_emails(usernames); 464 | }) 465 | .then(function(users) { 466 | return Promise.all(users.map(function(u) { 467 | return sync_model.add_record([u.id], user_id, 'invite', inv_map[u.username].id, 'delete'); 468 | })); 469 | }); 470 | }) 471 | .then(function() { 472 | // build/save sync records for all our deleted members 473 | return exports.get_space_user_ids(space_id) 474 | .then(function(space_user_ids) { 475 | return sync_model.add_record(space_user_ids, user_id, 'space', space_id, 'unshare'); 476 | }); 477 | }) 478 | .then(function() { 479 | var params = {space_id: space_id}; 480 | return Promise.all([ 481 | db.query('DELETE FROM spaces_users WHERE space_id = {{space_id}}', params), 482 | db.query('DELETE FROM spaces_invites WHERE space_id = {{space_id}}', params), 483 | ]); 484 | }) 485 | .then(function(_) { 486 | return db.delete('spaces', space_id); 487 | }) 488 | .then(function() { 489 | // remove the keychain entries pointing to this space, and make sure 490 | // we sync out to the restecpive owners 491 | return keychain_model.get_by_item_id(space_id) 492 | .map(function(entry) { 493 | return db.delete('keychain', entry.id) 494 | .then(function() { 495 | return sync_model.add_record([entry.user_id], user_id, 'keychain', entry.id, 'delete'); 496 | }); 497 | }); 498 | }) 499 | .then(function() { 500 | return sync_model.add_record(affected_users, user_id, 'space', space_id, 'delete') 501 | }) 502 | .catch(error.promise_catch('space_missing'), function() { 503 | // silently ignore deleting something that doesn't exist. 504 | return []; 505 | }); 506 | }; 507 | exports.delete_space = del; 508 | 509 | var link = function(ids) { 510 | return db.by_ids('spaces', ids, {fields: ['id', 'data']}) 511 | .then(function(spaces) { 512 | return populate_members(spaces); 513 | }) 514 | .then(function(items) { 515 | return items.map(function(i) { return i.data;}); 516 | }); 517 | }; 518 | 519 | /** 520 | * Abstracts adding a specific object type to a space. Handles validation, 521 | * inthertion uhhhuhuh, permissions checks, and creation of the corresponding 522 | * sync records. 523 | */ 524 | exports.simple_add = function(sync_type, sync_table, sync_permission, make_item_fn) { 525 | return function(user_id, data) { 526 | data.user_id = user_id; 527 | data = vlad.validate(sync_type, data); 528 | var space_id = data.space_id; 529 | return exports.permissions_check(user_id, space_id, sync_permission) 530 | .then(function(_) { 531 | return db.upsert(sync_table, make_item_fn(data), 'id'); 532 | }) 533 | .tap(function(item) { 534 | return exports.get_space_user_ids(space_id) 535 | .then(function(user_ids) { 536 | return sync_model.add_record(user_ids, user_id, sync_type, item.id, 'add'); 537 | }) 538 | .then(function(sync_ids) { 539 | item.sync_ids = sync_ids; 540 | }); 541 | }); 542 | }; 543 | }; 544 | 545 | /** 546 | * Abstracts editing a specific object type in a space. Handles validation, 547 | * updating, permissions checks, and creation of the corresponding sync records. 548 | */ 549 | exports.simple_edit = function(sync_type, sync_table, sync_permission, get_by_id, make_item_fn) { 550 | return function(user_id, data) { 551 | data = vlad.validate(sync_type, data); 552 | return get_by_id(data.id) 553 | .then(function(item_data) { 554 | if(!item_data) throw error.not_found(sync_type+' '+data.id+' does not exist'); 555 | // preserve user_id/space_id 556 | // And Charlie and I, we go down the sewer. And first thing we 557 | // do is to preserve our clothes, we take... take our clothes 558 | // off. We get totally naked because you don't want to get wet. 559 | // We ball our clothes up. We stick them up some place high. 560 | data.user_id = item_data.user_id; 561 | data.space_id = item_data.space_id; 562 | return exports.permissions_check(user_id, data.space_id, sync_permission) 563 | .then(function(_) { 564 | return db.update(sync_table, data.id, make_item_fn(data, item_data)); 565 | }); 566 | }) 567 | .tap(function(item) { 568 | return exports.get_space_user_ids(data.space_id) 569 | .then(function(user_ids) { 570 | return sync_model.add_record(user_ids, user_id, sync_type, item.id, 'edit'); 571 | }) 572 | .then(function(sync_ids) { 573 | item.sync_ids = sync_ids; 574 | }); 575 | }); 576 | }; 577 | }; 578 | 579 | /** 580 | * Abstracts deleting a specific object type from a space. Handles permissions, 581 | * deletion, and sync record creation. 582 | */ 583 | exports.simple_delete = function(sync_type, sync_table, sync_permissions, get_by_id) { 584 | return function(user_id, item_id) { 585 | var space_id = null; 586 | return get_by_id(item_id) 587 | .then(function(item_data) { 588 | if(!item_data) error.promise_throw('doesnt_exist'); 589 | space_id = item_data.space_id; 590 | return exports.permissions_check(user_id, space_id, sync_permissions); 591 | }) 592 | .then(function() { 593 | return db.delete(sync_table, item_id); 594 | }) 595 | .then(function() { 596 | return exports.get_space_user_ids(space_id) 597 | .then(function(user_ids) { 598 | return sync_model.add_record(user_ids, user_id, sync_type, item_id, 'delete'); 599 | }); 600 | }) 601 | .catch(error.promise_catch('doesnt_exist'), function() { 602 | // silently ignore deleting something that doesn't exist. 603 | return []; 604 | }) 605 | }; 606 | }; 607 | 608 | /** 609 | * Abstracts moving an item from one space to another space (ex, a board or a 610 | * note). 611 | */ 612 | exports.simple_move_space = function(sync_type, sync_table, perms_delete, perms_add, get_by_id, post_move_fn) { 613 | return function(user_id, data) { 614 | data = vlad.validate(sync_type, data); 615 | var item_id = data.id; 616 | var old_space_id = null; 617 | var new_space_id = null; 618 | return get_by_id(item_id) 619 | .then(function(cur_item_data) { 620 | if(!cur_item_data) throw error.not_found('that space was not found'); 621 | old_space_id = cur_item_data.space_id; 622 | new_space_id = data.space_id; 623 | // the jackass catcher 624 | if(old_space_id == new_space_id) { 625 | error.promise_throw('same_space', cur_item_data); 626 | } 627 | return Promise.all([ 628 | cur_item_data, 629 | old_space_id, 630 | new_space_id, 631 | // if either permission check fails, we get booted 632 | exports.permissions_check(user_id, old_space_id, perms_delete), 633 | exports.permissions_check(user_id, new_space_id, perms_add), 634 | ]); 635 | }) 636 | .spread(function(cur_item_data, old_space_id, new_space_id, _can_delete, _can_add) { 637 | cur_item_data.space_id = new_space_id; 638 | // gotta update those keys or the whole sweater unravels 639 | cur_item_data.keys = data.keys; 640 | var update = { 641 | space_id: new_space_id, 642 | data: cur_item_data, 643 | }; 644 | return db.update(sync_table, item_id, update) 645 | .tap(function(item) { 646 | var user_promises = [ 647 | exports.get_space_user_ids(old_space_id), 648 | exports.get_space_user_ids(new_space_id), 649 | ]; 650 | return Promise.all(user_promises) 651 | .spread(function(old_user_ids, new_user_ids) { 652 | var split_users = sync_model.split_same_users(old_user_ids, new_user_ids); 653 | var action_map = { 654 | same: 'edit', 655 | old: 'delete', 656 | new: 'add', 657 | }; 658 | return sync_model.add_records_from_split(user_id, split_users, action_map, sync_type, item_id); 659 | }) 660 | .then(function(syncs) { 661 | item.sync_ids = util.flatten(syncs); 662 | }); 663 | }); 664 | }) 665 | .tap(function(item) { 666 | // if we have a post-move function, run it with some useful 667 | // info. for instance, a board may want to update and create 668 | // sync records for all of its notes to point to the new 669 | // space when it moves 670 | if(!post_move_fn) return; 671 | return post_move_fn(user_id, item, old_space_id, new_space_id) 672 | .then(function(sync_ids) { 673 | if(!item.sync_ids) item.sync_ids = []; 674 | item.sync_ids = item.sync_ids.concat(sync_ids); 675 | }); 676 | }) 677 | .catch(error.promise_catch('same_space'), function(err) { 678 | var item = err.same_space; 679 | return {data: item, sync_ids: []}; 680 | }); 681 | }; 682 | }; 683 | 684 | /** 685 | * Gets the size of a space in bytes (includes note content and files). 686 | */ 687 | exports.get_space_size = function(space_id) { 688 | var qry = [ 689 | 'SELECT', 690 | ' OCTET_LENGTH(n.data->>\'body\') AS nsize,', 691 | ' (data#>>\'{file,size}\')::int AS fsize', 692 | 'FROM', 693 | ' notes n', 694 | 'WHERE', 695 | ' space_id = {{space_id}}', 696 | ]; 697 | return db.query(qry.join('\n'), {space_id: space_id}) 698 | .then(function(notes) { 699 | return notes.reduce(function(acc, x) { 700 | return acc + parseInt(x.nsize || 0) + parseInt(x.fsize || 0); 701 | }, 0); 702 | }); 703 | }; 704 | 705 | sync_model.register('space', { 706 | 'add': add, 707 | 'edit': edit, 708 | 'delete': del, 709 | 'link': link, 710 | }); 711 | 712 | -------------------------------------------------------------------------------- /models/sync.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Promise = require('bluebird'); 4 | const db = require('../helpers/db'); 5 | const error = require('../helpers/error'); 6 | const analytics = require('./analytics'); 7 | const util = require('../helpers/util'); 8 | const log = require('../helpers/log'); 9 | const config = require('../helpers/config'); 10 | const plugins = require('../helpers/plugins'); 11 | 12 | // holds our sync mappings. models will register themselves to the sync system 13 | // via the `register()` call 14 | var process_sync_map = {}; 15 | 16 | /** 17 | * Register a model with the sync system (used mainly for 18 | * process_incoming_sync()) 19 | */ 20 | exports.register = function(type, syncs) { 21 | log.debug('register sync: '+type+': ['+Object.keys(syncs).join(', ')+']'); 22 | process_sync_map[type] = syncs; 23 | }; 24 | 25 | // ----------------------------------------------------------------------------- 26 | // NOTE: i'd normally put these with the other imports at the top, but we *need* 27 | // to define `sync.register()` before loading the models. 28 | // ----------------------------------------------------------------------------- 29 | var user_model = require('./user'); 30 | var keychain_model = require('./keychain'); 31 | var space_model = require('./space'); 32 | var board_model = require('./board'); 33 | var note_model = require('./note'); 34 | var invite_model = require('./invite'); 35 | 36 | /** 37 | * Make a sync record. 38 | */ 39 | var make_sync_record = function(user_id, item_type, item_id, action) { 40 | return { 41 | user_id: user_id, 42 | type: item_type, 43 | item_id: item_id, 44 | action: action, 45 | }; 46 | }; 47 | 48 | /** 49 | * Given an item that can be synced, convert it into a sync record. 50 | */ 51 | var convert_to_sync = function(item, type, action) { 52 | var user_id = item.user_id; 53 | if(!user_id && type == 'invite') user_id = item.from_user_id; 54 | var sync = make_sync_record(user_id, type, item.id, action); 55 | if(action == 'delete') { 56 | sync.data = {id: item.id, deleted: true}; 57 | } else { 58 | sync.data = item; 59 | } 60 | return sync; 61 | }; 62 | 63 | /** 64 | * inserts a sync record and attaches it to the given space_ids. this is how 65 | * various clients share data with each other. 66 | */ 67 | exports.add_record = function(affected_user_ids, creator_user_id, type, object_id, action) { 68 | // if this affects no users, then it's useless, but not worth derailing the 69 | // sync process. return a blank array. 70 | if(affected_user_ids.length == 0) return Promise.resolve([]); 71 | 72 | affected_user_ids = util.dedupe(affected_user_ids); 73 | var sync_rec = make_sync_record(creator_user_id, type, object_id, action); 74 | return db.insert('sync', sync_rec) 75 | .tap(function(sync) { 76 | return db.insert('sync_users', affected_user_ids.map(function(user_id) { 77 | return {sync_id: sync.id, user_id: user_id}; 78 | })); 79 | }) 80 | .then(function(sync) { 81 | return [sync.id]; 82 | }); 83 | }; 84 | 85 | /** 86 | * Given a set of old and new user ids, find all users that are the same and 87 | * return same, old, new (all unique from each other). 88 | * 89 | * This is useful when you WOULD be tempted to do a delete-on-old/add-on-new 90 | * double-sync, but some of your users would want a edit-on-same for a less 91 | * jarring experience in the client. 92 | */ 93 | exports.split_same_users = function(old_user_ids, new_user_ids) { 94 | var in_both = []; 95 | old_user_ids.forEach(function(old_user_id) { 96 | if(new_user_ids.indexOf(old_user_id) >= 0) { 97 | in_both.push(old_user_id); 98 | } 99 | }); 100 | old_user_ids = old_user_ids.filter(function(id) { return in_both.indexOf(id) < 0; }); 101 | new_user_ids = new_user_ids.filter(function(id) { return in_both.indexOf(id) < 0; }); 102 | return { 103 | old: old_user_ids, 104 | new: new_user_ids, 105 | same: in_both, 106 | }; 107 | }; 108 | 109 | /** 110 | * Add sync records from a split returned from split_same_users 111 | */ 112 | exports.add_records_from_split = function(user_id, split_obj, action_map, sync_type, item_id) { 113 | var promises = []; 114 | var push_sync = function(user_ids, action) { 115 | promises.push(exports.add_record(user_ids, user_id, sync_type, item_id, action)); 116 | }; 117 | ['same', 'old', 'new'].forEach(function(split_type) { 118 | push_sync(split_obj[split_type], action_map[split_type]); 119 | }); 120 | return Promise.all(promises); 121 | }; 122 | 123 | /** 124 | * takes a set of sync records and a set of items (presumably pulled out from 125 | * said sync records) and matches them together. destructive on sync_records. 126 | */ 127 | var populate_sync_records_with_items = function(sync_records, items) { 128 | var item_index = {}; 129 | items.forEach(function(item) { item_index[item.id] = item; }); 130 | sync_records.forEach(function(sync) { 131 | var item = item_index[sync.item_id]; 132 | if(item) { 133 | sync.data = item; 134 | } else { 135 | sync.data = {missing: true}; 136 | } 137 | }); 138 | return sync_records; 139 | }; 140 | 141 | /** 142 | * Given a collection of sync records, link in their corresponding data for each 143 | * item type. For instance, if we have: 144 | * { 145 | * id: 1234, 146 | * item_id: '6969', 147 | * type: 'note', 148 | * action: 'add' 149 | * } 150 | * when done, we'll have: 151 | * { 152 | * id: 1234, 153 | * item_id: '6969', 154 | * type: 'note', 155 | * action: 'add' 156 | * data: {id: '6969', body: 'abcd==', ...} 157 | * } 158 | * Note that we pulled out the actual note related to this sync record. Wicked. 159 | */ 160 | var link_sync_records = function(sync_records) { 161 | var mapped = {}; 162 | var deleted = []; 163 | var present = []; 164 | // split our sync records between deleted and non-deleted. deleted records 165 | // require no real processing/linking and can just be shoved in at the end 166 | // of the entire process (just before sorting everything). 167 | sync_records.forEach(function(sync) { 168 | if(sync.action == 'delete') { 169 | sync.data = {id: sync.item_id, deleted: true}; 170 | deleted.push(sync); 171 | } else { 172 | present.push(sync); 173 | } 174 | }); 175 | // group our present sync records by sync.type 176 | present.forEach(function(sync) { 177 | var type = sync.type; 178 | if(!mapped[type]) mapped[type] = []; 179 | mapped[type].push(sync); 180 | }); 181 | var promises = []; 182 | Object.keys(mapped).forEach(function(type) { 183 | if(!process_sync_map[type]) { 184 | throw error.bad_request('Missing sync handler for type `'+type+'`'); 185 | } 186 | var sync_type_handler = process_sync_map[type]; 187 | var link = sync_type_handler.link; 188 | if(!link) { 189 | throw error.bad_request('Missing sync handler for type `'+type+'.link`'); 190 | } 191 | var sync_records = mapped[type]; 192 | var promise = Promise.resolve([]); 193 | if(sync_records.length > 0) { 194 | promise = link(sync_records.map(function(s) { return s.item_id; })) 195 | .then(function(items) { 196 | return populate_sync_records_with_items(sync_records, items); 197 | }); 198 | } 199 | promises.push(promise); 200 | }); 201 | return Promise.all(promises) 202 | .then(function(grouped_syncs) { 203 | var ungrouped = deleted; 204 | var latest_sync_id = 0; 205 | grouped_syncs.forEach(function(sync_records) { 206 | sync_records.forEach(function(sync) { 207 | if(sync.id > latest_sync_id) latest_sync_id = sync.id; 208 | ungrouped.push(sync); 209 | }); 210 | }); 211 | ungrouped.forEach(function(sync) { 212 | if(sync.id > latest_sync_id) latest_sync_id = sync.id; 213 | }); 214 | return [ 215 | ungrouped.sort(function(a, b) { return a.id - b.id; }), 216 | latest_sync_id > 0 ? latest_sync_id : null, 217 | ]; 218 | }); 219 | }; 220 | 221 | /** 222 | * Removes any private data from sync records (like invite server tokens, for 223 | * instance) 224 | */ 225 | var clean_sync_records = function(sync_records) { 226 | return sync_records.map(function(sync) { 227 | if(!process_sync_map[sync.type] || !process_sync_map[sync.type].clean) return sync; 228 | sync.data = process_sync_map[sync.type].clean(sync.data); 229 | return sync; 230 | }); 231 | }; 232 | 233 | /** 234 | * Given space sync records with action "(un)share", replace the share sync 235 | * record(s) with full data from that space (boards/notes). 236 | * 237 | * note that if a space is unshared, we explicitely send back "delete" sync 238 | * items for EACH member of the space (boards/notes/invites) individually. 239 | */ 240 | var populate_shares = function(user_id, sync_records) { 241 | var populated = []; 242 | return Promise.each(sync_records, function(sync) { 243 | if(sync.type == 'space' && ['share', 'unshare'].indexOf(sync.action) >= 0) { 244 | // get all boards/notes from this space 245 | var action = sync.action == 'share' ? 'add' : 'delete'; 246 | return space_model.user_has_permission(user_id, sync.item_id, space_model.permissions.add_space_invite) 247 | .then(function(has_perm) { 248 | return space_model.get_data_tree(sync.item_id, {skip_invites: !has_perm}); 249 | }) 250 | .spread(function(space, boards, notes) { 251 | // make sure the space actually exists before creating our 252 | // sync records. otherwise, we just pass through the 253 | // original sync record, but with our add/delete action 254 | // (and we'll have {missing: true} for our `data` tee hee) 255 | if(space) { 256 | populated.push(convert_to_sync(space, 'space', action)); 257 | boards.forEach(function(item) { 258 | var sync = convert_to_sync(item, 'board', action); 259 | populated.push(sync); 260 | }); 261 | notes.forEach(function(item) { 262 | var sync = convert_to_sync(item, 'note', action); 263 | populated.push(sync); 264 | }); 265 | notes.forEach(function(item) { 266 | if(!item.has_file) return; 267 | var sync = convert_to_sync(item, 'file', action); 268 | populated.push(sync); 269 | }); 270 | } else { 271 | // ah ah! alex, remember what we talked about? mmhmm 272 | // thank you. shutup. thank you. 273 | sync.action = action; 274 | populated.push(sync); 275 | } 276 | }); 277 | } else { 278 | populated.push(sync); 279 | } 280 | }).then(function() { return populated; }); 281 | }; 282 | 283 | var poll_sync_items = function(user_id, from_sync_id, poll, cutoff) { 284 | var qry = [ 285 | 'SELECT', 286 | ' s.*', 287 | 'FROM', 288 | ' sync s, sync_users su', 289 | 'WHERE', 290 | ' s.id = su.sync_id AND', 291 | ' su.user_id = {{user_id}} AND', 292 | ' s.id > {{sync_id}}', 293 | 'ORDER BY', 294 | ' s.id ASC', 295 | ].join('\n'); 296 | return db.query(qry, {user_id: user_id, sync_id: from_sync_id}) 297 | .then(function(sync_records) { 298 | var now = new Date().getTime(); 299 | if(sync_records.length > 0 || !poll || (poll && now > cutoff)) { 300 | // if we're polling (normal use), then when a sync comes in, 301 | // there's a great chance we're going to return the first part 302 | // of the sync before the entire thing finishes, which means the 303 | // client won't have access to all the sync_ids that were 304 | // created BEFORE the incoming sync triggers. race condition, 305 | // really. so what we do is delay arbitrarily to give whatever 306 | // triggered the incoming sync time to finish. 307 | if(poll) { 308 | return util.delay(500, sync_records); 309 | } else { 310 | return sync_records; 311 | } 312 | } 313 | return util.delay(2500) 314 | .then(function() { 315 | return poll_sync_items(user_id, from_sync_id, poll, cutoff); 316 | }); 317 | }); 318 | }; 319 | 320 | /** 321 | * Grab all the sync records for a user id AFTER the given sync id. 322 | */ 323 | exports.sync_from = function(user_id, from_sync_id, poll) { 324 | if(!from_sync_id && from_sync_id !== 0) { 325 | return Promise.reject(error.bad_request('missing `sync_id` var: '+JSON.stringify(from_sync_id))); 326 | } 327 | var cutoff = (new Date().getTime()) + (1000 * 20); 328 | return poll_sync_items(user_id, from_sync_id, poll, cutoff) 329 | .then(function(sync_records) { 330 | return link_sync_records(sync_records); 331 | }) 332 | .spread(function(sync_records, latest_sync_id) { 333 | return populate_shares(user_id, sync_records) 334 | .then(function(sync_records) { 335 | return clean_sync_records(sync_records); 336 | }) 337 | .then(function(sync_records) { 338 | return plugins.with('sync', function(syncer) { return syncer.sync_meta(user_id); }) 339 | .then(function(sync_meta) { 340 | return [ 341 | sync_records, 342 | latest_sync_id || from_sync_id, 343 | sync_meta, 344 | ]; 345 | }); 346 | }); 347 | }); 348 | }; 349 | 350 | /** 351 | * Processes a sync item using the sync handlers that have registered themselves 352 | * with the sync system. Returns the final item added/edited/deleted/etced. 353 | */ 354 | var process_incoming_sync = function(user_id, sync) { 355 | var item = sync.data; 356 | if(!process_sync_map[sync.type]) { 357 | return Promise.reject(error.bad_request('Missing sync handler for type `'+sync.type+'`')); 358 | } 359 | var sync_type_handler = process_sync_map[sync.type]; 360 | if(!sync_type_handler[sync.action]) { 361 | var allowed_actions = Object.keys(sync_type_handler).join(', '); 362 | return Promise.reject(error.bad_request('Missing sync handler for type `'+sync.type+'.'+sync.action+'` (allowed actions for '+sync.type+': ['+allowed_actions+'])')); 363 | } 364 | 365 | // run the sync item through the sync plugin for ......processing 366 | var sync_plugin_promise = plugins.with('sync', function(syncer) { 367 | return syncer.sync_item(user_id, sync); 368 | }); 369 | return sync_plugin_promise 370 | .then(function(_sync) { 371 | var handler = sync_type_handler[sync.action]; 372 | var handler_data = null; 373 | if(sync.action == 'delete' && !sync_type_handler.skip_standard_delete) { 374 | handler_data = item.id; 375 | } else { 376 | handler_data = sync.data; 377 | } 378 | try { 379 | var promise = handler(user_id, handler_data); 380 | } catch(err) { 381 | return Promise.reject(err); 382 | } 383 | return promise; 384 | }) 385 | .then(function(item_data) { 386 | if(sync.action == 'delete' && !sync_type_handler.skip_standard_delete) { 387 | // return a standard "delete" item (unless the handler says 388 | // otherwise) 389 | return {id: sync.data.id, sync_ids: item_data}; 390 | } 391 | // NOTE: since our sync handlers are expected to return the full 392 | // db record, and we really only want to return the object's `data`, 393 | // here we grab the data and set in our sync_ids 394 | var data = item_data.data; 395 | if(!data.id && item_data.id) data.id = item_data.id; 396 | data.sync_ids = item_data.sync_ids; 397 | return item_data.data; 398 | }); 399 | }; 400 | 401 | /** 402 | * Given a user_id and a set of incoming sync records, apply the records to the 403 | * user's profile. 404 | */ 405 | exports.bulk_sync = function(user_id, sync_records, client) { 406 | // enforce our sync.max_bulk_sync_records config 407 | var max_sync_records = (config.sync || {}).max_bulk_sync_records; 408 | if(max_sync_records) { 409 | sync_records = sync_records.slice(0, max_sync_records); 410 | } 411 | var breakdown = {}; 412 | sync_records.forEach(function(sync) { 413 | var key = sync.type+'.'+sync.action; 414 | if(!breakdown[key]) breakdown[key] = 0; 415 | breakdown[key]++; 416 | }); 417 | log.info('sync.bulk_sync() -- user '+user_id+': syncing '+sync_records.length+' items: ', breakdown); 418 | 419 | // assign each sync item a unique id so we can track successes vs failures 420 | sync_records.forEach(function(sync, i) { sync._id = i + 1; }); 421 | var success_idx = {}; 422 | 423 | var successes = []; 424 | return Promise.each(sync_records, function(sync) { 425 | var sync_client_id = sync.id; 426 | log.debug('sync.bulk_sync() -- sync item start: ', sync_client_id, sync.action, sync.type); 427 | return process_incoming_sync(user_id, sync) 428 | .tap(function(item) { 429 | log.debug('sync.bulk_sync() -- sync item done: ', sync_client_id); 430 | var sync_ids = item.sync_ids; 431 | delete item.sync_ids; 432 | successes.push({ 433 | id: sync_client_id, 434 | user_id: user_id, 435 | item_id: item.id, 436 | type: sync.type, 437 | action: sync.action, 438 | sync_ids: sync_ids, 439 | data: item, 440 | }); 441 | success_idx[sync._id] = true; 442 | // DON'T return, we don't want failed analytics to grind the 443 | // sync to a halt 444 | analytics.track(user_id, sync.type+'.'+sync.action, client); 445 | }) 446 | .catch(function(err) { 447 | log.error('sync.bulk_sync() -- ', err); 448 | // store the errmsg in the sync item itself, which will be 449 | // returned to the client. 450 | sync.error = {code: err.status || 500, msg: err.message}; 451 | }); 452 | }).then(function() { 453 | log.debug('sync.bulk_sync() -- sync complete'); 454 | return plugins.with('sync', function(syncer) { return syncer.sync_meta(user_id); }) 455 | .then(function(plugin_data) { 456 | return { 457 | // return all successful syncs 458 | success: successes, 459 | // return all failed syncs 460 | failures: sync_records.filter(function(sync) { 461 | return !success_idx[sync._id] && sync.error; 462 | }), 463 | // return all syncs that cannot continue because they are blocked by 464 | // a failure (remember, syncs process one after the other...if one 465 | // fails, the rest of the chain cannot continue) 466 | blocked: sync_records.filter(function(sync) { 467 | return !success_idx[sync._id] && !sync.error; 468 | }), 469 | // return the sync plugin extra data 470 | extra: plugin_data, 471 | }; 472 | }); 473 | }); 474 | }; 475 | 476 | /** 477 | * Grab all a user's profile data, in the form of sync records. 478 | */ 479 | exports.full_sync = function(user_id) { 480 | var user; 481 | var sync_records = []; 482 | var space_ids = []; 483 | return user_model.get_by_id(user_id, {data: true}) 484 | .then(function(_user) { 485 | user = _user; 486 | user.user_id = user_id; 487 | sync_records.push(convert_to_sync(user, 'user', 'add')); 488 | delete user.user_id; 489 | return keychain_model.get_by_user(user_id); 490 | }) 491 | .then(function(keychain) { 492 | keychain.forEach(function(entry) { 493 | sync_records.push(convert_to_sync(entry, 'keychain', 'add')); 494 | }); 495 | return space_model.get_by_user_id(user_id); 496 | }) 497 | .then(function(spaces) { 498 | return Promise.all(spaces.map(function(space) { 499 | space_ids.push(space.id); 500 | return space_model.user_has_permission(user_id, space.id, space_model.permissions.add_space_invite) 501 | .then(function(has_perm) { 502 | if(!has_perm) delete space.data.invites; 503 | // spaces return the top-level object, not space.data, so we 504 | // have to dig in to create the sync item. 505 | sync_records.push(convert_to_sync(space.data, 'space', 'add')); 506 | }); 507 | })); 508 | }) 509 | .then(function(spaces) { 510 | return board_model.get_by_spaces(space_ids); 511 | }) 512 | .then(function(boards) { 513 | boards.forEach(function(board) { 514 | sync_records.push(convert_to_sync(board, 'board', 'add')); 515 | }); 516 | return note_model.get_by_spaces(space_ids); 517 | }) 518 | .then(function(notes) { 519 | notes.forEach(function(note) { 520 | sync_records.push(convert_to_sync(note, 'note', 'add')); 521 | }); 522 | notes.forEach(function(note) { 523 | if(!note.has_file) return; 524 | sync_records.push(convert_to_sync(note, 'file', 'add')); 525 | }); 526 | return invite_model.get_by_to_email(user.username); 527 | }) 528 | .then(function(invites) { 529 | invites.forEach(function(invite) { 530 | sync_records.push(convert_to_sync(invite, 'invite', 'add')); 531 | }); 532 | return db.first('SELECT MAX(id) AS sync_id FROM sync') 533 | .then(function(rec) { return rec.sync_id; }); 534 | }) 535 | .then(function(sync_id) { 536 | return plugins.with('sync', function(syncer) { return syncer.sync_meta(user_id); }) 537 | .then(function(sync_meta) { 538 | return { 539 | sync_id: sync_id || 0, 540 | records: sync_records.map(function(s) {s.id = 0; return s;}), 541 | extra: sync_meta, 542 | }; 543 | }); 544 | }); 545 | }; 546 | 547 | -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const log = require('../helpers/log'); 4 | 5 | var db = require('../helpers/db'); 6 | var config = require('../helpers/config'); 7 | var Promise = require('bluebird'); 8 | var error = require('../helpers/error'); 9 | var vlad = require('../helpers/validator'); 10 | var crypto = require('crypto'); 11 | var sync_model = require('./sync'); 12 | var space_model = require('./space'); 13 | var board_model = require('./board'); 14 | var note_model = require('./note'); 15 | var invite_model = require('./invite'); 16 | var keychain_model = require('./keychain'); 17 | var analytics = require('./analytics'); 18 | var email_model = require('./email'); 19 | var profile_model = require('./profile'); 20 | 21 | vlad.define('user', { 22 | username: {type: vlad.type.email}, 23 | pubkey: {type: vlad.type.string}, 24 | name: {type: vlad.type.string}, 25 | body: {type: vlad.type.string}, 26 | }); 27 | 28 | /** 29 | * do a pbkdf2 on our private data using the app's SECRET hash 30 | */ 31 | var secure_hash = function(privatedata, options) { 32 | options || (options = {}); 33 | var iter = options.iter || 100000; 34 | var output = options.output || 'hex'; 35 | 36 | var res = crypto.pbkdf2Sync(privatedata, config.app.secure_hash_salt, iter, 128, 'sha256'); 37 | return res.toString(output); 38 | }; 39 | exports.secure_hash = secure_hash; 40 | 41 | /** 42 | * who needs constant-time comparisons when you can just double-hmac? 43 | * 44 | * find out why this one weird app has password crackers FURIOUS!!! 45 | */ 46 | var secure_compare = function(secret1, secret2) { 47 | var now = new Date().getTime(); 48 | var key = now+'|'+config.app.secure_hash_salt; 49 | var hmac1 = crypto.createHmac('sha256', key).update(secret1).digest('base64'); 50 | var hmac2 = crypto.createHmac('sha256', key).update(secret2).digest('base64'); 51 | return hmac1 == hmac2; 52 | }; 53 | exports.secure_compare = secure_compare; 54 | 55 | /** 56 | * create a random token. useful for creating values the server knows that users 57 | * do not (invite tokens et al). 58 | */ 59 | var random_token = function(options) { 60 | options || (options = {}); 61 | var hash = options.hash || 'sha256'; 62 | 63 | var rand = crypto.randomBytes(64); 64 | return crypto 65 | .createHash(hash) 66 | .update(rand) 67 | .digest('hex'); 68 | }; 69 | exports.random_token = random_token; 70 | 71 | /** 72 | * remove any sensitive data from a user object 73 | */ 74 | var clean_user = function(user) { 75 | delete user.auth; 76 | return user; 77 | }; 78 | 79 | var auth_hash = function(authkey) { 80 | // two iterations. yes, two. if someone gets the database, they 81 | // won't be able to crack the real auth key out of it since it's 82 | // just a binary blob anyway, meaning this step only exists to keep 83 | // them from being able to impersonate the user (not to hide the 84 | // secret it holds, since there IS no secret...even if they cracked 85 | // the auth data, they'd have to have the user's key to decrypt it). 86 | return secure_hash(authkey, {output: 'base64', iter: 2}); 87 | }; 88 | 89 | exports.check_auth = function(authinfo) { 90 | if(!authinfo) return Promise.reject(error.forbidden('bad login: (bad auth)')); 91 | var base64_auth = authinfo.replace(/^Basic */, ''); 92 | var parsed = new Buffer(base64_auth, 'base64').toString('utf8'); 93 | var auth_parts = parsed.split(':'); 94 | var username = auth_parts[0]; 95 | var auth = auth_parts[1]; 96 | 97 | return db.first('SELECT * FROM users WHERE username = {{username}}', {username: username}) 98 | .then(function(user) { 99 | if(!user) throw error.forbidden('bad login: '+username); 100 | if(!user.active) throw error.forbidden('user inactive'); 101 | if(config.app.login.max_attemps > 0 && user.login_failed_count >= config.app.login.max_attemps) { 102 | var currentDate = new Date(); 103 | if(currentDate.getTime() - user.login_failed_last.getTime() <= config.app.login.lock_duration*1000) { 104 | throw error.forbidden('user locked'); 105 | } 106 | } 107 | if(!secure_compare(user.auth, auth_hash(auth))) { 108 | exports.update_login_failed(user.id); 109 | throw error.forbidden('bad login'); 110 | } else { 111 | exports.reset_login_failed(user.id); 112 | } 113 | 114 | return clean_user(user); 115 | }); 116 | }; 117 | 118 | exports.join = function(userdata) { 119 | if(!userdata.auth) return Promise.reject(error.bad_request('missing `auth` key')); 120 | if(!userdata.username) return Promise.reject(error.bad_request('missing `username` key (must be a valid email)')); 121 | if(!userdata.username.match(/@/)) return Promise.reject(error.bad_request('please enter a valid email')); 122 | try { 123 | var data = vlad.validate('user', userdata.data || {}); 124 | } catch(e) { 125 | return Promise.reject(e); 126 | } 127 | 128 | // make sure username is lowercase 129 | userdata.username = userdata.username.toLowerCase(); 130 | 131 | // create a confirmation token 132 | var token = random_token({hash: 'sha512'}); 133 | 134 | // check existing username 135 | return exports.get_by_email(userdata.username, {raw: true}) 136 | .then(function(existing) { 137 | if(existing) throw error.forbidden('the account "'+userdata.username+'" already exists'); 138 | var auth = auth_hash(userdata.auth); 139 | return db.insert('users', { 140 | username: userdata.username, 141 | auth: auth, 142 | active: true, 143 | confirmed: false, 144 | confirmation_token: token, 145 | data: db.json(data), 146 | last_login: db.literal('now()'), 147 | }); 148 | }) 149 | .tap(function(user) { 150 | // DON'T return. if the confirmation email fails, the user can send 151 | // again through the settings interface 152 | send_confirmation_email(user); 153 | }) 154 | .tap(function(user) { 155 | return analytics.join(user.id, { 156 | $distinct_id: user.id, 157 | $email: user.username, 158 | $name: (user.data || {}).name, 159 | }); 160 | }) 161 | .then(clean_user); 162 | }; 163 | 164 | var send_confirmation_email = function(user) { 165 | var subject = 'Welcome to Turtl! Please confirm your email'; 166 | var confirmation_url = config.app.api_url+'/users/confirm/'+encodeURIComponent(user.username)+'/'+encodeURIComponent(user.confirmation_token); 167 | var body = [ 168 | 'Welcome to Turtl! Your account is active and you\'re ready to start using the app.', 169 | '', 170 | 'However, sharing is disabled on your account until you confirm your email by going here:', 171 | '', 172 | confirmation_url, 173 | '', 174 | 'You can resend this confirmation email at any time through the app by opening the Turtl menu and going to Your settings -> Resend confirmation', 175 | '', 176 | 'Thanks!', 177 | '- Turtl team', 178 | ].join('\n'); 179 | return email_model.send(config.app.emails.info, user.username, subject, body) 180 | .catch(function(err) { 181 | throw error.internal('problem sending confirmation email: '+err.message); 182 | }); 183 | }; 184 | 185 | exports.confirm_user = function(email, token) { 186 | return exports.get_by_email(email, {raw: true}) 187 | .then(function(user) { 188 | if(!user) throw error.not_found('that email isn\'t attached to an active account'); 189 | if(user.confirmed) throw error.conflict('that account has already been confirmed'); 190 | var server_token = user.confirmation_token; 191 | if(!server_token) throw error.internal('that account has no confirmation token'); 192 | if(!secure_compare(token, server_token)) throw error.bad_request('invalid confirmation token'); 193 | return db.update('users', user.id, {confirmed: true, confirmation_token: null}); 194 | }) 195 | .tap(function(user) { 196 | return sync_model.add_record([user.id], user.id, 'user', user.id, 'edit'); 197 | }) 198 | .tap(function(user) { 199 | // if thre are pending invites sent to the email that was just 200 | // confirmed, we create invite.add sync records for them so the user 201 | // sees them in their profile. 202 | return invite_model.create_sync_records_for_email(user.id, email); 203 | }) 204 | .then(clean_user); 205 | }; 206 | 207 | exports.resend_confirmation = function(user_id) { 208 | return db.by_id('users', user_id) 209 | .then(function(user) { 210 | if(!user) throw error.not_found('weird, your user account wasn\'t found'); 211 | if(user.confirmed) throw error.bad_request('your account is already confirmed'); 212 | return send_confirmation_email(user); 213 | }) 214 | .then(function() { return true; }); 215 | }; 216 | 217 | exports.delete = function(cur_user_id, user_id) { 218 | if(cur_user_id != user_id) return Promise.reject(error.forbidden('you cannot delete an account you don\'t own')); 219 | 220 | return space_model.get_by_user_id(user_id, {role: space_model.roles.owner}) 221 | .then(function(owned_spaces) { 222 | return Promise.all(owned_spaces.map(function(space) { 223 | return space_model.delete_space(user_id, space.id); 224 | })); 225 | }) 226 | .then(function() { 227 | var params = {user_id: user_id}; 228 | return Promise.all([ 229 | db.query('DELETE FROM keychain WHERE user_id = {{user_id}}', params), 230 | db.query('DELETE FROM users WHERE id = {{user_id}}', params), 231 | ]); 232 | }) 233 | .then(function() { 234 | return true; 235 | }); 236 | }; 237 | 238 | exports.update = function(cur_user_id, user_id, data) { 239 | // error checking 240 | if(cur_user_id != user_id) { 241 | return Promise.reject(error.forbidden('you cannot edit another user\'s account')); 242 | } 243 | var keys = ['user', 'auth', 'keychain']; 244 | for(var i = 0; i < keys.length; i++) { 245 | var key = keys[i]; 246 | if(!data[key]) { 247 | return Promise.reject(error.bad_request('missing `'+key+'` in update data')); 248 | } 249 | } 250 | if(!data.user.username) { 251 | return Promise.reject(error.bad_request('missing `user.username` in update data')); 252 | } 253 | if(!data.user.body) { 254 | return Promise.reject(error.bad_request('missing `user.body` in update data')); 255 | } 256 | 257 | // make sure username is lowercase 258 | data.user.username = data.user.username.toLowerCase(); 259 | 260 | // this is going to get a bit "manual" but we need to manage our connection 261 | // by hand so we can "transact." 262 | var client = null; 263 | var user = null; 264 | var username_changed = false; 265 | var existing_keychain_idx = null; 266 | return exports.get_by_id(user_id) 267 | .then(function(_user) { 268 | user = _user; 269 | if(user.username != data.user.username) username_changed = true; 270 | return keychain_model.get_by_user(user_id); 271 | }) 272 | // make sure the given keychain matches the keychain the profile. this 273 | // is important because if the the user is out of sync and missing a key 274 | // when re-encrypting their profile, they're going to lose data. 275 | .then(function(existing_keychain) { 276 | // index our keychain 277 | existing_keychain_idx = {}; 278 | existing_keychain.forEach(function(k) { 279 | existing_keychain_idx[k.id] = k; 280 | }); 281 | 282 | // simple length check. so simple. a CHILD could do it. 283 | if(existing_keychain.length != data.keychain.length) { 284 | // as for the health service, marijuana will be made available 285 | // free on the NHS for de treatment of chronic diseases. 286 | // 287 | // ...such as itchy scrot. 288 | throw error.conflict('the given keychain doesn\'t match what is in your profile. try clearing local data and try again/'); 289 | } 290 | 291 | // now check that each entry in the db exists in the given keychain. 292 | data.keychain.forEach(function(key) { 293 | if(existing_keychain_idx[key.id]) return; 294 | // in the candy, candy center of your world. 295 | // there's a poison pumped up in your heart. 296 | // the tunnels are all twisted up in knots. 297 | // noone really finds the way back home. 298 | throw error.conflict('the given keychain doesn\'t match what is in your profile. try clearing local data and try again/'); 299 | }); 300 | return db.client(); 301 | }) 302 | // start our transaction 303 | .then(function(_client) { 304 | client = _client; 305 | return client.query('BEGIN'); 306 | }) 307 | // update the user. spill the wine. 308 | .then(function() { 309 | var auth = auth_hash(data.auth); 310 | var qry = ['UPDATE users']; 311 | var sets = [ 312 | 'auth = {{auth}}', 313 | 'data = {{data}}', 314 | ]; 315 | var userdata = user.data; 316 | userdata.body = data.user.body; 317 | var vals = { 318 | auth: auth, 319 | data: db.json(userdata), 320 | user_id: user_id, 321 | }; 322 | if(username_changed) { 323 | var confirmation_token = random_token({hash: 'sha512'}); 324 | sets.push('username = {{username}}'); 325 | sets.push('confirmed = false'); 326 | sets.push('confirmation_token = {{token}}'); 327 | vals.username = data.user.username; 328 | vals.token = confirmation_token; 329 | } 330 | qry.push('SET '+sets.join(', ')); 331 | qry.push('WHERE id = {{user_id}}'); 332 | return client.query(qry.join('\n'), vals); 333 | }) 334 | // now update the keychain. take that girl. 335 | .then(function() { 336 | // loop over each entry, save them one by one. really we just need 337 | // to update the data.body with the new keydata, so our update is 338 | // simple. 339 | return Promise.each(data.keychain, function(key) { 340 | var keydata = existing_keychain_idx[key.id]; 341 | keydata.body = key.body; 342 | var qry = [ 343 | 'UPDATE keychain', 344 | 'SET data = {{data}}', 345 | 'WHERE id = {{id}}', 346 | ]; 347 | var vals = { 348 | data: db.json(keydata), 349 | id: key.id, 350 | }; 351 | return client.query(qry.join('\n'), vals); 352 | }); 353 | }) 354 | // spillthewinespillthewinespillthewine 355 | .then(function() { 356 | return client.query('COMMIT'); 357 | }) 358 | .then(function() { 359 | return space_model.get_members_from_users_spaces(user_id); 360 | }) 361 | // make sync records for our sensitive shit 362 | .then(function(users_spaces_members) { 363 | var promises = [ 364 | sync_model.add_record([user_id], user_id, 'user', user_id, 'change-password'), 365 | ]; 366 | data.keychain.forEach(function(key) { 367 | promises.push(sync_model.add_record([user_id], user_id, 'keychain', key.id, 'edit')); 368 | }); 369 | var space_idx = {}; 370 | users_spaces_members.forEach(function(member_rec) { 371 | var space_id = member_rec.space_id; 372 | if(!space_idx[space_id]) space_idx[space_id] = []; 373 | space_idx[space_id].push(member_rec.user_id); 374 | }); 375 | Object.keys(space_idx).forEach(function(space_id) { 376 | promises.push(sync_model.add_record(space_idx[space_id], user_id, 'space', space_id, 'edit')); 377 | }); 378 | return Promise.all(promises) 379 | .then(function(ids_arr) { 380 | return {sync_ids: ids_arr.map(function(s) { return s[0]; })}; 381 | }); 382 | }) 383 | .tap(function() { 384 | if(!username_changed) return; 385 | // i don't want to be your buddy, rick. i just...want a little confirmation? 386 | return exports.resend_confirmation(user_id); 387 | }) 388 | .finally(function() { 389 | client && client.close(); 390 | }); 391 | }; 392 | 393 | exports.get_by_ids = function(user_ids, options) { 394 | options || (options = {}); 395 | return db.by_ids('users', user_ids) 396 | .each(clean_user) 397 | .map(function(user) { 398 | if(options.profile_size) { 399 | return profile_model.get_profile_size(user.id) 400 | .then(function(size) { 401 | user.profile_size = size; 402 | return user; 403 | }); 404 | } else { 405 | return user; 406 | } 407 | }) 408 | .map(function(user) { 409 | if(!options.data) return user; 410 | var data = user.data; 411 | ['id', 'username', 'confirmed', 'profile_size'].forEach(function(field) { 412 | data[field] = user[field]; 413 | }); 414 | return data; 415 | }); 416 | }; 417 | 418 | exports.get_by_id = function(user_id, options) { 419 | return exports.get_by_ids([user_id], options) 420 | .then(function(users) { 421 | return (users || [])[0]; 422 | }); 423 | }; 424 | 425 | exports.get_by_email = function(email, options) { 426 | options || (options = {}); 427 | email = email.toString().toLowerCase(); 428 | return db.first('SELECT * FROM users WHERE username = {{email}} LIMIT 1', {email: email}) 429 | .then(function(user) { 430 | if(!user) return null; 431 | if(options.raw) return user; 432 | if(options.data) { 433 | var data = user.data; 434 | ['id', 'username', 'confirmed'].forEach(function(field) { 435 | data[field] = user[field]; 436 | }); 437 | return data; 438 | } 439 | return clean_user(user); 440 | }); 441 | }; 442 | 443 | exports.update_last_login = function(user_id) { 444 | return db.query('UPDATE users SET last_login = NOW() WHERE id = {{user_id}}', {user_id: user_id}); 445 | }; 446 | 447 | exports.update_login_failed = function(user_id) { 448 | return db.query('UPDATE users SET login_failed_last = NOW(), login_failed_count = login_failed_count + 1 WHERE id = {{user_id}}', {user_id: user_id}); 449 | }; 450 | 451 | exports.reset_login_failed = function(user_id) { 452 | return db.query('UPDATE users SET login_failed_last = NULL, login_failed_count = 0 WHERE id = {{user_id}}', {user_id: user_id}); 453 | }; 454 | 455 | exports.get_by_emails = function(emails) { 456 | return db.by_ids('users', emails, {id_field: 'username'}) 457 | }; 458 | 459 | exports.delete_by_email = function(email, token) { 460 | return exports.get_by_email(email, {raw: true}) 461 | .then(function(user) { 462 | if(!user) throw error.not_found('that email isn\'t attached to an active account'); 463 | if(!user.data.delete_token) throw error.not_found('the account attached to that email doesn\'t have an active delete token'); 464 | if(!secure_compare(user.data.delete_token, token)) { 465 | throw error.forbidden('the delete token given is incorrect'); 466 | } 467 | return exports.delete(user.id, user.id); 468 | }); 469 | }; 470 | 471 | exports.start_delete_by_email = function(email) { 472 | var delete_token = null; 473 | var username = null; 474 | return exports.get_by_email(email, {raw: true}) 475 | .then(function(user) { 476 | if(!user) throw error.not_found('that email isn\'t attached to an active account'); 477 | username = user.username; 478 | const data = user.data || {}; 479 | delete_token = data.delete_token; 480 | if(delete_token) return; 481 | delete_token = random_token({hash: 'sha512'}); 482 | data.delete_token = delete_token; 483 | return db.update('users', user.id, {data: data}); 484 | }) 485 | .then(function() { 486 | const delete_url = config.app.api_url+'/users/delete/'+encodeURIComponent(username)+'/'+encodeURIComponent(delete_token); 487 | const subject = 'Confirm deletion of your account'; 488 | const body = [ 489 | 'To confirm the deletion of your account, please go here: '+delete_url, 490 | '', 491 | 'If you did not request to delete your Turtl account, you can ignore this email.', 492 | ].join('\n'); 493 | return email_model.send(config.app.emails.info, username, subject, body) 494 | }) 495 | .then(function() { 496 | return true; 497 | }); 498 | }; 499 | 500 | var edit = function(user_id, data) { 501 | if(user_id != data.id) return Promise.reject(error.forbidden('you cannot edit someone else\'s user record. shame shame.')); 502 | data = vlad.validate('user', data); 503 | return db.update('users', user_id, {data: data}) 504 | .tap(function(user) { 505 | return sync_model.add_record([], user_id, 'user', user_id, 'edit') 506 | .then(function(sync_ids) { 507 | user.sync_ids = sync_ids; 508 | }); 509 | }); 510 | }; 511 | 512 | var link = function(ids) { 513 | return exports.get_by_ids(ids, {data: true}); 514 | }; 515 | 516 | sync_model.register('user', { 517 | edit: edit, 518 | link: link, 519 | }); 520 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turtl-server", 3 | "version": "0.1.3", 4 | "description": "Turtl's node-based API server (sorry, lisp)", 5 | "author": "Andrew Lyon", 6 | "license": "AGPL-3.0", 7 | "repository": "https://github.com/turtl/server", 8 | "scripts": { 9 | "preinstall": "bash scripts/install-plugins.sh", 10 | "start": "node server.js" 11 | }, 12 | "dependencies": { 13 | "aws-sdk": "2.9.0", 14 | "bluebird": "3.4.7", 15 | "body-parser": "1.15.2", 16 | "express": "4.14.0", 17 | "method-override": "2.3.10", 18 | "morgan": "1.9.1", 19 | "pg": "7.4.3", 20 | "request": "2.81.0", 21 | "s3-upload-stream": "1.0.7", 22 | "turtl-lib-permissions": "git+https://github.com/turtl/lib-permissions.git#master", 23 | "winston": "2.3.0", 24 | "yaml-env": "git+https://github.com/orthecreedence/yaml-env.git#master" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scripts/init-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | node tools/create-db-schema.js 4 | 5 | -------------------------------------------------------------------------------- /scripts/install-plugins.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "${TURTL_SERVER_PLUGIN_REPO}" != "" ]; then 4 | if [ "${TURTL_SERVER_PLUGIN_LOCATION}" == "" ]; then 5 | TURTL_SERVER_PLUGIN_LOCATION="plugins" 6 | fi 7 | git clone ${TURTL_SERVER_PLUGIN_REPO} "${TURTL_SERVER_PLUGIN_LOCATION}" || \ 8 | { echo "Error grabbing plugins"; exit 1; } 9 | pushd "${TURTL_SERVER_PLUGIN_LOCATION}" 10 | npm install || \ 11 | { echo "Error installing plugin deps"; exit 1; } 12 | popd 13 | fi 14 | 15 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const express = require('express'); 4 | const morgan = require('morgan'); 5 | const body_parser = require('body-parser'); 6 | const method_override = require('method-override'); 7 | const log = require('./helpers/log'); 8 | const tres = require('./helpers/tres'); 9 | const cors = require('./helpers/cors'); 10 | const turtl_auth = require('./helpers/auth'); 11 | const config = require('./helpers/config'); 12 | const error = require('./helpers/error'); 13 | const fs = require('fs'); 14 | const plugins = require('./helpers/plugins'); 15 | 16 | var app = express(); 17 | app.disable('etag'); 18 | app.use(method_override('_method')); 19 | app.use(cors); 20 | app.use(body_parser.json({strict: false, limit: '24mb'})); 21 | app.use(body_parser.urlencoded({extended: true, limit: '4mb'})); 22 | app.use(morgan(':remote-addr ":method :url" :status :res[content-length]', { 23 | stream: { write: function(message, _enc) { log.info(message.slice(0, -1)); } } 24 | })); 25 | app.use(turtl_auth.verify); 26 | 27 | // welcome route 28 | app.get('/', function(req, res) { 29 | tres.send(res, {greeting: "Hi."}); 30 | }); 31 | 32 | // load controllers 33 | ['users', 'sync', 'spaces', 'files', 'feedback', 'errlog', 'cla', 'bookmarking', 'health'] 34 | .forEach(function(con) { 35 | // only load bookmarking controller if we REALLY specify we want it 36 | if(con == 'bookmarking' && config.app.enable_bookmarker_proxy != 'I UNDERSTAND THIS VIOLATES THE PRIVACY OF MY USERS') { 37 | return; 38 | } 39 | log.info('Loading controller: '+con); 40 | var controller = require('./controllers/'+con); 41 | controller.route(app); 42 | }); 43 | 44 | // load plugins 45 | try { 46 | var plugin_dir = config.plugins.plugin_location || './plugins' 47 | var plugin_list = fs.readdirSync(plugin_dir); 48 | } catch(e) { 49 | log.info('Problem loading plugins: ', e); 50 | } 51 | plugin_list.forEach(function(plugin) { 52 | if(plugin[0] == '.') return; 53 | if(plugin == 'node_modules') return; 54 | var stats = fs.lstatSync(plugin_dir+'/'+plugin); 55 | if(!stats.isDirectory()) return; 56 | log.info('Loading plugin: '+plugin); 57 | var loader = require(plugin_dir+'/'+plugin+'/main.js'); 58 | var plugin_config = config.plugins[plugin]; 59 | loader.load(plugins.register.bind(plugins, plugin), plugin_config, { 60 | app: app, 61 | auth: turtl_auth, 62 | plugins: plugins, 63 | }); 64 | }); 65 | 66 | if (config.server.host) { 67 | app.listen(config.server.port, config.server.host); 68 | log.info('Listening for turtls on IP '+config.server.host+', port '+config.server.port+'...'); 69 | } else { 70 | app.listen(config.server.port); 71 | log.info('Listening for turtls on port '+config.server.port+'...'); 72 | } 73 | -------------------------------------------------------------------------------- /tools/create-db-schema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines (and creates) our database schema. 3 | * 4 | * NOTE: we make liberal use of the text type because in postgres there's no 5 | * difference between varchar and text under the hood, but varchar can be 6 | * excessively limiting and hard to change later on. 7 | */ 8 | 9 | const db = require('../helpers/db'); 10 | const config = require('../helpers/config'); 11 | const Promise = require('bluebird'); 12 | 13 | const schema_version = 3; 14 | 15 | const run_upgrade = function(from_version, to_version) { 16 | var cur_version = from_version; 17 | var promises = []; 18 | const run = function(qry, params) { 19 | promises.push(db.query(qry, params)); 20 | }; 21 | 22 | if(cur_version == 1) { 23 | run("ALTER TABLE users ADD COLUMN last_login timestamp with time zone DEFAULT NULL"); 24 | cur_version++; 25 | } 26 | 27 | if(cur_version == 2) { 28 | run("DROP INDEX spaces_users_user_id"); 29 | cur_version++; 30 | } 31 | 32 | return Promise.all(promises); 33 | }; 34 | 35 | var schema = []; 36 | var indexes = []; 37 | const builder = { 38 | type: { 39 | pk_int: 'bigserial primary key', 40 | pk: 'varchar(96) primary key', 41 | id_int: 'bigint', 42 | id: 'varchar(96)', 43 | int: 'integer', 44 | json: 'jsonb', 45 | date: 'timestamp with time zone', 46 | varchar: function(chars) { return 'varchar('+chars+')'; }, 47 | text: 'text', 48 | bool: 'boolean', 49 | smallint: 'smallint', 50 | }, 51 | not_null: function(type) { return type+' not null'; }, 52 | 53 | default: function(type, df) { return type+' default '+df; }, 54 | 55 | table: function(table_name, options) { 56 | var fields = options.fields; 57 | var table_indexes = options.indexes; 58 | 59 | fields.created = builder.type.date+' default CURRENT_TIMESTAMP'; 60 | fields.updated = builder.type.date+' default CURRENT_TIMESTAMP'; 61 | schema.push([ 62 | 'create table if not exists '+table_name+' (', 63 | Object.keys(fields).map(function(name) { 64 | var type = fields[name]; 65 | var options = {}; 66 | if(typeof(type) == 'object') { 67 | options = type; 68 | type = type.type; 69 | delete options.type; 70 | } 71 | var sql_field = [name, type]; 72 | if(typeof(options.default) != 'undefined') { 73 | sql_field.push('DEFAULT '+options.default); 74 | } 75 | if(options.not_null) sql_field.push('NOT NULL'); 76 | return sql_field.join(' '); 77 | }), 78 | ')', 79 | ].join(' ')); 80 | if(table_indexes && table_indexes.length) { 81 | table_indexes.forEach(function(index) { 82 | var name = index.name || index.fields.join('_'); 83 | indexes.push([ 84 | 'create index if not exists '+table_name+'_'+name+' on '+table_name+' (', 85 | index.fields.join(','), 86 | ')', 87 | ].join(' ')); 88 | }); 89 | } 90 | }, 91 | }; 92 | 93 | const ty = builder.type; 94 | 95 | builder.table('app', { 96 | fields: { 97 | id: ty.pk, 98 | val: ty.text, 99 | }, 100 | }); 101 | 102 | builder.table('boards', { 103 | fields: { 104 | id: ty.pk, 105 | space_id: builder.not_null(ty.id), 106 | data: ty.json, 107 | }, 108 | indexes: [ 109 | {name: 'space_id', fields: ['space_id']} 110 | ], 111 | }); 112 | 113 | builder.table('cla', { 114 | fields: { 115 | id: ty.pk_int, 116 | fullname: builder.not_null(ty.text), 117 | email: builder.not_null(ty.text), 118 | sigdata: ty.json, 119 | }, 120 | }); 121 | 122 | builder.table('errorlog', { 123 | fields: { 124 | id: ty.pk, 125 | data: ty.json, 126 | }, 127 | }); 128 | 129 | builder.table('keychain', { 130 | fields: { 131 | id: ty.pk, 132 | user_id: builder.not_null(ty.id_int), 133 | item_id: builder.not_null(ty.id), 134 | data: ty.json, 135 | }, 136 | indexes: [ 137 | {name: 'user_item', fields: ['user_id', 'item_id']}, 138 | {name: 'item', fields: ['item_id']}, 139 | ], 140 | }); 141 | 142 | builder.table('notes', { 143 | fields: { 144 | id: ty.pk, 145 | space_id: builder.not_null(ty.id), 146 | board_id: ty.id, 147 | data: ty.json 148 | }, 149 | indexes: [ 150 | {name: 'space_id', fields: ['space_id', 'board_id']} 151 | ], 152 | }); 153 | 154 | builder.table('spaces', { 155 | fields: { 156 | id: ty.pk, 157 | data: ty.json, 158 | }, 159 | }); 160 | 161 | builder.table('spaces_invites', { 162 | fields: { 163 | id: ty.pk, 164 | space_id: builder.not_null(ty.id), 165 | from_user_id: builder.not_null(ty.id_int), 166 | to_user: builder.not_null(ty.text), 167 | data: ty.json, 168 | }, 169 | indexes: [ 170 | {name: 'space_id', fields: ['space_id']}, 171 | {name: 'from_user_id', fields: ['from_user_id']}, 172 | {name: 'to_user', fields: ['to_user']}, 173 | ], 174 | }); 175 | 176 | builder.table('spaces_users', { 177 | fields: { 178 | id: ty.pk_int, 179 | space_id: builder.not_null(ty.id), 180 | user_id: builder.not_null(ty.id_int), 181 | role: builder.not_null(ty.varchar(24)), 182 | }, 183 | indexes: [ 184 | {name: 'user_id_space_id', fields: ['user_id', 'space_id']}, 185 | {name: 'space_id', fields: ['space_id']}, 186 | ], 187 | }); 188 | 189 | builder.table('sync', { 190 | fields: { 191 | id: ty.pk_int, 192 | item_id: builder.not_null(ty.id), 193 | type: builder.not_null(ty.text), 194 | action: builder.not_null(ty.varchar(32)), 195 | user_id: builder.not_null(ty.id_int), 196 | }, 197 | }); 198 | 199 | builder.table('sync_users', { 200 | fields: { 201 | id: ty.pk_int, 202 | sync_id: builder.not_null(ty.id_int), 203 | user_id: builder.not_null(ty.id_int), 204 | }, 205 | indexes: [ 206 | {name: 'sync_scan', fields: ['sync_id', 'user_id']}, 207 | ], 208 | }); 209 | 210 | builder.table('users', { 211 | fields: { 212 | id: ty.pk_int, 213 | username: builder.not_null(ty.text), 214 | auth: builder.not_null(ty.text), 215 | active: builder.not_null(ty.bool), 216 | confirmed: builder.not_null(ty.bool), 217 | confirmation_token: ty.text, 218 | data: ty.json, 219 | last_login: ty.date, 220 | login_failed_last: ty.date, 221 | login_failed_count: builder.default(ty.int, 0), 222 | }, 223 | indexes: [ 224 | {name: 'username', fields: ['username'], unique: true}, 225 | {name: 'last_login', fields: ['last_login']}, 226 | ], 227 | }); 228 | 229 | function run() { 230 | console.log('- running DB schema'); 231 | return Promise.each(schema, function(qry) { return db.query(qry); }) 232 | .then(function() { 233 | return db.by_id('app', 'schema-version'); 234 | }) 235 | .then(function(schema_ver) { 236 | if(!schema_ver) { 237 | // no record? just insert it with the current version. 238 | return db.upsert('app', {id: 'schema-version', val: schema_version}, 'id'); 239 | } else if(parseInt(schema_ver.val) < schema_version) { 240 | // run an upgrayyyyd 241 | var from = parseInt(schema_ver.val); 242 | var to = schema_version; 243 | console.log('- upgrading schema from v'+from+' to v'+to+'...'); 244 | return run_upgrade(from, to) 245 | .then(function() { 246 | console.log('- schema upgraded to v'+to+'!'); 247 | return db.upsert('app', {id: 'schema-version', val: schema_version}, 'id'); 248 | }); 249 | } 250 | }) 251 | .then(function() { 252 | console.log('- creating indexes'); 253 | return Promise.each(indexes, function(qry) { return db.query(qry); }) 254 | }) 255 | .then(function() { console.log('- done'); }) 256 | .catch(function(err) { 257 | console.error(err, err.stack); 258 | process.exit(1); 259 | }) 260 | .finally(function() { setTimeout(process.exit, 100); }); 261 | } 262 | 263 | run(); 264 | -------------------------------------------------------------------------------- /tools/delete_user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Here we have a cli utility for deleting a user 3 | */ 4 | 5 | var Promise = require('bluebird'); 6 | 7 | var uid = (process.argv[2] || '').toString(); 8 | if(!uid) { 9 | console.log(''); 10 | console.log('Usage: '+process.argv[0]+' '+process.argv[1]+' '); 11 | } 12 | var user_model = require('../models/user'); 13 | 14 | function main() { 15 | var id_promise = Promise.resolve(uid); 16 | if(uid.toString().match(/@/)) { 17 | id_promise = user_model.get_by_email(uid, {raw: true}) 18 | .then(function(user) { 19 | if(!user) throw new Error('User '+uid+' wasn\'t found.'); 20 | return user.id; 21 | }); 22 | } 23 | var user_id; 24 | return id_promise 25 | .then(function(_user_id) { 26 | user_id = _user_id; 27 | return user_model.delete(user_id, user_id); 28 | }) 29 | .then(function() { 30 | console.log('User deleted: '+user_id); 31 | }) 32 | .catch(function(err) { 33 | console.log('Error deleting: ', err, err.stack); 34 | }) 35 | .finally(process.exit); 36 | } 37 | 38 | main(); 39 | 40 | -------------------------------------------------------------------------------- /tools/note-rm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Here we have a cli utility for deleting a user 3 | */ 4 | 5 | var Promise = require('bluebird'); 6 | 7 | var note_id = (process.argv[2] || '').toString(); 8 | if(!note_id) { 9 | console.log(''); 10 | console.log('Usage: '+process.argv[0]+' '+process.argv[1]+' '); 11 | } 12 | const note_model = require('../models/note'); 13 | 14 | function main() { 15 | return note_model.get_by_id(note_id) 16 | .then(function(note) { 17 | if(!note) { 18 | console.log('that note doesn\'t exist'); 19 | return; 20 | } 21 | var user_id = note.user_id; 22 | return note_model.delete_note(user_id, note_id) 23 | .then(function() { 24 | console.log('Note deleted: '+note_id); 25 | }); 26 | }) 27 | .catch(function(err) { 28 | console.log('Error deleting: ', err, err.stack); 29 | }) 30 | .finally(process.exit); 31 | } 32 | 33 | main(); 34 | 35 | --------------------------------------------------------------------------------