├── Makefile
├── README.md
├── bin
└── thinglerd.sh
├── etc
└── thingler.cron
├── pub
├── css
│ └── thingler.css
├── error.html
├── favicon.gif
├── images
│ └── lock-24.png
├── index.html
├── js
│ ├── domdom-tokenizing.js
│ ├── domdom.js
│ ├── index.json
│ ├── pilgrim.js
│ └── thingler.js
├── less
│ └── thingler.less
└── upgrade.html
└── src
├── changes.js
├── db.js
├── index.js
├── md5.js
├── routes.js
├── session
├── index.js
└── session.js
├── todo
├── index.js
└── todo.js
└── uuid.js
/Makefile:
--------------------------------------------------------------------------------
1 | css:
2 | lessc pub/less/thingler.less > pub/css/thingler.css -x
3 |
4 | crontab:
5 | sudo crontab etc/thingler.cron -u couchdb
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | thingler
2 | ========
3 |
4 | > Hello, I am [Thingler](http://thingler.com), and this is my source code. Feel free to browse.
5 |
6 | License
7 | -------
8 |
9 | Thingler is licensed under the following license:
We've been notified, but I suggest you don't try that again, until it's fixed.
15 | 16 | 17 | -------------------------------------------------------------------------------- /pub/favicon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudhead/thingler/321286cb9d988a77f27753ed4d83246a23d575e0/pub/favicon.gif -------------------------------------------------------------------------------- /pub/images/lock-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudhead/thingler/321286cb9d988a77f27753ed4d83246a23d575e0/pub/images/lock-24.png -------------------------------------------------------------------------------- /pub/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |' + match.replace(/\\`/g, '`') + '
';
484 | }).replace(/(https?:\/\/[^\s]+)/g, '$1');
485 | }
486 |
487 | function handleSort(id, to) {
488 | return room.changes.push('sort', id, { to: to });
489 | }
490 | function handleTagFilter(filter) {
491 | var child, tag, tags;
492 |
493 | list.querySelectorAll('li.active').forEach(function (e) {
494 | e.removeClass('active');
495 | });
496 |
497 | list.children.forEach(function (child) {
498 | if (filter) {
499 | tag = child.querySelector('[data-tag="' + filter + '"]');
500 | tags = child.firstChild.getAttribute('data-tags');
501 |
502 | if (tags && (tags.split(' ').indexOf(filter) !== -1)) {
503 | tag.addClass('active');
504 | dom.show(child);
505 | } else {
506 | dom.hide(child);
507 | }
508 | } else {
509 | tag && tag.removeClass('active');
510 | dom.show(child);
511 | }
512 | });
513 | }
514 | function handleEdit(element) {
515 | var label = element.querySelector('label'),
516 | tags = element.getAttribute('data-tags'),
517 | field = element.querySelector('input[type="text"]'),
518 | li = element.parentNode,
519 | check = element.querySelector('input[type="checkbox"]'),
520 | tokens = element.querySelector('.tokens');
521 |
522 | li.style.cursor = 'text';
523 |
524 | if (li.hasClass('editing')) {
525 | handleEditSave.call(field, { tokens: dom.tokenizing.parseTokens(tokens) });
526 | } else {
527 | check.disabled = false;
528 | li.addClass('editing');
529 | dom.show(tokens), dom.hide(label), dom.show(field);
530 | if (tags) {
531 | dom.tokenizing.createTokens.call(field.tokenizer, tags.split(' '));
532 | tokens.lastChild.lastChild.focus();
533 | } else {
534 | field.focus();
535 | }
536 | field.value = element.getAttribute('data-title');
537 | field.autosize();
538 | }
539 | }
540 | function handleEditSave(e) {
541 | if (! this.parentNode.parentNode.hasClass('editing')) { return }
542 |
543 | var div = this.parentNode,
544 | tokens = div.querySelector('.tokens'),
545 | tags = div.getAttribute('data-tags'),
546 | id = div.getAttribute('data-id'),
547 | check = div.querySelector('input[type="checkbox"]'),
548 | label = div.querySelector('label');
549 |
550 | var item = {
551 | title: this.value,
552 | tags: e.tokens
553 | };
554 |
555 | div.parentNode.style.cursor = '';
556 |
557 | var old = div.getAttribute('data-title');
558 |
559 | div.getAttribute('data-completed') && (check.disabled = true);
560 |
561 | div.parentNode.removeClass('editing');
562 | dom.hide(this), dom.show(label);
563 |
564 | // Only push a change if something actually changed.
565 | if (item.title !== old || item.tags.join(' ') !== tags) {
566 | room.changes.push('edit', id, item);
567 | refreshItem(div, item);
568 | }
569 | dom.hide(tokens);
570 | };
571 |
572 | function setTitle(str) {
573 | title.value = str;
574 | document.title = 'Thingler · ' + str;
575 | }
576 | //
577 | // Check the hashtag every 10ms, for changes
578 | //
579 | setInterval(function () {
580 | if (window.location.hash !== hash) {
581 | hash = window.location.hash;
582 | handleTagFilter(hash);
583 | }
584 | }, 10);
585 |
586 |
--------------------------------------------------------------------------------
/pub/less/thingler.less:
--------------------------------------------------------------------------------
1 | //
2 | // thingler.less
3 | //
4 | @yellow: hsl(60, 85%, 97%);
5 | @dark-yellow: hsl(hue(@yellow), 55%, 92%);
6 | @red: hsl(10, 50%, 60%);
7 | @light-red: #ee8167;
8 | @black: hsl(215, 20%, 30%);
9 | @dark-blue: hsl(225, 50%, 40%);
10 |
11 | @white: #fafafa;
12 | @light-grey: #ddd;
13 | @medium-grey: #ccc;
14 | @dark-grey: #aaa;
15 |
16 | @page-width: 800px;
17 |
18 | //
19 | // Mixins
20 | //
21 | .box-shadow(@x, @y, @blur, @color) {
22 | -webkit-box-shadow: @x @y @blur @color;
23 | -moz-box-shadow: @x @y @blur @color;
24 | box-shadow: @x @y @blur @color;
25 | }
26 | .border-radius(@r) {
27 | -webkit-border-radius: @r;
28 | -moz-border-radius: @r;
29 | border-radius: @r;
30 | }
31 | .border-radius-bottom(@r) {
32 | -webkit-border-bottom-left-radius: @r;
33 | -moz-border-bottom-left-radius: @r;
34 | border-bottom-left-radius: @r;
35 | -webkit-border-bottom-right-radius: @r;
36 | -moz-border-bottom-right-radius: @r;
37 | border-bottom-right-radius: @r;
38 | }
39 | .link (@color: inherit, @border-color: #eee, @border-style: solid) {
40 | border-bottom: 1px @border-style @border-color;
41 | font-family: Arial, sans-serif;
42 | color: @color;
43 | &:hover {
44 | color: hsl(10, 50%, 60%);
45 | border-bottom: 1px solid hsl(10, 50%, 95%);
46 | }
47 | }
48 | .tokenize (@color, @padding-v, @padding-h: 8px) {
49 | color: @color;
50 | padding: 0;
51 | margin: auto 0;
52 | line-height: 1em;
53 | padding: @padding-v @padding-h;
54 | border-width: 1px;
55 | border-style: solid;
56 | border-color: darken(@dark-yellow, 2%);
57 | background-color: @yellow;
58 | display: inline-block;
59 | .box-shadow(0, 1px, 2px, #eee);
60 | .border-radius(2px);
61 | &:hover {
62 | text-decoration: none;
63 | color: darken(@color, 10%);
64 | border-color: darken(@dark-yellow, 8%);
65 | }
66 | }
67 |
68 | //
69 | // General
70 | //
71 | * { margin: 0; padding: 0 }
72 |
73 | body {
74 | font-family: 'Arial', sans-serif;
75 | font-size: 24px;
76 | width: @page-width;
77 | padding: 60px 30px;
78 | margin: 0 auto;
79 | background-color: @white;
80 | }
81 | body > header {
82 | position: absolute;
83 | width: @page-width;
84 | top: 22px;
85 | color: @light-grey;
86 | display: block;
87 | margin: 0;
88 | font-size: 16px;
89 | }
90 | body > footer {
91 | visibility: hidden;
92 | p { margin: 10px 0 }
93 | margin: 30px 0;
94 | text-align: right;
95 | font-size: 14px;
96 | color: lighten(@light-grey, 5%);
97 | p:first-child {
98 | color: @light-grey;
99 | font-size: 18px;
100 | &:hover, &:hover a {
101 | color: darken(@light-grey, 8%);
102 | a:hover {
103 | color: @light-red;
104 | border-bottom: 1px solid hsl(10, 50%, 95%);
105 | }
106 | }
107 | }
108 | a {
109 | color: @light-grey;
110 | border-bottom: 1px solid #eee;
111 | &:hover {
112 | color: @light-red;
113 | border-bottom: 1px solid hsl(10, 50%, 95%);
114 | }
115 | }
116 | }
117 |
118 | h1 { color: @black; font-size: 42px; }
119 | ul, li { list-style-type: none; padding: 0; }
120 | ul { -webkit-padding-start: 0; }
121 | a { text-decoration: none; color: @medium-grey; }
122 | input[type="text"], input[type="password"] { outline: none; }
123 | input[type="checkbox"] { font-size: 22px; }
124 |
125 | //
126 | // Todo List
127 | //
128 | #page {
129 | width: @page-width;
130 | @item-height: 48px;
131 | #title {
132 | font-size: 48px;
133 | font-weight: bold;
134 | font-family: 'Arial', sans-serif;
135 | border: 0;
136 | border: 1px dashed transparent;
137 | color: @black;
138 | &:focus { outline: none; border-color: @light-grey }
139 | margin-bottom: 15px;
140 | margin-left: -1px;
141 | padding: 0.25em 1px;
142 | width: @page-width - 4px;
143 | background-color: @white;
144 | &:focus { background-color: white; }
145 | }
146 | ul#list.unselectable li {
147 | cursor: default;
148 | user-select: none;
149 | -moz-user-select: none;
150 | -webkit-user-select: none;
151 | }
152 | label, label a, label a:visited {
153 | color: @black;
154 | text-decoration: none;
155 | display: inline-block;
156 | line-height: @item-height;
157 | //overflow: hidden;
158 | position: relative;
159 | //white-space: wrap;
160 | }
161 | label { max-width: 670px; }
162 | li.flashing label:after { visibility: hidden; }
163 | //label:after {
164 | // content: " ";
165 | // padding: 1px;
166 | // position: absolute;
167 | // top: 0;
168 | // right: -15px;
169 | // width: 30px;
170 | // height: @item-height;
171 | // display: block;
172 | // line-height: @item-height;
173 | // background-color: white;
174 | // .box-shadow(-15px, 0px, 30px, white);
175 | //}
176 | #list { margin: 30px 0; width: @page-width; padding: 0; }
177 | #list > li {
178 | overflow: hidden;
179 | font-size: 22px;
180 | font-family: Georgia, 'Times New Roman', serif;
181 | width: @page-width - 12px;
182 | padding: 0 5px;
183 | cursor: move;
184 | border-color: transparent;
185 | border-style: solid;
186 | border-width: 0 1px 0px 1px;
187 | border-bottom: 1px dotted @light-grey;
188 | background-color: @white;
189 | a {
190 | color: @black + #333;
191 | &:hover { text-decoration: underline; }
192 | }
193 | &:hover {
194 | background-color: @yellow;
195 | .actions { visibility: visible; }
196 | label:after {
197 | background-color: @yellow;
198 | .box-shadow(-15px, 0px, 30px, @yellow);
199 | }
200 | }
201 | &.editing {
202 | .tags { display: none }
203 | .actions { visibility: visible }
204 | [data-action="edit"] { color: @light-red }
205 | background-color: white;
206 | border: 1px dashed @light-grey !important;
207 | margin-top: -1px;
208 | }
209 | &.editing:last-child { margin-bottom: -1px; }
210 | input[type="text"] {
211 | font-family: Georgia, 'Times New Roman', serif;
212 | font-size: 22px;
213 | margin-left: -1px;
214 | border: 0;
215 | background-color: transparent;
216 | height: @item-height;
217 | width: 650px;
218 | }
219 | input[type="checkbox"] {
220 | vertical-align: middle;
221 | height: @item-height;
222 | line-height: @item-height;
223 | float: left;
224 | display: block;
225 | margin-right: 25px;
226 | }
227 | @color: desaturate(darken(@dark-yellow, 20%), 35%);
228 | height: auto;
229 | &:hover .tags li a {
230 | color: darken(@color, 5%);
231 | background-color: @white;
232 | }
233 | &.editing .token {
234 | color: darken(@color, 15%) !important;
235 | }
236 | .tags {
237 | @padding-v: 4px;
238 | float: right;
239 | display: inline;
240 | line-height: @item-height;
241 | height: @item-height;
242 | margin: 0 15px;
243 | li {
244 | display: inline-block;
245 | margin-left: 5px;
246 |
247 | a {
248 | .tokenize(@color, @padding-v);
249 | }
250 | &.active a {
251 | border-color: darken(@dark-yellow, 8%);
252 | color: darken(@color, 10%);
253 | }
254 | }
255 | li:first-child { margin-left: 0 }
256 | }
257 | //
258 | // Edit/Remove
259 | //
260 | .actions {
261 | visibility: hidden;
262 | float: right;
263 | font-size: 14px;
264 | height: @item-height;
265 | line-height: @item-height;
266 | a {
267 | padding: 8px;
268 | border: 0 !important;
269 | &:hover { border: 0 !important }
270 | border-bottom: 1px solid #f4f4f4;
271 | font-family: Arial, sans-serif;
272 | text-decoration: none;
273 | color: @medium-grey;
274 | &:last-child { margin-right: 10px }
275 | &:hover { color: @light-red; }
276 | }
277 | }
278 | &:last-child { border-bottom: 0; }
279 | }
280 | .completed {
281 | label, label a {
282 | color: #ccc !important;
283 | text-decoration: line-through;
284 | }
285 | .tags li a {
286 | color: #ccc !important;
287 | background-color: #fafafa !important;
288 | border-color: #eee !important;
289 | &:hover {
290 | color: #bbb !important;
291 | }
292 | }
293 | }
294 | }
295 |
296 | //
297 | // Input
298 | //
299 | input#new, input[type="password"], input.token-input {
300 | font-size: 24px;
301 | padding: 8px;
302 | border: 1px solid @light-grey;
303 | &.disabled {
304 | color: @medium-grey;
305 | background-color: #f0f0f0;
306 | }
307 | }
308 | input#new, span.string-input, input.token {
309 | font-family: 'Lucida Grande', Arial, sans-serif;
310 | }
311 | ul.tokens { margin: 0 }
312 | ul.tokens, ul.tokens li { display: inline-block; margin: 0; }
313 |
314 | span.string-input {
315 | font-size: 24px;
316 | margin-left: 1px;
317 | }
318 | ul.tokens li:last-child .token-input {
319 | padding: 0 5px !important;
320 | }
321 | input.token-input {
322 | margin: 0;
323 | padding: 0 3px 0 1px;
324 | display: inline-block;
325 | outline: none;
326 | border: 0;
327 | &.empty {
328 | margin: 0;
329 | width: 5px;
330 | }
331 | }
332 |
333 | #list {
334 | .token {
335 | @color: desaturate(darken(@dark-yellow, 20%), 35%);
336 | .tokenize(@color, 2px, 1px);
337 | padding-right: 4px;
338 | margin-left: -2px;
339 | outline: none;
340 | font-size: inherit;
341 | font-family: inherit;
342 | }
343 | .token-input {
344 | padding: 0 3px 0 1px;
345 | margin: 0;
346 | font-family: inherit;
347 | font-size: inherit;
348 | }
349 | }
350 | #new-wrapper {
351 | font-size: 24px;
352 | padding: 8px;
353 | cursor: text;
354 | width: @page-width - 18px;
355 | .box-shadow(0px, 1px, 2px, @light-grey);
356 | &.focused { .box-shadow(0px, 2px, 8px, @light-grey); }
357 | border: 1px solid @light-grey;
358 | background-color: white;
359 | input#new {
360 | width: @page-width - 18px - 16px;
361 | padding: 0;
362 | border: 0 !important;
363 | display: inline-block;
364 | margin: 0;
365 | }
366 | .token {
367 | @color: desaturate(darken(@dark-yellow, 20%), 35%);
368 | .tokenize(@color, 2px, 1px);
369 | padding-right: 4px;
370 | margin: -6px 0;
371 | margin-left: -2px; // To compensate for the padding-left
372 | outline: none;
373 | font-size: 24px;
374 | }
375 | }
376 |
377 | //
378 | // Drag & Drop
379 | //
380 | .dragging {
381 | div { cursor: move; }
382 | position: absolute;
383 | z-index: 10;
384 | background-color: hsla(60, 100%, 95%, 0.7) !important;
385 | width: @page-width;
386 | border: 1px dashed @light-grey !important;
387 | .actions { visibility: hidden }
388 | .box-shadow(0px, 2px, 16px, @light-grey);
389 | }
390 | .ghost {
391 | label { color: @light-grey !important; }
392 | .tags {
393 | color: @light-grey !important;
394 | li a { background-color: #fefefe !important; border-color: #eee !important }
395 | }
396 | margin-top: -1px;
397 | border-bottom: 1px dashed @light-grey !important;
398 | border-top: 1px dashed @light-grey !important;
399 | }
400 |
401 |
402 | //
403 | // Room Locking
404 | //
405 | #password-protect div.password {
406 | .border-radius(8px);
407 | background-color: transparent;
408 | input {
409 | .box-shadow(0, 3px, 30px, #ddd);
410 | }
411 | }
412 | div.password {
413 | @width: 600px;
414 | @height: 116px;
415 | position: absolute;
416 | height: @height;
417 | top: 40%;
418 | left: 50%;
419 | width: @width;
420 | margin-left: ((@width + 120px) / 2) * -1;
421 | margin-top: ((@height + 120px) / 2) * -1;
422 | padding: 60px;
423 |
424 | label {
425 | font-weight: bold;
426 | color: lighten(@black, 10%);
427 | font-size: 32px;
428 | display: block;
429 | padding-bottom: 30px;
430 | }
431 | input {
432 | width: @width - 30px;
433 | .border-radius(8px);
434 | }
435 | input.error {
436 | border-color: hsl(10, 60%, 60%);
437 | }
438 | label {
439 | }
440 | .close {
441 | color: #ccc;
442 | float: right;
443 | margin-top: 30px;
444 | margin-right: 16px;
445 | font-size: 18px;
446 | text-shadow: 0px 0px 10px white;
447 | border-bottom: 1px solid #eee;
448 | &:hover {
449 | color: @light-red;
450 | }
451 | }
452 | }
453 | #lock {
454 | text-align: right;
455 | float: right;
456 | div {
457 | display: inline-block;
458 | width: 24px;
459 | height: 24px;
460 | background: url(/images/lock-24.png) 0 0 no-repeat;
461 | margin-left: 5px;
462 | }
463 | &.locked {
464 | display: block;
465 | &:hover {
466 | .locked-hint { display: inline }
467 | .unlocked-hint { display: none }
468 | }
469 | span { color: @light-red }
470 | div { background: url(/images/lock-24.png) -24px 0 no-repeat; }
471 | }
472 | &:hover {
473 | .locked-hint { display: none }
474 | .unlocked-hint { display: inline }
475 | div { background: url(/images/lock-24.png) -24px 0 no-repeat; }
476 | }
477 | span {
478 | color: @light-red;
479 | font-size: 14px;
480 | display: none;
481 | vertical-align: 1px;
482 | }
483 | }
484 |
485 | //
486 | // Overlays
487 | //
488 | .overlay {
489 | position: fixed;
490 | top: 0;
491 | left: 0;
492 | width: 100%;
493 | height: 100%;
494 | z-index: 10;
495 | }
496 | #password-authenticate.overlay {
497 | background-color: @white;
498 | }
499 | #password-protect.overlay {
500 | background-color: rgba(255, 255, 255, 0.9);
501 | }
502 |
503 | //
504 | // 404
505 | //
506 | #not-found {
507 | padding: 60px 0;
508 | h1 {
509 | text-align: center;
510 | font-size: 48px;
511 | }
512 | p { text-align: center; margin-top: 30px; }
513 | a { .link(@dark-grey) }
514 | }
515 |
516 | //
517 | // About
518 | //
519 | #about {
520 | font-size: 14px;
521 | float: left;
522 | display: none;
523 | margin-top: 0px;
524 | text-align: right;
525 | p {
526 | font-size: 14px !important;
527 | color: @dark-grey;
528 | line-height: 22px;
529 | a { .link(lighten(@dark-grey, 8%), @light-grey, dashed) }
530 | }
531 | }
532 |
533 |
--------------------------------------------------------------------------------
/pub/upgrade.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Please upgrade your browser to something decent.
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/changes.js: -------------------------------------------------------------------------------- 1 | var db = require('./db').database; 2 | var parseRev = require('./db').parseRev; 3 | var todo = require('./todo').resource; 4 | var md5 = require('./md5'); 5 | 6 | var cache = {}; 7 | 8 | var hour = 3600 * 1000; 9 | 10 | // Remove commits older than an hour. 11 | // The maximum client polling time is one hour, 12 | // so by this time, all connected clients should 13 | // be up-to-date. 14 | setInterval(function () { 15 | var now = Date.now(); 16 | Object.keys(cache).forEach(function (k) { 17 | if (now - cache[k].ctime > hour) { 18 | delete(cache[k]); 19 | } 20 | }); 21 | }, hour); 22 | 23 | this.post = function (res, id, params, session) { 24 | todo.get(id, function (err, doc) { 25 | if (err) { return res.send(doc.headers.status, {}, err) } 26 | 27 | var changes = params.changes; 28 | 29 | // Apply all the changes to the document 30 | changes.forEach(function (change) { 31 | if (validate(change)) { 32 | exports.handlers[change.type](doc, change, session); 33 | } 34 | }); 35 | 36 | if (changes.length > 0) { 37 | db.put(id, doc, function (err, doc) { 38 | if (err) { 39 | return res.send(doc.headers.status, {}, err); 40 | } 41 | reply(doc.rev); 42 | todo.clear(id); 43 | }); 44 | } else { 45 | reply(doc._rev); 46 | } 47 | 48 | function reply(rev) { 49 | cache[id] = cache[id] || []; 50 | 51 | var dirty = cache[id].slice(0), status = 200; 52 | 53 | rev = rev ? parseRev(rev) : 0; 54 | 55 | if (changes.length > 0) { 56 | cache[id].push({ rev: rev, changes: changes, ctime: Date.now() }); 57 | status = 201; 58 | } 59 | 60 | // If it's a goodbye, don't send anything back, just an OK 61 | if (params.last) { 62 | res.send(status); 63 | } else { 64 | res.send(status, {}, { 65 | rev: rev, 66 | commits: dirty.filter(function (commit) { 67 | return commit.rev > params.rev; 68 | }) 69 | }); 70 | } 71 | } 72 | }); 73 | }; 74 | 75 | this.get = function (res, id, params) { 76 | res.send(200, {}, { changes: cache[id] }); 77 | }; 78 | 79 | this.handlers = { 80 | insert: function (doc, change) { 81 | if (doc.items.length < 256) { 82 | if (! Array.isArray(change.tags)) { return } 83 | doc.items.unshift({ 84 | id: change.id, 85 | title: sanitize(change.title), 86 | tags: change.tags 87 | }); 88 | } 89 | }, 90 | title: function (doc, change) { 91 | doc.title = sanitize(change.value); 92 | }, 93 | edit: function (doc, change) { 94 | var item = find(change.id, doc); 95 | if (item) { 96 | item.title = change.title; 97 | item.tags = change.tags; 98 | } 99 | }, 100 | sort: function (doc, change) { 101 | var index = indexOf(change.id, doc), item; 102 | if (index !== -1) { 103 | item = doc.items.splice(index, 1)[0]; 104 | doc.items.splice(change.to, 0, item); 105 | } 106 | }, 107 | check: function (doc, change) { 108 | var item = find(change.id, doc); 109 | if (item) { 110 | item.completed = Date.now(); 111 | } 112 | }, 113 | uncheck: function (doc, change) { 114 | var item = find(change.id, doc); 115 | if (item) { 116 | delete(item.completed); 117 | } 118 | }, 119 | remove: function (doc, change) { 120 | var index = indexOf(change.id, doc); 121 | if (index !== -1) { 122 | doc.items.splice(index, 1); 123 | } 124 | }, 125 | lock: function (doc, change, session) { 126 | if (session) { 127 | session.authenticated.push(doc._id); 128 | doc.password = md5.digest(change.password); 129 | } 130 | }, 131 | unlock: function (doc, change, session) { 132 | doc.password = null; 133 | } 134 | }; 135 | 136 | function validate(change) { 137 | if (change.type && (change.type in exports.handlers)) { 138 | if ('title' in change) { 139 | if ((typeof(change.title) !== 'string') || change.title.length > 256) { 140 | return false; 141 | } 142 | } 143 | } else { 144 | return false; 145 | } 146 | return true; 147 | } 148 | function sanitize(str) { 149 | return str.replace(//g, '>'); 150 | } 151 | 152 | function find(id, doc) { 153 | for (var i = 0; i < doc.items.length; i++) { 154 | if (doc.items[i].id === id) { 155 | return doc.items[i]; 156 | } 157 | } 158 | return null; 159 | } 160 | function indexOf(id, doc) { 161 | for (var i = 0; i < doc.items.length; i++) { 162 | if (doc.items[i].id === id) { 163 | return i; 164 | } 165 | } 166 | return -1; 167 | } 168 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | 2 | var cradle = require('cradle'); 3 | 4 | this.connection = new(cradle.Connection)({ 5 | host: '127.0.0.1', 6 | port: 5984 7 | }); 8 | 9 | this.database = this.connection.database('thingler'); 10 | 11 | this.parseRev = function (rev) { 12 | return parseInt(rev.match(/^(\d+)-/)[1]); 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | var fs = require('fs'); 3 | var sys = require('sys'); 4 | var http = require('http'); 5 | var Buffer = require('buffer').Buffer; 6 | 7 | var journey = require('journey'), 8 | static = require('node-static'); 9 | 10 | var todo = require('./todo').resource, 11 | session = require('./session/session'), 12 | routes = require('./routes'); 13 | 14 | var options = { 15 | port: parseInt(process.argv[2]) || 8080, 16 | lock: '/tmp/thinglerd.pid' 17 | }; 18 | 19 | var env = (process.env['NODE_ENV'] === 'production' || 20 | options.port === 80) ? 'production' : 'development'; 21 | 22 | // 23 | // Create a Router object with an associated routing table 24 | // 25 | var router = new(journey.Router)(routes.map, { strict: true }); 26 | var file = new(static.Server)('./pub', { cache: env === 'production' ? 3600 : 0 }); 27 | 28 | this.server = http.createServer(function (request, response) { 29 | var body = [], log; 30 | 31 | request.addListener('data', function (chunk) { body.push(chunk) }); 32 | request.addListener('end', function () { 33 | log = [request.method, request.url, body.join('')]; 34 | 35 | // If the response hasn't completed within 5 seconds 36 | // of the request, send a 500 back. 37 | var timer = setTimeout(function () { 38 | if (! response.finished) { 39 | if (request.headers.accept.indexOf('application/json') !== -1) { 40 | response.writeHead(500, {}); 41 | response.end(JSON.stringify({error: 500})); 42 | } else { 43 | file.serveFile('/error.html', 500, {}, request, response); 44 | } 45 | } 46 | }, 5000); 47 | 48 | if (/MSIE [0-8]/.test(request.headers['user-agent'])) { // Block old IE 49 | file.serveFile('/upgrade.html', 200, {}, request, response); 50 | clearTimeout(timer); 51 | } else if (request.url === '/') { 52 | todo.create(function (id) { 53 | finish(303, { 'Location': '/' + id }); 54 | }); 55 | } else { 56 | // 57 | // Dispatch the request to the router 58 | // 59 | router.route(request, body.join(''), function (result) { 60 | if (result.status === 406) { // A request for non-json data 61 | file.serve(request, response, function (err, result) { 62 | if (err) { 63 | file.serveFile('/index.html', 200, {}, request, response); 64 | clearTimeout(timer); 65 | } 66 | }); 67 | } else { 68 | session.create(request, function (header) { 69 | if (header) { result.headers['Set-Cookie'] = header['Set-Cookie'] } 70 | finish(result.status, result.headers, result.body); 71 | }); 72 | } 73 | }); 74 | } 75 | function finish(status, headers, body) { 76 | response.writeHead(status, headers); 77 | body ? response.end(body) : response.end(); 78 | clearTimeout(timer); 79 | 80 | sys.puts([ 81 | new(Date)().toJSON(), 82 | log.join(' '), 83 | [status, http.STATUS_CODES[status], body].join(' ') 84 | ].join(' -- ')); 85 | } 86 | }); 87 | }); 88 | 89 | 90 | this.server.listen(options.port); 91 | 92 | if (env === 'production') { 93 | // Write lock file 94 | fs.writeFileSync(options.lock, process.pid.toString() + '\n', 'ascii'); 95 | } 96 | 97 | process.on('uncaughtException', function (err) { 98 | if (env === 'production') { 99 | fs.open('thinglerd.log', 'a+', 0666, function (e, fd) { 100 | var buffer = new(Buffer)(new(Date)().toUTCString() + ' -- ' + err.stack + '\n'); 101 | fs.write(fd, buffer, 0, buffer.length, null); 102 | }); 103 | } else { 104 | sys.error(err.stack); 105 | } 106 | }); 107 | process.on('exit', function () { 108 | (env === 'production') && fs.unlinkSync(options.lock); 109 | }); 110 | -------------------------------------------------------------------------------- /src/md5.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | 3 | this.digest = function (str) { 4 | return crypto.createHash('md5').update(str).digest('hex'); 5 | }; 6 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | var todo = require('./todo'), 2 | session = require('./session'), 3 | changes = require('./changes'); 4 | 5 | function auth(callback) { 6 | return function (res, id, params) { 7 | var sess = session.resource.retrieve(this.request); 8 | callback(res, id, params, sess); 9 | }; 10 | } 11 | // 12 | // Routing table 13 | // 14 | this.map = function () { 15 | // Create a new todo list 16 | this.post('/'); 17 | 18 | // List 19 | this.path(/^([a-zA-Z0-9-]+)(?:\.json)?/, function () { 20 | // Create/Destroy session 21 | this.post('/session').bind (auth(session.post)); 22 | this.del('/session').bind (auth(session.del)); 23 | 24 | // Retrieve the todo list 25 | this.get().bind (auth(todo.get)); 26 | 27 | // Update the todo list 28 | this.put().bind (auth(todo.put)); 29 | 30 | // Destroy the todo list 31 | this.del().bind (auth(todo.del)); 32 | 33 | // Create a change 34 | this.post().bind (auth(changes.post)); 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/session/index.js: -------------------------------------------------------------------------------- 1 | 2 | var session = require('./session'); 3 | var todo = require('../todo').resource; 4 | var md5 = require('../md5'); 5 | 6 | this.resource = session; 7 | 8 | this.post = function (res, id, params, sess) { 9 | id = id.toString(); 10 | if (sess) { 11 | todo.get(id, function (e, doc) { 12 | if (! doc.password) { 13 | res.send(201); 14 | } else if (doc.password === md5.digest(params.password)) { 15 | session.authenticate(sess, id); 16 | res.send(201); 17 | } else { 18 | res.send(401, {}, { error: 'wrong password' }); 19 | } 20 | }); 21 | } else { 22 | res.send(401); 23 | } 24 | }; 25 | this.del = function (res, id, params) { 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /src/session/session.js: -------------------------------------------------------------------------------- 1 | 2 | var uuid = require('../uuid'); 3 | 4 | // Session store 5 | var store = {}; 6 | 7 | this.store = store; 8 | 9 | // Prune un-used sessions every hour 10 | setInterval(function () { exports.prune() }, 3600); 11 | 12 | this.maxAge = 3600 * 24; // 1 day 13 | 14 | this.generate = function (callback) { 15 | uuid.generate(function (id) { 16 | store[id] = { 17 | id: id, 18 | ctime: Date.now(), 19 | atime: Date.now(), 20 | authenticated: [] 21 | }; 22 | callback(id); 23 | }); 24 | }; 25 | this.create = function (req, callback) { 26 | var id = this.extract(req); 27 | 28 | if (!id || !(id in store)) { 29 | this.generate(function (id) { 30 | callback({ 'Set-Cookie':'SESSID=' + id }); 31 | }); 32 | } else { 33 | callback(null); 34 | } 35 | }; 36 | 37 | this.extract = function (req) { 38 | if (req.headers['cookie']) { 39 | if (match = req.headers['cookie'].match(/SESSID=(\w{32})/)) { 40 | return match[1]; 41 | } else { 42 | return null; 43 | } 44 | } 45 | }; 46 | 47 | this.retrieve = function (req) { 48 | return this.get(this.extract(req)); 49 | }; 50 | 51 | this.get = function (id) { 52 | if (id in store) { 53 | store[id].atime = Date.now(); 54 | return store[id]; 55 | } else { 56 | return null; 57 | } 58 | }; 59 | 60 | this.authenticate = function (session, docId) { 61 | session.atime = Date.now(); 62 | return session.authenticated.push(docId); 63 | }; 64 | 65 | this.prune = function () { 66 | var keys = Object.keys(store); 67 | var now = Date.now(); 68 | 69 | for (var i = 0, key; i < keys.length; i++) { 70 | key = keys[i]; 71 | if (now - store[key].atime > this.maxAge) { 72 | delete(store[key]); 73 | } 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /src/todo/index.js: -------------------------------------------------------------------------------- 1 | 2 | var sys = require('sys'); 3 | 4 | var todo = require('./todo'); 5 | var db = require('../db'); 6 | 7 | this.resource = todo; 8 | 9 | // 10 | // Retrieve a list 11 | // 12 | this.get = function (res, id, params, session) { 13 | id = id.toString(); 14 | todo.get(id, function (e, doc) { 15 | if (e) { 16 | res.send(doc.headers.status, {}, e); 17 | } else { 18 | if (doc.password) { 19 | if (! session) { 20 | res.send(401, {}, { error: 'you must be logged in' }); 21 | } else if (session.authenticated.indexOf(id) === -1) { 22 | res.send(401, {}, { error: "you don't have permission to view this url" }); 23 | } else { 24 | doc.locked = true; 25 | res.send(200, {}, doc); 26 | } 27 | } else { 28 | res.send(200, {}, doc); 29 | } 30 | } 31 | }) 32 | }; 33 | 34 | // 35 | // Update a list, or create a named list 36 | // 37 | this.put = function (res, id, params) { 38 | todo.save(id.toString(), function (e, doc) { 39 | if (e) { 40 | res.send(doc.headers.status, {}, e); 41 | } else { 42 | res.send(doc.status, {}, doc); 43 | } 44 | }); 45 | }; 46 | 47 | -------------------------------------------------------------------------------- /src/todo/todo.js: -------------------------------------------------------------------------------- 1 | 2 | var uuid = require('../uuid'); 3 | 4 | require.paths.unshift(__dirname + '/..'); 5 | 6 | var db = require('db').database; 7 | 8 | var cache = {}; 9 | 10 | this.Todo = function (attributes) { 11 | this.title = "Hello, I'm a Todo List."; 12 | this.items = []; 13 | this.timestamp = new(Date)().toUTCString(); 14 | 15 | for (var k in attributes) { this[k] = attributes[k] } 16 | }; 17 | this.Todo.prototype = { 18 | get json () { 19 | var that = this; 20 | 21 | return Object.keys(this).reduce(function (json, k) { 22 | json[k] = that[k]; 23 | return json; 24 | }, {}); 25 | }, 26 | update: function (obj) { 27 | var that = this; 28 | Object.keys(obj).forEach(function (k) { 29 | that[k] = obj[k]; 30 | }) 31 | return this; 32 | }, 33 | save: function (callback) { 34 | db.put(this._id, this.json, function (e, res) { 35 | callback(e, res); 36 | }); 37 | } 38 | }; 39 | 40 | this.create = function (callback) { 41 | uuid.generate(function (id) { 42 | cache[id] = new(exports.Todo)({ _id: id }); 43 | callback(id); 44 | }); 45 | }; 46 | 47 | this.clear = function (id) { 48 | delete(cache[id]); 49 | }; 50 | 51 | this.get = function (id, callback) { 52 | process.nextTick(function () { 53 | if (id in cache) { 54 | callback(null, cache[id].json); 55 | } else { 56 | db.get(id, function (e, result) { 57 | if (e) { 58 | callback(e, result); 59 | } else { 60 | callback(null, result.json); 61 | } 62 | }); 63 | } 64 | }); 65 | }; 66 | 67 | this.save = function (id, callback) { 68 | db.get(id, function (e, doc) { 69 | var newDoc = new(exports.Todo)(); 70 | 71 | if (e && (e.error !== 'not_found')) { 72 | callback(e); 73 | } else { 74 | db.put(id, newDoc.json, function (e, doc) { 75 | if (e) { 76 | callback(e, doc); 77 | } else { 78 | callback(null, { 79 | title: newDoc.title, 80 | _rev: doc._rev, 81 | status: doc.headers.status 82 | }); 83 | } 84 | }); 85 | } 86 | }); 87 | }; 88 | -------------------------------------------------------------------------------- /src/uuid.js: -------------------------------------------------------------------------------- 1 | var db = require('./db').connection; 2 | 3 | var cache = []; 4 | 5 | this.generate = function (callback) { 6 | if (cache.length > 0) { 7 | callback(cache.pop()); 8 | } else { 9 | db.uuids(100, function (err, data) { 10 | Array.prototype.push.apply(cache, data); 11 | callback(cache.pop()); 12 | }); 13 | } 14 | }; 15 | --------------------------------------------------------------------------------