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