├── .bowerrc ├── .gitattributes ├── .gitignore ├── .jscsrc ├── .jshintignore ├── .jshintrc ├── .travis.yml ├── AUTHORS ├── CONTRIBUTING.md ├── Gruntfile.js ├── LICENSE ├── README.md ├── app ├── app.html ├── images │ ├── .gitkeep │ ├── arrow.png │ ├── arrow@2x.png │ ├── chronicle-devices.png │ ├── chronicle-devices@2x.png │ ├── icon-delete-active.png │ ├── icon-delete-active@2x.png │ ├── icon-delete-hover.png │ ├── icon-delete-hover@2x.png │ ├── icon-delete.png │ ├── icon-delete@2x.png │ ├── icon-favicon_default.png │ ├── icon-favicon_default@2x.png │ ├── icon-search.png │ ├── icon-search@2x.png │ ├── mozilla.png │ └── mozilla@2x.png ├── index.html ├── scripts │ ├── collections │ │ ├── search_results.js │ │ └── visits.js │ ├── config.js │ ├── lib │ │ ├── image_proxy.js │ │ ├── localizer.js │ │ └── modal_manager.js │ ├── main.js │ ├── models │ │ ├── user.js │ │ ├── user_page.js │ │ └── visit.js │ ├── presenters │ │ └── user_page_presenter.js │ ├── router.js │ ├── templates │ │ ├── global_header │ │ │ ├── global_header.html │ │ │ ├── search_box.html │ │ │ └── user_info.html │ │ ├── search │ │ │ └── index.html │ │ ├── user_pages │ │ │ └── item.html │ │ ├── visits │ │ │ ├── date_divider.html │ │ │ └── index.html │ │ └── welcome.html │ └── views │ │ ├── base.js │ │ ├── global_header │ │ ├── global_header.js │ │ ├── search_box.js │ │ └── user_info.js │ │ ├── home │ │ └── home.js │ │ ├── search │ │ ├── index.js │ │ └── item.js │ │ ├── visits │ │ ├── date_divider.js │ │ ├── index.js │ │ └── item.js │ │ └── welcome.js └── styles │ ├── .scss-lint.yml │ ├── _base.scss │ ├── _breakpoints.scss │ ├── _layout.scss │ ├── _mixins.scss │ ├── _modules.scss │ ├── _state.scss │ ├── _variables.scss │ ├── main.scss │ └── modules │ ├── _actions.scss │ ├── _buttons.scss │ ├── _global_footer.scss │ ├── _global_header.scss │ ├── _home.scss │ ├── _modal.scss │ ├── _notifications.scss │ ├── _row.scss │ ├── _search_result.scss │ ├── _user_info.scss │ ├── _user_page.scss │ ├── _visits.scss │ └── _welcome.scss ├── bin ├── create_db.sh ├── create_test_data.js ├── migrate.js └── www ├── bower.json ├── config ├── local.json.example ├── local.json.travis └── test-urls.js ├── docs ├── API.md └── signoff.md ├── grunttasks ├── autoprefixer.js ├── build.js ├── changelog.js ├── clean.js ├── contributors.js ├── copy.js ├── copyright.js ├── css.js ├── default.js ├── hapi.js ├── intern.js ├── jscs.js ├── jshint.js ├── jsonlint.js ├── lint.js ├── requirejs.js ├── rev.js ├── sass.js ├── serve.js ├── template.js ├── test.js ├── todo.js ├── usemin.js ├── validate-shrinkwrap.js └── watch.js ├── npm-shrinkwrap.json ├── package.json ├── public └── .gitignore ├── server ├── bell-oauth-profile.js ├── config.js ├── controllers │ ├── auth.js │ ├── base.js │ ├── profile.js │ ├── search.js │ ├── ver.js │ ├── visit.js │ └── visits.js ├── db │ ├── elasticsearch.js │ ├── migrations │ │ ├── patch-0-1.sql │ │ ├── patch-1-0.sql │ │ ├── patch-1-2.sql │ │ └── patch-2-1.sql │ ├── migrator.js │ ├── postgres.js │ └── reindex.js ├── embedly.js ├── index.js ├── logger.js ├── models │ ├── user-page.js │ ├── user.js │ ├── visit.js │ └── visits.js ├── routes │ ├── auth.js │ ├── base.js │ ├── index.js │ ├── ops.js │ ├── profile.js │ ├── search.js │ ├── ver.js │ ├── visit.js │ └── visits.js ├── utils.js ├── views │ ├── search.js │ ├── visit.js │ └── visits.js └── work-queue │ ├── README.md │ ├── jobs │ ├── create-visit.js │ ├── extract-page.js │ ├── index.js │ └── send-welcome-email.js │ └── queue.js └── tests ├── functional └── visits.js └── intern.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "app/bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | npm-shrinkwrap.json -diff 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config/*.json 3 | app/bower_components 4 | app/styles/*.css 5 | *.swp 6 | *.swo 7 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "disallowKeywords": ["with", "eval"], 3 | "disallowKeywordsOnNewLine": ["else"], 4 | "disallowMultipleLineStrings": true, 5 | "disallowSpaceAfterObjectKeys": true, 6 | "disallowSpaceAfterPrefixUnaryOperators": ["++", "--", "+", "-"], 7 | "disallowSpaceBeforePostfixUnaryOperators": ["++", "--"], 8 | "maximumLineLength": 160, 9 | "requireCapitalizedConstructors": true, 10 | "requireCurlyBraces": ["for", "while", "do"], 11 | "requireLineFeedAtFileEnd": true, 12 | "requireSpaceAfterBinaryOperators": ["=", ",", "+", "-", "/", "*", "==", "===", "!=", "!=="], 13 | "requireSpaceAfterKeywords": ["if", "else", "for", "while", "do", "switch", "return"], 14 | "requireSpaceAfterPrefixUnaryOperators": ["~"], 15 | "requireSpaceBeforeBinaryOperators": ["+", "-", "/", "*", "=", "==", "===", "!=", "!=="], 16 | "requireSpacesInConditionalExpression": true, 17 | "validateIndentation": 2, 18 | "validateLineBreaks": "LF", 19 | "validateQuoteMarks": true, 20 | "validateJSDoc": { 21 | "checkParamNames": true, 22 | "checkRedundantParams": true, 23 | "requireParamTypes": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | app/bower_components 2 | node_modules 3 | public 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "curly": true, 4 | "eqeqeq": true, 5 | "esnext": true, 6 | "expr": true, 7 | "globalstrict": false, 8 | "immed": true, 9 | "indent": 2, 10 | "latedef": true, 11 | "newcap": true, 12 | "noarg": true, 13 | "node": true, 14 | "quotmark": "single", 15 | "regexp": true, 16 | "smarttabs": true, 17 | "strict": true, 18 | "sub": true, 19 | "trailing": true, 20 | "undef": true, 21 | "globals": { 22 | "define": false, 23 | "Promise": true, 24 | "window": true, 25 | "document": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - "0.10" 6 | 7 | cache: 8 | directories: 9 | - node_modules 10 | - app/bower_components 11 | 12 | addons: 13 | firefox: "34.0" 14 | 15 | services: 16 | - redis-server 17 | - elasticsearch 18 | 19 | before_install: 20 | # Configure npm 21 | - npm config set spin false 22 | - npm config set loglevel silent 23 | # Install grunt globally 24 | - npm i bower grunt-cli -g 25 | # Download Selenium 26 | - wget http://selenium-release.storage.googleapis.com/2.44/selenium-server-standalone-2.44.0.jar 27 | 28 | install: 29 | - travis_retry npm install 30 | 31 | before_script: 32 | # Copy configuration 33 | - cp config/local.json.travis config/local.json 34 | # Setup database 35 | - bin/create_db.sh postgres 36 | - bin/migrate.js 37 | - node bin/create_test_data.js 38 | # Configure and start xvfb 39 | - export DISPLAY=:99.0 40 | - sh -e /etc/init.d/xvfb start 41 | # Start selenium and ignore the output 42 | - java -jar selenium-server-standalone-2.44.0.jar &> /dev/null & 43 | script: 44 | - grunt test 45 | - time npm run lint 46 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jared Hirsch 2 | Nick Chapman 3 | Nick Chapman 4 | Peter deHaan 5 | Vlad Filippov 6 | johngruen -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines for Chronicle 2 | 3 | Anyone is welcome to help with Chronicle. Feel free to get in touch with other community members on IRC, the 4 | mailing list or through issues here on GitHub. 5 | 6 | - IRC: `#chronicle` on `irc.mozilla.org` 7 | - Mailing list: 8 | - and of course, [the issues list](https://github.com/mozilla/chronicle/issues) 9 | 10 | ## Bug Reports ## 11 | 12 | You can file issues here on GitHub. Please try to include as much information as you can and under what conditions 13 | you saw the issue. 14 | 15 | ## Sending Pull Requests ## 16 | 17 | Coming Soon 18 | 19 | ## Code Review ## 20 | 21 | Coming Soon 22 | 23 | ## Code Organization 24 | 25 | See `server/README.md` for a ludicrous speed overview of that code. 26 | 27 | ## Git Commit Guidelines 28 | 29 | We loosely follow the [Angular commit guidelines](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#type) of `(): ` where `type` must be one of: 30 | 31 | * **feat**: A new feature 32 | * **fix**: A bug fix 33 | * **docs**: Documentation only changes 34 | * **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing 35 | semi-colons, etc) 36 | * **refactor**: A code change that neither fixes a bug or adds a feature 37 | * **perf**: A code change that improves performance 38 | * **test**: Adding missing tests 39 | * **chore**: Changes to the build process or auxiliary tools and libraries such as documentation 40 | generation 41 | 42 | ### Scope 43 | The scope could be anything specifying place of the commit change. For example `oauth`, 44 | `fxa-client`, `signup`, `l10n` etc... 45 | 46 | ### Subject 47 | The subject contains succinct description of the change: 48 | 49 | * use the imperative, present tense: "change" not "changed" nor "changes" 50 | * don't capitalize first letter 51 | * no dot (.) at the end 52 | 53 | ###Body (optional) 54 | Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes" 55 | The body should include the motivation for the change and contrast this with previous behavior. 56 | 57 | ###Footer 58 | The footer should contain any information about **Breaking Changes** and is also the place to 59 | reference GitHub issues that this commit **Closes**. 60 | 61 | ## Working With Styles 62 | 63 | Chronicle's Styles are written in Sass and organized following conventions derived from [SMACSS](https://smacss.com/) 64 | 65 | ### Sass Files 66 | 67 | Files are located in `app/styles/`. 68 | 69 | | Name | Description | 70 | |------|-------------| 71 | | `modules/*.scss` | Partials for individual modules. All modules should be a single partial. 72 | | `_base.scss` | Partial for editing global element styles. 73 | | `_layout.scss` | Partial for laying out id selected elements and classed layout modifiers. Modifiers should be.classes prefixed with `l-`. 74 | | `_modules.scss` | Partial for combining individual module partials. 75 | | `_state.scss` | Partial for state modification classes. 76 | | `_variables.scss` | Partial where Sass variables live. 77 | | `main.scss` | Where all the partials go to mix and mingle. 78 | 79 | ### Compiling Sass 80 | 81 | Use `grunt css` to compile. 82 | 83 | 84 | ## Test Options 85 | 86 | Coming Soon 87 | 88 | ## Intern Runner custom arguments 89 | 90 | Coming Soon 91 | 92 | ## Servers 93 | 94 | Coming Soon 95 | 96 | ## Dependencies and Tools 97 | 98 | Coming Soon 99 | 100 | ## License 101 | 102 | MPL 2.0 103 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | require('load-grunt-tasks')(grunt, { pattern: ['grunt-*', 'intern'] }); 9 | 10 | var config = require('./server/config'); 11 | 12 | grunt.initConfig({ 13 | staticPath: config.get('server_staticPath') 14 | }); 15 | 16 | grunt.loadTasks('grunttasks/'); 17 | }; 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chronicle 2 | 3 | find everything you've ever found 4 | 5 | [![Build Status: Travis](https://travis-ci.org/mozilla/chronicle.svg?branch=master)](https://travis-ci.org/mozilla/chronicle) 6 | 7 | ## Installation 8 | 9 | ### Large Tools 10 | 11 | Chronicle is built using [Node.js](https://nodejs.org/), [ElasticSearch](https://www.elasticsearch.org/), [PostgreSQL](http://www.postgresql.org/), and [Redis](http://redis.io/), so you'll want to install the current stable version of all of these. 12 | 13 | If you are using Mac OS and have [Homebrew](http://brew.sh/) installed, this incantation should work: 14 | 15 | ```sh 16 | $ brew install nodejs elasticsearch postgresql redis 17 | ``` 18 | 19 | ### Code 20 | 21 | The server-side code dependencies are managed with [npm](https://www.npmjs.com/) and requires that [Grunt](http://gruntjs.com/) is globally installed (`npm install -g grunt-cli`). The front-end dependencies are managed with [Bower](https://bower.io/); you can install it via `npm install -g bower` if you don't have it on your system. 22 | 23 | To fetch dependencies and get cooking: 24 | 25 | 1. `npm install` and ensure redis, elasticsearch, postgres are all running 26 | 2. As part of the npm install process, the `postinstall` script will install the Bower dependencies for you. 27 | 3. Copy `config/local.json.example` to `config/local.json`, and put your local info in there. 28 | 4. Run `./bin/create_db.sh` to create the database 29 | - this script currently hard-codes the db user, password, and dbname to 'chronicle' (issue #112) 30 | 5. Run `./bin/migrate.js` to run all the migrations that create the database tables and indexes. (This script also reindexes elasticsearch, but on the first pass, you don't have data in postgres to copy over.) 31 | 6. Run `./bin/create_test_data.js` to create a test user and test data 32 | - the test user is defined in the config file 33 | - the test data is a set of visits created using the URLs in `config/test-urls.js`. Over time we'll experiment with different test data sets, might wind up with a test-urls directory instead. 34 | 7. `npm start` 35 | 8. You're up and running! surf to :surfer: 36 | 37 | ## Tests 38 | 39 | Right now the test suite consists entirely of functional tests that require Selenium Server 2.44.0. 40 | 41 | ### Prerequisites 42 | 43 | * Java JDK or JRE (http://www.oracle.com/technetwork/java/javase/downloads/index.html) 44 | * Selenium Server (http://docs.seleniumhq.org/download/) 45 | 46 | ### Run the tests 47 | 48 | Run the following in separate terminal windows/tabs: 49 | 50 | * `java -jar path/to/selenium-server-standalone-2.44.0.jar` 51 | * `grunt test` 52 | 53 | ### Available Grunt Tasks 54 | 55 | | Name | Description | 56 | |------|-------------| 57 | | `autoprefixer` | Adds vendor prefixes to CSS files based on statistics. 58 | | `build` | Build front-end assets and copy them to dist. 59 | | `changelog` | Generate a changelog from git metadata. 60 | | `clean` | Deletes files and folders. 61 | | `contributors` | Generates a list of contributors from your project's git history. 62 | | `copy` | Copies files and folders. 63 | | `copyright` | Checks for MPL copyright headers in source files. 64 | | `css` | Alias for "sass", "autoprefixer" tasks. 65 | | `hapi` | Starts the hapi server. 66 | | `jscs` | JavaScript Code Style checker. 67 | | `jshint` | Validates files with JSHint. 68 | | `jsonlint` | Validates JSON files. 69 | | `lint` | Alias for "jshint", "jscs", "jsonlint", "copyright" tasks. 70 | | `sass` | Compiles Sass files to vanilla CSS. 71 | | `serve` | Alias for "hapi", "build", and "watch" tasks. 72 | | `validate-shrinkwrap` | Submits your _npm-shrinkwrap.json_ file to for validation. 73 | | `watch` | Runs predefined tasks whenever watched files change. 74 | 75 | 76 | ### npm Scripts 77 | 78 | | Name | Description | 79 | |------|-------------| 80 | | `authors` | Alias for `grunt contributors` Grunt task. 81 | | `lint` | Alias for `grunt lint` Grunt task. This task gets run during the [precommit](https://www.npmjs.com/package/precommit-hook) Git hook. 82 | | `outdated` | Alias for `npm outdated --depth 0` to list top-level outdated modules in your package.json file. For more information, see . 83 | | `postinstall` | Runs after the package is installed, and automatically installs/updates the Bower dependencies. 84 | | `shrinkwrap` | Alias for `npm shrinkwrap --dev` and `npm run validate` to generate and validate npm-shrinkwrap.json file (including devDependencies). 85 | | `start` | Runs `grunt serve`. 86 | | `test` | Runs unit and functional tests. 87 | | `validate` | Alias for `grunt validate-shrinkwrap` task (ignoring any errors which may be reported). 88 | 89 | ### Creating Dummy Data 90 | 91 | If you just want to test something quickly with a small, known test data set: 92 | 93 | 1. Run `./bin/create_db.sh` to drop and re-create the local Postgres database. 94 | 2. Run `./bin/migrate.js` to apply any Postgres migrations specified in the `server/db/migrations/` directory. 95 | 3. To enable test data, ensure the `testUser.enabled` config option is set in `config/local.json`. 96 | - You can use the default id and email (defined in `server/config.js`), or set them yourself. 97 | You can set the values via env vars or config values. 98 | See `server/config.js` for the defaults and which config values or env vars to use. 99 | 4. Run `./bin/create_test_data.js` to create a dummy user and a few dummy visits. 100 | - The created dummy visits which will be created can be found in the `config/test-urls.js` file. 101 | 102 | ## Learn More 103 | * Tumblr: http://mozillachronicle.tumblr.com/ 104 | * IRC channel: #chronicle on mozilla IRC 105 | * Mailing list: chronicle-dev@mozilla.org (https://mail.mozilla.org/listinfo/chronicle-dev) 106 | -------------------------------------------------------------------------------- /app/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chronicle 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 |
18 |
19 |
20 | 21 |
22 |
23 |
24 |
25 |
26 |

© 2015 • Mozilla

27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/.gitkeep -------------------------------------------------------------------------------- /app/images/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/arrow.png -------------------------------------------------------------------------------- /app/images/arrow@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/arrow@2x.png -------------------------------------------------------------------------------- /app/images/chronicle-devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/chronicle-devices.png -------------------------------------------------------------------------------- /app/images/chronicle-devices@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/chronicle-devices@2x.png -------------------------------------------------------------------------------- /app/images/icon-delete-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-delete-active.png -------------------------------------------------------------------------------- /app/images/icon-delete-active@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-delete-active@2x.png -------------------------------------------------------------------------------- /app/images/icon-delete-hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-delete-hover.png -------------------------------------------------------------------------------- /app/images/icon-delete-hover@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-delete-hover@2x.png -------------------------------------------------------------------------------- /app/images/icon-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-delete.png -------------------------------------------------------------------------------- /app/images/icon-delete@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-delete@2x.png -------------------------------------------------------------------------------- /app/images/icon-favicon_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-favicon_default.png -------------------------------------------------------------------------------- /app/images/icon-favicon_default@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-favicon_default@2x.png -------------------------------------------------------------------------------- /app/images/icon-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-search.png -------------------------------------------------------------------------------- /app/images/icon-search@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/icon-search@2x.png -------------------------------------------------------------------------------- /app/images/mozilla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/mozilla.png -------------------------------------------------------------------------------- /app/images/mozilla@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/chronicle/7434f4426228b88fdac123210d211ffc52561786/app/images/mozilla@2x.png -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chronicle 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

Chronicle is currently in development. Learn more.

18 |
19 |
20 | 21 |
22 |
23 |
24 |
25 |
26 | 27 |

Chronicle

28 |

Find everything you've ever found

29 | 30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 |
38 |

You know when you can’t find that 
great video you saw online last week?

39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | 75 |

Chronicle fixes that.

76 |

We take all the best stuff you’ve seen online
 and organize it into a beautiful, searchable feed.

77 |
78 |
79 |
80 |
81 | 82 |
83 |
84 |
85 |

Chronicle goes where you do.

86 |
87 |

Sign in to Chronicle on iOS or Android and bring your online life with you wherever you go.

88 |
89 |
90 |
91 |
92 | 93 |
94 |
95 |
96 | 97 |

Chronicle puts you in control

98 |

Chronicle is built by Mozilla, the non-profit behind Firefox and the most trusted internet company for privacy. We’ll never share or sell your data because we believe it belongs to you.

99 |
100 |
101 |
102 |
103 | 104 |
105 |
106 |
107 | 108 |

Chronicle

109 | 110 | 111 |
112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /app/scripts/collections/search_results.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'backbone', 8 | 'models/user_page' 9 | ], function (_, Backbone, UserPage) { 10 | 'use strict'; 11 | 12 | var SearchResults = Backbone.Collection.extend({ 13 | model: UserPage, 14 | url: '/v1/search', 15 | 16 | parse: function (response, xhr) { 17 | if (response && response.results && response.results.hits) { 18 | return _.collect(response.results.hits, function (hit) { 19 | return hit._source; 20 | }); 21 | } 22 | } 23 | }); 24 | 25 | return SearchResults; 26 | }); 27 | -------------------------------------------------------------------------------- /app/scripts/collections/visits.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'backbone', 7 | 'models/visit' 8 | ], function (Backbone, Visit) { 9 | 'use strict'; 10 | 11 | var Visits = Backbone.Collection.extend({ 12 | model: Visit, 13 | url: '/v1/visits' 14 | }); 15 | 16 | return Visits; 17 | }); 18 | -------------------------------------------------------------------------------- /app/scripts/config.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | ], function () { 7 | 'use strict'; 8 | 9 | var config = { 10 | embedly: { 11 | apiKey: '{%= config.get("embedly_apiKey") %}' 12 | }, 13 | addon: { 14 | firefox: { 15 | url: '{%= config.get("addon_firefox_url") %}' 16 | } 17 | } 18 | }; 19 | 20 | return config; 21 | }); 22 | -------------------------------------------------------------------------------- /app/scripts/lib/image_proxy.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'jquery', 8 | 'config' 9 | ], function (_, $, config) { 10 | 'use strict'; 11 | 12 | var EMBEDLY_DISPLAY_ENDPOINT = '//i.embed.ly/1/display'; 13 | 14 | // TODO: move this somewhere generic when it's needed in more than one place 15 | var IS_RETINA = window.devicePixelRatio >= 1.3; 16 | 17 | var imageProxy = { 18 | display: function (url) { 19 | return this._buildUrl('', { url: url }); 20 | }, 21 | 22 | crop: function (url, width, height) { 23 | return this._buildUrl('/crop', { url: url, width: width, height: height }); 24 | }, 25 | 26 | resize: function (url, width, height, grow) { 27 | return this._buildUrl('/resize', { url: url, width: width, height: height, grow: grow }); 28 | }, 29 | 30 | _buildUrl: function (action, options) { 31 | // add api key, retinify dimension options, and convert to query params 32 | var params = $.param(_.extend({ key: config.embedly.apiKey }, this._retinafyDimensions(options))); 33 | 34 | return EMBEDLY_DISPLAY_ENDPOINT + action + '?' + params; 35 | }, 36 | 37 | // Increases the size of images to account for retina displays 38 | _retinafyDimensions: function (options) { 39 | if (IS_RETINA && options.width && options.height) { 40 | options.width = options.width * 2; 41 | options.height = options.height * 2; 42 | } 43 | 44 | return options; 45 | } 46 | }; 47 | 48 | return imageProxy; 49 | }); 50 | -------------------------------------------------------------------------------- /app/scripts/lib/localizer.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'jquery' 8 | ], function (_, $) { 9 | 'use strict'; 10 | 11 | /** 12 | * Singleton object that fetches localizations from the server and localizes English strings. 13 | * 14 | * @class Localizer 15 | * 16 | * @constructor 17 | */ 18 | var Localizer = { 19 | dictionary: {}, 20 | 21 | /** 22 | * Fetches the localized strings from the server and saves them for later. 23 | * 24 | * @return {jqXHR} jqXHR jQuery XHR object representing the request 25 | */ 26 | // TODO: Enable server side localization (issue #28). 27 | fetch: function () { 28 | var xhr = $.ajax('/1/l10n/client.json'); 29 | 30 | var self = this; 31 | 32 | xhr.fail(function () { 33 | // Reset the dictionary on failure 34 | self.dictionary = {}; 35 | }); 36 | 37 | xhr.done(function (data) { 38 | self.dictionary = data; 39 | }); 40 | 41 | return xhr; 42 | }, 43 | 44 | /** 45 | * Localizes the English input string. Returns the input string if nothing is found. 46 | * 47 | * @return {String} localized string 48 | */ 49 | localize: function (input) { 50 | var output = this.dictionary[input]; 51 | 52 | // null or empty string returns input 53 | return output && output.trim().length ? output : input; 54 | } 55 | }; 56 | 57 | return Localizer; 58 | }); 59 | -------------------------------------------------------------------------------- /app/scripts/lib/modal_manager.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'jquery', 8 | 'velocity', 9 | 'velocityui' 10 | ], function (_, $) { 11 | 'use strict'; 12 | 13 | function ModalManager () { 14 | this._viewStates = []; 15 | this.$modal = $('#modal'); 16 | 17 | // Handle default button actions 18 | this.$modal.on('click', 'a.close', this.close.bind(this)); 19 | this.$modal.on('click', 'a.back', this.pop.bind(this)); 20 | } 21 | 22 | _.extend(ModalManager.prototype, { 23 | /** 24 | * Opens the modal. This is a specialized version of `push` that cleans up previous views. 25 | * 26 | * @method open 27 | * @param {Backbone.View} view Backbone view to be rendered and placed in the modal 28 | * @param {Object} options Behavioral options for the modal 29 | * @param {VelocityObject} options.effect Animation effect to use when opening the modal 30 | * @param {Boolean} options.fullScreen Make the modal full screen 31 | */ 32 | open: function (view, options) { 33 | this._destroyViews(); 34 | 35 | this.push(view, options); 36 | }, 37 | 38 | /** 39 | * Pushes a view onto the modal view stack. 40 | * 41 | * @method push 42 | * @param {Backbone.View} view Backbone view to be rendered and placed in the modal 43 | * @param {Object} options Behavioral options for the modal 44 | * @param {VelocityObject} options.effect Animation effect to use when opening the modal 45 | * @param {Boolean} options.fullScreen Make the modal full screen 46 | */ 47 | push: function (view, options) { 48 | this._viewStates.push({ view: view, options: (options || {}) }); 49 | 50 | this._show(); 51 | }, 52 | 53 | 54 | /** 55 | * Pops the top most view off the view stack. 56 | * 57 | * @method pop 58 | */ 59 | pop: function () { 60 | var viewState = this._viewStates.pop(); 61 | 62 | if (viewState) { 63 | viewState.view.destroy(); 64 | } 65 | 66 | if (this._viewStates.length > 0) { 67 | this._show(); 68 | } else { 69 | this._hide(); 70 | } 71 | }, 72 | 73 | /** 74 | * Closes the modal. 75 | * 76 | * @method close 77 | * @param {Object} options Options for closing the modal 78 | * @param {VelocityObject} options.effect Animation effect to use when closing the modal 79 | */ 80 | close: function (options) { 81 | this._destroyViews(); 82 | this._hide(options); 83 | }, 84 | 85 | 86 | /** 87 | * Cleans up all the views in the stack and empties the `_viewStates`. 88 | * 89 | * @private 90 | * @method _destroyViews 91 | */ 92 | _destroyViews: function () { 93 | _.each(this.viewStates, function (viewState) { 94 | viewState.view.destroy(); 95 | }); 96 | 97 | this._viewStates = []; 98 | }, 99 | 100 | /** 101 | * Shows the last view in the stack. 102 | * 103 | * @private 104 | * @method _show 105 | */ 106 | _show: function () { 107 | var viewState = _.last(this._viewStates); 108 | 109 | viewState.view.render(); 110 | 111 | // Force delegate events to fix an issue where restoring a previous view breaks event bindings 112 | viewState.view.delegateEvents(); 113 | 114 | // TODO: Add fancy positioning: likely in the middle of the screen. We're only supporting 115 | // full screen right now, so it can come later. 116 | 117 | // Add full-screen class if the full screen option is enabled 118 | if (viewState.options.fullScreen) { 119 | this.$modal.addClass('full-screen'); 120 | } else { 121 | this.$modal.removeClass('full-screen'); 122 | } 123 | 124 | // Replace modal contents 125 | this.$modal.html(viewState.view.el); 126 | 127 | // Animate if an effect is provided 128 | if (viewState.options.effect) { 129 | this.$modal.velocity(viewState.options.effect); 130 | } else { 131 | this.$modal.show(); 132 | } 133 | }, 134 | 135 | /** 136 | * Hides the modal. 137 | * 138 | * @private 139 | * @method _hide 140 | * @param {Object} options Options for closing the modal 141 | * @param {VelocityObject} options.effect Animation effect to use when closing the modal 142 | */ 143 | _hide: function (options) { 144 | options = options || {}; 145 | 146 | // Animate if an effect is provided 147 | if (options.effect) { 148 | this.$modal.velocity(options.effect, function () { 149 | // Reset styles and hide 150 | $(this).removeAttr('style').hide(); 151 | }); 152 | } else { 153 | this.$modal.hide(); 154 | } 155 | } 156 | }); 157 | 158 | // Return a singleton 159 | return new ModalManager(); 160 | }); 161 | -------------------------------------------------------------------------------- /app/scripts/main.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | require.config({ 8 | paths: { 9 | jquery: '../bower_components/jquery/dist/jquery', 10 | backbone: '../bower_components/backbone/backbone', 11 | underscore: '../bower_components/underscore/underscore', 12 | text: '../bower_components/requirejs-text/text', 13 | mustache: '../bower_components/mustache/mustache', 14 | stache: '../bower_components/requirejs-mustache/stache', 15 | moment: '../bower_components/moment/moment', 16 | velocity: '../bower_components/velocity/velocity', 17 | velocityui: '../bower_components/velocity/velocity.ui', 18 | fullpage: '../bower_components/fullpage/jquery.fullPage' 19 | }, 20 | shim: { 21 | underscore: { 22 | exports: '_' 23 | }, 24 | backbone: { 25 | deps: [ 26 | 'underscore', 27 | 'jquery' 28 | ], 29 | exports: 'Backbone' 30 | }, 31 | fullpage: { 32 | deps: ['jquery'], 33 | exports: '$.fn.fullpage' 34 | } 35 | } 36 | }); 37 | 38 | function startApp() { 39 | require(['backbone', 'router'], function (Backbone) { 40 | Backbone.history.start(); 41 | }); 42 | } 43 | 44 | function startHome() { 45 | require(['views/home/home'], function (HomeView) { 46 | new HomeView({ el: 'html.home' }); 47 | }); 48 | } 49 | 50 | require([ 51 | 'jquery' 52 | ], function ($) { 53 | // Use the class on html to determine if we should boot home or app 54 | $('html').hasClass('app') ? startApp() : startHome(); 55 | }); 56 | -------------------------------------------------------------------------------- /app/scripts/models/user.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'backbone' 7 | ], function (Backbone) { 8 | 'use strict'; 9 | 10 | var User = Backbone.Model.extend({ 11 | url: function () { 12 | return '/v1/profile'; 13 | } 14 | }); 15 | 16 | return User; 17 | }); 18 | -------------------------------------------------------------------------------- /app/scripts/models/user_page.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'backbone' 7 | ], function (Backbone) { 8 | 'use strict'; 9 | 10 | var UserPage = Backbone.Model.extend(); 11 | 12 | return UserPage; 13 | }); 14 | -------------------------------------------------------------------------------- /app/scripts/models/visit.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'backbone' 7 | ], function (Backbone) { 8 | 'use strict'; 9 | 10 | var Visit = Backbone.Model.extend(); 11 | 12 | return Visit; 13 | }); 14 | -------------------------------------------------------------------------------- /app/scripts/presenters/user_page_presenter.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'lib/image_proxy' 8 | ], function (_, imageProxy) { 9 | 'use strict'; 10 | 11 | var MINIMUM_IMAGE_ENTROPY = 1.75; 12 | var IMAGE_DISPLAY_WIDTH = 270; 13 | var IMAGE_DISPLAY_HEIGHT = 180; 14 | 15 | // This pattern ignores extracted images from the following URLs 16 | // - anything from github (we'll likely have a long list of domains like this) 17 | // - any domain without a path 18 | // - search results 19 | // - anything paged 20 | var EXTRACTED_IMAGE_DENY_PATTERN = /github.com|(\..{2,3}\/$)|([&?]q=)|([&?]page=\d+)/; 21 | 22 | function UserPagePresenter (userPage, options) { 23 | // Copy over user page attributes 24 | _.extend(this, userPage.attributes); 25 | 26 | // Set default options 27 | options = _.extend({ 28 | relatedVisit: null, 29 | alternateImages: true 30 | }, options); 31 | 32 | this.relatedVisit = options.relatedVisit && options.relatedVisit.attributes; 33 | this.alternateImages = options.alternateImages; 34 | } 35 | 36 | _.extend(UserPagePresenter.prototype, { 37 | title: function () { 38 | // Use the relatedVisit title if we have it 39 | return (this.relatedVisit && this.relatedVisit.title) || this.extractedTitle; 40 | }, 41 | 42 | faviconUrl: function () { 43 | if (this.extractedFaviconUrl) { 44 | return imageProxy.display(this.extractedFaviconUrl); 45 | } else { 46 | return '/images/icon-favicon_default@2x.png'; 47 | } 48 | }, 49 | 50 | imageUrl: function () { 51 | var url = this.hasValidExtractedImageUrl() ? this.extractedImageUrl : this._getScreenshotUrl(); 52 | 53 | // it would be smarter to only crop screenshots if we're not in retina, but it's easier 54 | // to let the imageProxy just handle it for now. 55 | return imageProxy.crop(url, IMAGE_DISPLAY_WIDTH, IMAGE_DISPLAY_HEIGHT); 56 | }, 57 | 58 | imagePosition: function () { 59 | if (this.alternateImages) { 60 | return (this.title().length % 2) ? 'left' : 'right'; 61 | } else { 62 | return 'right'; 63 | } 64 | }, 65 | 66 | isSearchResult: function () { 67 | return !!this._getUrl().match(/[?&#][pq]=/i); 68 | }, 69 | 70 | hasHashBang: function () { 71 | return !!this._getUrl().match(/(#!)|(#(.*?)\/)/); 72 | }, 73 | 74 | hasValidExtractedImageUrl: function () { 75 | return this.extractedImageUrl && 76 | (this.extractedImageEntropy && this.extractedImageEntropy > MINIMUM_IMAGE_ENTROPY) && 77 | !this._getUrl().match(EXTRACTED_IMAGE_DENY_PATTERN); 78 | }, 79 | 80 | hasLargeImage: function () { 81 | return this.extractedImageWidth && this.extractedImageWidth >= IMAGE_DISPLAY_WIDTH; 82 | }, 83 | 84 | getInterestingness: function () { 85 | if (!this.interestingness) { 86 | var value = 1.0; 87 | 88 | // Positives 89 | if (this.hasValidExtractedImageUrl()) { 90 | value *= 1.3; 91 | } 92 | 93 | if (this.hasLargeImage()) { 94 | value *= 1.2; 95 | } 96 | 97 | if (this.extractedMediaHtml) { 98 | value *= 2.0; 99 | } 100 | 101 | if (this.extractedDescription) { 102 | value *= 1.3; 103 | } 104 | 105 | // Negatives 106 | if (this.hasHashBang()) { 107 | value *= 0.6; 108 | } 109 | 110 | if (this.isSearchResult()) { 111 | value *= 0.2; 112 | } 113 | 114 | this.interestingness = value; 115 | } 116 | 117 | return this.interestingness; 118 | }, 119 | 120 | isLarge: function () { 121 | return this.getInterestingness() >= 1.75; 122 | }, 123 | 124 | isMedium: function () { 125 | return this.getInterestingness() < 1.75 && this.interestingness > 1.0; 126 | }, 127 | 128 | isSmall: function () { 129 | return this.getInterestingness() <= 1.0; 130 | }, 131 | 132 | getSizeClassName: function () { 133 | if (this.isLarge()) { 134 | return 'large'; 135 | } else if (this.isMedium()) { 136 | return 'medium'; 137 | } else { 138 | return 'small'; 139 | } 140 | }, 141 | 142 | // TODO: this is working around a difference in the location of the screenshot url (#264) 143 | _getScreenshotUrl: function () { 144 | return this.screenshot_url || (this.relatedVisit && this.relatedVisit.screenshot_url); 145 | }, 146 | 147 | // TODO: this is working around a difference in the location of the url (#264) 148 | _getUrl: function () { 149 | return this.url || this.rawUrl; 150 | } 151 | }); 152 | 153 | return UserPagePresenter; 154 | }); 155 | -------------------------------------------------------------------------------- /app/scripts/router.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'jquery', 7 | 'backbone', 8 | 'lib/modal_manager', 9 | 'views/global_header/global_header', 10 | 'views/visits/index', 11 | 'views/search/index', 12 | 'views/welcome' 13 | ], function ($, Backbone, modalManager, GlobalHeaderView, VisitsIndexView, SearchIndexView, WelcomeView) { 14 | 'use strict'; 15 | 16 | var Router = Backbone.Router.extend({ 17 | routes: { 18 | '': 'showIndex', 19 | 'search/(:query)': 'showSearchIndex', 20 | 'welcome': 'showWelcome' 21 | }, 22 | 23 | initialize: function () { 24 | this.initializeGlobalHeader(); 25 | this.watchAnchors(); 26 | }, 27 | 28 | initializeGlobalHeader: function () { 29 | this.globalHeaderView = new GlobalHeaderView(); 30 | 31 | // This renders the first time the stage is set 32 | $('#global-header').html(this.globalHeaderView.el); 33 | }, 34 | 35 | showIndex: function () { 36 | this.setStage(new VisitsIndexView()); 37 | }, 38 | 39 | showSearchIndex: function (query) { 40 | this.setStage(new SearchIndexView(query)); 41 | }, 42 | 43 | showWelcome: function () { 44 | modalManager.open(new WelcomeView(), { fullScreen: true }); 45 | 46 | // Setup the index view underneath the modal 47 | this.showIndex(); 48 | }, 49 | 50 | setStage: function (view) { 51 | // Destroy the current view before replacing it 52 | if (this.currentView) { 53 | this.currentView.destroy(); 54 | } 55 | 56 | this.currentView = view; 57 | 58 | // Render and insert view into the stage 59 | $('#stage').html(this.currentView.render().el); 60 | 61 | // Render the header again to give it a chance to update based on the current state 62 | // This could be an annoyance at some point but it's reliable for now 63 | this.globalHeaderView.render(); 64 | }, 65 | 66 | // watches for clicks that should be handled by backbone and calls navigate internally 67 | watchAnchors: function () { 68 | $(window.document).on('click', 'a[href^="/"]', function (event) { 69 | // Remove leading slashes 70 | var url = $(event.target).attr('href').replace(/^\//, ''); 71 | 72 | // rewrite the url if: 73 | // - nobody prevented the event 74 | // - it doesn't start with 'auth' 75 | // - no special keys 76 | if (!event.isDefaultPrevented() && !url.match(/^auth/) && !event.altKey && 77 | !event.ctrlKey && !event.metaKey && !event.shiftKey) { 78 | event.preventDefault(); 79 | 80 | this.navigate(url, { trigger: true }); 81 | } 82 | }.bind(this)); 83 | } 84 | }); 85 | 86 | // return singleton 87 | return new Router(); 88 | }); 89 | -------------------------------------------------------------------------------- /app/scripts/templates/global_header/global_header.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |

Chronicle

6 | 9 |
10 | -------------------------------------------------------------------------------- /app/scripts/templates/global_header/search_box.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /app/scripts/templates/global_header/user_info.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | avatar 4 |
5 |
6 | 24 | -------------------------------------------------------------------------------- /app/scripts/templates/search/index.html: -------------------------------------------------------------------------------- 1 |

2 | {{#onlyOneResult}} 3 | {{numberOfResults}} result 4 | {{/onlyOneResult}} 5 | {{^onlyOneResult}} 6 | {{numberOfResults}} results 7 | {{/onlyOneResult}} 8 |

9 |
10 | 11 |
12 | -------------------------------------------------------------------------------- /app/scripts/templates/user_pages/item.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | favicon 6 | {{extractedProviderName}} 7 |
8 |

{{title}}

9 |
{{extractedDescription}}
10 |
    11 |
  • 12 |
13 | -------------------------------------------------------------------------------- /app/scripts/templates/visits/date_divider.html: -------------------------------------------------------------------------------- 1 | {{formattedDate}} 2 | -------------------------------------------------------------------------------- /app/scripts/templates/visits/index.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /app/scripts/templates/welcome.html: -------------------------------------------------------------------------------- 1 |

Welcome y'all

2 | 3 | {{#supportedBrowser}} 4 |
5 |

Some details about the add-on

6 | 7 |
8 | 12 | {{/supportedBrowser}} 13 | 14 | {{^supportedBrowser}} 15 |

Sorry, there isn't currently an add-on for your browser.

16 | 17 | {{/supportedBrowser}} 18 | -------------------------------------------------------------------------------- /app/scripts/views/base.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define( 6 | [ 7 | 'underscore', 8 | 'backbone', 9 | 'lib/localizer' 10 | ], 11 | function (_, Backbone, Localizer) { 12 | 'use strict'; 13 | 14 | /** 15 | * Base class for views that provides common rendering, model presentation, DOM assignment, 16 | * subview tracking, and tear-down. 17 | * 18 | * @class BaseView 19 | * 20 | * @constructor 21 | * 22 | * @param {Object} options configuration options passed along to Backbone.View 23 | */ 24 | var BaseView = Backbone.View.extend({ 25 | constructor: function (options) { 26 | this.subviews = []; 27 | 28 | Backbone.View.call(this, options); 29 | }, 30 | 31 | /** 32 | * Gets context from model's attributes. Can be overridden to provide custom context for template. 33 | * 34 | * @method getContext 35 | * @return {Object} context 36 | */ 37 | getContext: function () { 38 | var context; 39 | 40 | if (this.model) { 41 | context = this.model.attributes; 42 | } else { 43 | context = {}; 44 | } 45 | 46 | return context; 47 | }, 48 | 49 | /** 50 | * Localizes English input text. 51 | * 52 | * @method localize 53 | * @return {String} localized text 54 | */ 55 | localize: function (text) { 56 | return Localizer.localize(text); 57 | }, 58 | 59 | /** 60 | * Renders by combining template and context and inserting into the associated element. 61 | * 62 | * @method render 63 | * @return {BaseView} this 64 | * @chainable 65 | */ 66 | render: function () { 67 | this.destroySubviews(); 68 | 69 | var context = this.getContext(); 70 | var self = this; 71 | 72 | context.l = function () { 73 | return function (text, render) { 74 | return render(self.localize(text)); 75 | }; 76 | }; 77 | 78 | this.$el.html(this.template(context)); 79 | 80 | this.afterRender(); 81 | 82 | return this; 83 | }, 84 | 85 | /** 86 | * Called after render completes. Provides easy access to custom rendering for subclasses 87 | * without having to override render. 88 | * 89 | * @method afterRender 90 | */ 91 | afterRender: function () { 92 | // Implement in subclasses 93 | }, 94 | 95 | /** 96 | * Renders local collection using the provided view and inserts into the provided selector. 97 | * 98 | * @method renderCollection 99 | * @param {Backbone.View} ItemView view for rendering each item in the collection 100 | * @param {String} selector jQuery selector to insert the collected elements 101 | */ 102 | renderCollection: function (ItemView, selector) { 103 | var els = this.collection.collect(function (item) { 104 | return this.trackSubview(new ItemView({ model: item })).render().el; 105 | }.bind(this)); 106 | 107 | this.$(selector).append(els); 108 | }, 109 | 110 | /** 111 | * Assigns view to a selector. 112 | * 113 | * @method assign 114 | * @param {Backbone.View} view to assign 115 | * @param {String} selector jQuery selector for the element to be assigned 116 | * @return {BaseView} this 117 | */ 118 | assign: function (view, selector) { 119 | view.setElement(this.$(selector)); 120 | view.render(); 121 | }, 122 | 123 | /** 124 | * Destroys view by stopping Backbone event listeners, disabling jQuery events, and destroying 125 | * subviews. 126 | * 127 | * @method destroy 128 | */ 129 | destroy: function () { 130 | if (this.beforeDestroy) { 131 | this.beforeDestroy(); 132 | } 133 | 134 | this.stopListening(); 135 | this.destroySubviews(); 136 | this.$el.off(); 137 | }, 138 | 139 | /** 140 | * Keeps track of a subview so that it can later be destroyed. 141 | * 142 | * @method trackSubview 143 | * @param {BaseView} view to track 144 | * @return {BaseView} tracked view 145 | */ 146 | trackSubview: function (view) { 147 | if (!_.contains(this.subviews, view)) { 148 | this.subviews.push(view); 149 | } 150 | 151 | return view; 152 | }, 153 | 154 | /** 155 | * Destroys all subviews. 156 | * 157 | * @method destroySubviews 158 | */ 159 | destroySubviews: function () { 160 | _.invoke(this.subviews, 'destroy'); 161 | 162 | this.subviews = []; 163 | } 164 | }); 165 | 166 | return BaseView; 167 | } 168 | ); 169 | -------------------------------------------------------------------------------- /app/scripts/views/global_header/global_header.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'views/base', 7 | 'stache!templates/global_header/global_header', 8 | 'views/global_header/user_info', 9 | 'views/global_header/search_box' 10 | ], function (BaseView, GlobalHeaderTemplate, UserInfoView, SearchBoxView) { 11 | 'use strict'; 12 | 13 | var GlobalHeaderView = BaseView.extend({ 14 | template: GlobalHeaderTemplate, 15 | 16 | initialize: function () { 17 | this.userInfoView = new UserInfoView(); 18 | this.searchBoxView = new SearchBoxView(); 19 | }, 20 | 21 | afterRender: function () { 22 | this.assign(this.userInfoView, '#user-info'); 23 | this.assign(this.searchBoxView, '#search-box'); 24 | } 25 | }); 26 | 27 | return GlobalHeaderView; 28 | }); 29 | -------------------------------------------------------------------------------- /app/scripts/views/global_header/search_box.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'backbone', 7 | 'views/base', 8 | 'stache!templates/global_header/search_box' 9 | ], function (Backbone, BaseView, SearchBoxTemplate, router) { 10 | 'use strict'; 11 | 12 | var SearchBoxView = BaseView.extend({ 13 | template: SearchBoxTemplate, 14 | 15 | events: { 16 | 'submit form': 'search' 17 | }, 18 | 19 | search: function (event) { 20 | event.preventDefault(); 21 | 22 | var encodedQuery = window.encodeURIComponent(this.$('.query').val()); 23 | 24 | Backbone.history.navigate('search/' + encodedQuery, { trigger: true }); 25 | }, 26 | 27 | afterRender: function () { 28 | this._setInputValue(); 29 | }, 30 | 31 | _setInputValue: function () { 32 | // Check the current URL fragment 33 | var fragmentMatch = Backbone.history.fragment.match(/search\/(.+)/); 34 | 35 | // Update the input box if we have a match 36 | if (fragmentMatch) { 37 | var query = window.decodeURIComponent(fragmentMatch[1]); 38 | 39 | this.$('.query').val(query); 40 | } 41 | } 42 | }); 43 | 44 | return SearchBoxView; 45 | }); 46 | -------------------------------------------------------------------------------- /app/scripts/views/global_header/user_info.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'jquery', 7 | 'views/base', 8 | 'stache!templates/global_header/user_info', 9 | 'models/user' 10 | ], function ($, BaseView, UserInfoTemplate, User) { 11 | 'use strict'; 12 | 13 | var UserInfoView = BaseView.extend({ 14 | template: UserInfoTemplate, 15 | events: { 16 | 'click .trigger': '_toggleMenuState' 17 | }, 18 | 19 | initialize: function () { 20 | this.model = new User(); 21 | this.toggleSpeed = 200; 22 | 23 | this.listenTo(this.model, 'change', this.render); 24 | 25 | this.model.fetch(); 26 | }, 27 | 28 | _toggleMenuState: function (event) { 29 | var $target = $(event.currentTarget); 30 | 31 | if (!$target.hasClass('triggered')) { 32 | this._bindDismissMenu(); 33 | } else { 34 | this._unbindDismissMenu(); 35 | } 36 | 37 | this._toggleMenu(); 38 | }, 39 | 40 | _toggleMenu: function() { 41 | var $trigger = $('.trigger'); 42 | var $menu = $('.menu'); 43 | 44 | $trigger.stop().toggleClass('triggered'); 45 | $menu.stop().fadeToggle(this.toggleSpeed); 46 | }, 47 | 48 | _bindDismissMenu: function(event) { 49 | $('body').click(this._dismissMenu.bind(this)); 50 | }, 51 | 52 | _unbindDismissMenu: function() { 53 | $('body').off('click'); 54 | }, 55 | 56 | _dismissMenu: function(event) { 57 | var $target = $(event.target); 58 | 59 | if (!$target.is('.trigger, .trigger img, .menu') && !$target.closest('.menu').length) { 60 | this._toggleMenu(); 61 | this._unbindDismissMenu(); 62 | } 63 | } 64 | 65 | }); 66 | 67 | return UserInfoView; 68 | }); 69 | -------------------------------------------------------------------------------- /app/scripts/views/home/home.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'jquery', 7 | 'fullpage', 8 | 'views/base' 9 | ], function ($, fullpage, BaseView) { 10 | 'use strict'; 11 | 12 | var HomeView = BaseView.extend({ 13 | events: { 14 | 'click .skip-intro':'_skipIntro', 15 | 'click .arrow': '_moveDown' 16 | }, 17 | 18 | initialize: function () { 19 | this.mediaNames = ['video', 'story', 'photo', 'article', 'song', 'picture']; 20 | this._setRandomColors(); 21 | this._initializeFullpage(); 22 | }, 23 | 24 | // the html is delivered in the page by the server. nothing to render. 25 | render: function () { 26 | return this; 27 | }, 28 | 29 | _initializeFullpage: function() { 30 | $('#landing-page-wrapper').addClass('fade-in'); 31 | 32 | /* 33 | * invokes the jquery fullpage plugin 34 | * https://github.com/alvarotrigo/fullPage.js 35 | */ 36 | 37 | $('#landing-page').fullpage({ 38 | navigation: true, 39 | navigationPosition: 'right', 40 | scrollingSpeed: 700, 41 | touchSensitivity: 25, 42 | onLeave: this._swapContentHelper.bind(this) 43 | }); 44 | 45 | }, 46 | 47 | /* 48 | * this following two methods are temporary and will be removed 49 | * when we have an official color scheme 50 | */ 51 | 52 | _setRandomColors: function() { 53 | var that = this; 54 | this.$('.img').each(function(){ 55 | var color = that._generateColor(); 56 | $(this).css('background', color); 57 | }); 58 | }, 59 | 60 | _generateColor: function() { 61 | var letters = '0123456789ABCDEF'.split(''); 62 | var color = '#'; 63 | for (var i = 0; i < 6; i++ ) { 64 | color += letters[Math.floor(Math.random() * 16)]; 65 | } 66 | return color; 67 | }, 68 | 69 | _skipIntro: function() { 70 | $.fn.fullpage.moveTo(5, 0); 71 | }, 72 | 73 | _moveDown: function() { 74 | $.fn.fullpage.moveSectionDown(); 75 | }, 76 | 77 | /* 78 | * params come from fullpage.js callback 79 | * index: index of the leaving section. Starting from 1. 80 | * nextIndex: index of the destination section. Starting from 1. 81 | * direction: it will take the values up or down depending on the scrolling direction. 82 | */ 83 | 84 | _swapContentHelper: function(index, nextIndex, direction) { 85 | if (nextIndex === 2) { 86 | this._swapContent(); 87 | } 88 | }, 89 | 90 | _swapContent: function() { 91 | var $switcherEl = $('.content-switch'); 92 | var index = Math.floor(Math.random() * this.mediaNames.length); 93 | $switcherEl.html(this.mediaNames[index]); 94 | } 95 | 96 | }); 97 | 98 | return HomeView; 99 | }); 100 | -------------------------------------------------------------------------------- /app/scripts/views/search/index.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'views/base', 7 | 'stache!templates/search/index', 8 | 'collections/search_results', 9 | 'views/search/item' 10 | ], function (BaseView, SearchIndexTemplate, SearchResults, SearchItemView) { 11 | 'use strict'; 12 | 13 | var SearchIndexView = BaseView.extend({ 14 | template: SearchIndexTemplate, 15 | 16 | initialize: function (query) { 17 | this.query = query; 18 | this.collection = new SearchResults(); 19 | 20 | this.listenTo(this.collection, 'add destroy reset', this.render); 21 | 22 | // Fetch visits from the server 23 | this.collection.fetch({ reset: true, data: { q: this.query } }); 24 | }, 25 | 26 | getContext: function () { 27 | return { 28 | numberOfResults: this.collection.length, 29 | onlyOneResult: this.collection.length === 1 30 | }; 31 | }, 32 | 33 | afterRender: function () { 34 | this.renderCollection(SearchItemView, '.search-results'); 35 | } 36 | }); 37 | 38 | return SearchIndexView; 39 | }); 40 | -------------------------------------------------------------------------------- /app/scripts/views/search/item.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'views/base', 8 | 'stache!templates/user_pages/item', 9 | 'presenters/user_page_presenter' 10 | ], function (_, BaseView, UserPagesItemTemplate, UserPagePresenter) { 11 | 'use strict'; 12 | 13 | var SearchItemView = BaseView.extend({ 14 | className: 'search-result user-page medium', 15 | template: UserPagesItemTemplate, 16 | 17 | initialize: function () { 18 | this.presenter = new UserPagePresenter(this.model, { alternateImages: false }); 19 | }, 20 | 21 | getContext: function () { 22 | return this.presenter; 23 | } 24 | }); 25 | 26 | return SearchItemView; 27 | }); 28 | -------------------------------------------------------------------------------- /app/scripts/views/visits/date_divider.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'moment', 8 | 'views/base', 9 | 'stache!templates/visits/date_divider', 10 | ], function (_, moment, BaseView, VisitsDateDividerTemplate, UserPagePresenter) { 11 | 'use strict'; 12 | 13 | var VisitsDateDividerView = BaseView.extend({ 14 | tagName: 'h3', 15 | className: 'visit-date-divider', 16 | template: VisitsDateDividerTemplate, 17 | 18 | initialize: function (date) { 19 | this.date = date; 20 | }, 21 | 22 | getContext: function (date) { 23 | return { 24 | formattedDate: this._formatDate() 25 | }; 26 | }, 27 | 28 | _formatDate: function () { 29 | var formattedDate; 30 | 31 | var diff = this.date.diff(moment().startOf('day'), 'days'); 32 | 33 | if (diff === 0) { 34 | formattedDate = 'Today'; 35 | } else if (diff === -1) { 36 | formattedDate = 'Yesterday'; 37 | } else { 38 | formattedDate = this.date.format('MMMM Do'); 39 | } 40 | 41 | return formattedDate; 42 | } 43 | }); 44 | 45 | return VisitsDateDividerView; 46 | }); 47 | -------------------------------------------------------------------------------- /app/scripts/views/visits/index.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'jquery', 8 | 'moment', 9 | 'views/base', 10 | 'stache!templates/visits/index', 11 | 'collections/visits', 12 | 'views/visits/item', 13 | 'views/visits/date_divider' 14 | ], function (_, $, moment, BaseView, VisitsIndexTemplate, Visits, VisitsItemView, VisitsDateDividerView) { 15 | 'use strict'; 16 | 17 | var VisitsIndexView = BaseView.extend({ 18 | FETCH_RETRY_WAIT: 5000, 19 | 20 | template: VisitsIndexTemplate, 21 | 22 | initialize: function () { 23 | this.collection = new Visits(); 24 | 25 | this.lastVisitId = null; 26 | this.hasMoreVisits = true; 27 | this.loadingMoreVisits = false; 28 | this.count = 50; 29 | this.bottomBufferSize = 750; 30 | this.scrollDelay = 50; 31 | 32 | this.listenTo(this.collection, 'reset', this._renderVisits); 33 | 34 | this._fetch(); 35 | }, 36 | 37 | afterRender: function () { 38 | // check scroll position to see if we should load more visits 39 | // don't get crazy though. only check the scroll every this.scrollDelay ms 40 | $(window).on('scroll', _.throttle(this._checkScrollPosition.bind(this), this.scrollDelay)); 41 | }, 42 | 43 | beforeDestroy: function () { 44 | // stop checking scroll position 45 | $(window).off('scroll'); 46 | }, 47 | 48 | // this appends visit items rather than replacing them 49 | _renderVisits: function () { 50 | var els = []; 51 | 52 | this.collection.each(function (visit) { 53 | var currentVisitDate = moment(visit.get('visitedAt')).startOf('day'); 54 | 55 | if (!this.previousVisitDate || this.previousVisitDate.diff(currentVisitDate) !== 0) { 56 | els.push(this.trackSubview(new VisitsDateDividerView(currentVisitDate)).render().el); 57 | } 58 | 59 | els.push(this.trackSubview(new VisitsItemView({ model: visit })).render().el); 60 | 61 | this.previousVisitDate = currentVisitDate; 62 | }.bind(this)); 63 | 64 | this.$('.visits').append(els); 65 | }, 66 | 67 | _fetch: function () { 68 | // let checkScrollPosition know that we're already loading more visits 69 | this.loadingMoreVisits = true; 70 | 71 | var data = { 72 | count: this.count 73 | }; 74 | 75 | if (this.lastVisitId) { 76 | data.visitId = this.lastVisitId; 77 | } 78 | 79 | // fire off the xhr request 80 | this.collection.fetch({ reset: true, data: data }) 81 | .done(this._fetchDone.bind(this)) 82 | .fail(this._fetchFailed.bind(this)); 83 | }, 84 | 85 | _fetchDone: function (data) { 86 | this.loadingMoreVisits = false; 87 | 88 | // if the length is less than the count we know there aren't more results 89 | this.hasMoreVisits = this.collection.length === this.count; 90 | this.lastVisitId = this.collection.last().get('id'); 91 | }, 92 | 93 | _fetchFailed: function (xhr) { 94 | // TODO: Do something interesting with 404s (no results) 95 | }, 96 | 97 | _checkScrollPosition: function () { 98 | if (!this.loadingMoreVisits && this.hasMoreVisits && this._getPixelsFromBottom() < this.bottomBufferSize) { 99 | this._fetch(); 100 | } 101 | }, 102 | 103 | _getPixelsFromBottom: function () { 104 | return $(document).height() - $(document).scrollTop() - $(window).height(); 105 | }, 106 | }); 107 | 108 | return VisitsIndexView; 109 | }); 110 | -------------------------------------------------------------------------------- /app/scripts/views/visits/item.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'views/base', 8 | 'stache!templates/user_pages/item', 9 | 'models/user_page', 10 | 'presenters/user_page_presenter' 11 | ], function (_, BaseView, UserPagesItemView, UserPage, UserPagePresenter) { 12 | 'use strict'; 13 | 14 | var VisitsItemView = BaseView.extend({ 15 | className: 'visit user-page', 16 | template: UserPagesItemView, 17 | 18 | events: { 19 | 'click .destroy': 'destroyModel' 20 | }, 21 | 22 | initialize: function () { 23 | this.userPage = new UserPage(this.model.get('userPage')); 24 | this.presenter = new UserPagePresenter(this.userPage, { relatedVisit: this.model }); 25 | }, 26 | 27 | getContext: function () { 28 | return this.presenter; 29 | }, 30 | 31 | afterRender: function () { 32 | // add size class 33 | this.$el.addClass(this.presenter.getSizeClassName()); 34 | }, 35 | 36 | destroyModel: function (event) { 37 | event.preventDefault(); 38 | 39 | if (window.confirm('Destroy this visit?')) { 40 | this.model.destroy(); 41 | 42 | this.$el.fadeOut(function () { 43 | this.remove(); 44 | }.bind(this)); 45 | } 46 | } 47 | }); 48 | 49 | return VisitsItemView; 50 | }); 51 | -------------------------------------------------------------------------------- /app/scripts/views/welcome.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'underscore', 7 | 'backbone', 8 | 'jquery', 9 | 'views/base', 10 | 'stache!templates/welcome', 11 | 'lib/modal_manager', 12 | 'config' 13 | ], function (_, Backbone, $, BaseView, WelcomeTemplate, modalManager, config) { 14 | 'use strict'; 15 | 16 | var WelcomeView = BaseView.extend({ 17 | className: 'welcome', 18 | template: WelcomeTemplate, 19 | 20 | events: { 21 | 'click button.install': 'install', 22 | 'click button.continue': 'continue' 23 | }, 24 | 25 | install: function (event) { 26 | event.preventDefault(); 27 | 28 | $.Velocity.RunSequence([ 29 | { e: this.$('#install-container'), p: 'transition.fadeOut' }, 30 | { e: this.$('#continue-container'), p: 'transition.fadeIn' } 31 | ]); 32 | 33 | // Trigger add-on installation 34 | window.location = config.addon.firefox.url; 35 | }, 36 | 37 | continue: function (event) { 38 | event.preventDefault(); 39 | 40 | // Set the URL to the root but don't trigger since the view is already in place 41 | Backbone.history.navigate('/'); 42 | 43 | modalManager.close({ effect: 'transition.fadeOut' }); 44 | }, 45 | 46 | getContext: function () { 47 | return { 48 | supportedBrowser: /firefox/i.test(window.navigator.userAgent) 49 | }; 50 | } 51 | }); 52 | 53 | return WelcomeView; 54 | }); 55 | -------------------------------------------------------------------------------- /app/styles/.scss-lint.yml: -------------------------------------------------------------------------------- 1 | # Default application configuration that all configurations inherit from. 2 | 3 | linters: 4 | 5 | Comment: 6 | enabled: false 7 | 8 | HexLength: 9 | enabled: true 10 | style: long # or 'short' 11 | 12 | HexNotation: 13 | enabled: true 14 | style: lowercase # or 'uppercase' 15 | 16 | IdSelector: 17 | enabled: false 18 | 19 | LeadingZero: 20 | enabled: true 21 | style: include_zero # or 'exclude_zero' 22 | 23 | NestingDepth: 24 | enabled: true 25 | max_depth: 4 26 | 27 | PlaceholderInExtend: 28 | enabled: false 29 | 30 | SelectorDepth: 31 | enabled: true 32 | max_depth: 4 33 | 34 | Shorthand: 35 | enabled: false 36 | -------------------------------------------------------------------------------- /app/styles/_base.scss: -------------------------------------------------------------------------------- 1 | @import '../bower_components/normalize-scss/normalize'; 2 | @import url(//fonts.googleapis.com/css?family=Fira+Sans:400,500,700,400italic,700italic); 3 | 4 | *, 5 | *:before, 6 | *:after { 7 | box-sizing: border-box; 8 | } 9 | 10 | a { 11 | color: $primary; 12 | text-decoration: none; 13 | 14 | &:hover { 15 | text-decoration: underline; 16 | } 17 | } 18 | 19 | .truncate { 20 | overflow: hidden; 21 | text-overflow: ellipsis; 22 | white-space: nowrap; 23 | width: 100%; 24 | } 25 | -------------------------------------------------------------------------------- /app/styles/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | // Breakpoint management 2 | // http://www.sitepoint.com/managing-responsive-breakpoints-sass/ 3 | 4 | $breakpoints: ( 5 | small: ('max-width: 520px'), 6 | big: ('min-width: 521px') 7 | ); 8 | 9 | @mixin respond-to($breakpoint) { 10 | @if map-has-key($breakpoints, $breakpoint) { 11 | @media (#{map-get($breakpoints, $breakpoint)}) { 12 | @content; 13 | } 14 | } @else { 15 | @warn 'Unfortunately, no value could be retrieved from `#{$breakpoint}`. ' 16 | + 'Please make sure it is defined in `$breakpoints` map.'; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/styles/_layout.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background: $body-background; 3 | color: $dark-grey; 4 | font-family: $body-font; 5 | } 6 | 7 | #stage { 8 | margin-top: $double-grid; 9 | } 10 | 11 | #container { 12 | flex: 1; 13 | } 14 | 15 | .l-centered { 16 | margin: 0 auto; 17 | } 18 | 19 | .l-flex { 20 | display: flex; 21 | 22 | &-body { 23 | @extend .l-flex; 24 | flex-direction: column; 25 | min-height: 100vh; 26 | } 27 | 28 | &-horizontal { 29 | @extend .l-flex; 30 | align-items: center; 31 | justify-content: space-between; 32 | } 33 | 34 | &-horizontal-centered { 35 | @extend .l-flex; 36 | align-items: center; 37 | justify-content: space-around; 38 | } 39 | 40 | &-vertical { 41 | @extend .l-flex; 42 | flex-direction: column; 43 | 44 | &-centered { 45 | @extend .l-flex-vertical; 46 | align-items: center; 47 | justify-content: center; 48 | min-height: 100%; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin vertical-align { 2 | position: relative; 3 | top: 50%; 4 | transform: translateY(-50%); 5 | } 6 | 7 | @mixin two-by-three($width) { 8 | height: $width * 0.667; 9 | width: $width; 10 | } 11 | 12 | @mixin hidpi-background-image($filename, $background-size, $extension: png) { 13 | background-image: image-url("#{$filename}.#{$extension}"); 14 | background-size: $background-size; 15 | 16 | @media (min--moz-device-pixel-ratio: 1.3), 17 | (-o-min-device-pixel-ratio: 2.6/2), 18 | (-webkit-min-device-pixel-ratio: 1.3), 19 | (min-device-pixel-ratio: 1.3), 20 | (min-resolution: 1.3dppx) { 21 | background-image: image-url("#{$filename}@2x.#{$extension}"); 22 | } 23 | } 24 | 25 | // Kellum image replacement 26 | // http://www.zeldman.com/2012/03/01/replacing-the-9999px-hack-new-image-replacement/ 27 | @mixin image-replacement($filename, $width, $height, $extension: png) { 28 | @include hidpi-background-image($filename, $width $height, $extension); 29 | height: $height; 30 | overflow: hidden; 31 | text-indent: 100%; 32 | white-space: nowrap; 33 | width: $width; 34 | } 35 | 36 | @mixin color-scheme($color, $background-color) { 37 | background-color: $background-color; 38 | color: $color; 39 | } 40 | -------------------------------------------------------------------------------- /app/styles/_modules.scss: -------------------------------------------------------------------------------- 1 | @import 'modules/global_header'; 2 | @import 'modules/global_footer'; 3 | @import 'modules/notifications'; 4 | @import 'modules/user_info'; 5 | @import 'modules/buttons'; 6 | @import 'modules/visits'; 7 | @import 'modules/row'; 8 | @import 'modules/user_page'; 9 | @import 'modules/actions'; 10 | @import 'modules/search_result'; 11 | @import 'modules/welcome'; 12 | @import 'modules/modal'; 13 | @import 'modules/home'; 14 | -------------------------------------------------------------------------------- /app/styles/_state.scss: -------------------------------------------------------------------------------- 1 | .hidden { 2 | display: none; 3 | } 4 | 5 | .succcess { 6 | color: $success; 7 | } 8 | 9 | .warning { 10 | color: $warning; 11 | } 12 | 13 | .toggle { 14 | @include respond-to(small) { 15 | @extend .hidden; 16 | } 17 | 18 | @include respond-to(big) { 19 | display: block; 20 | } 21 | } 22 | 23 | .triggered { 24 | background: linear-gradient(to bottom, #060606 0%, #2d2d2d 100%); 25 | } 26 | 27 | @mixin border-state-machine($border-width, $base-color, $engaged-color) { 28 | border: $border-width solid $base-color; 29 | transition: all $fast; 30 | 31 | &:hover, 32 | &:focus { 33 | border-color: $engaged-color; 34 | } 35 | } 36 | 37 | @mixin scale-state-machine() { 38 | transition: all $fast; 39 | 40 | &:hover { 41 | transform: scale(1.05); 42 | } 43 | 44 | &:active { 45 | transform: scale(1); 46 | } 47 | } 48 | 49 | @mixin bg-state-machine($color, $reverse) { 50 | @if $reverse { 51 | color: $white; 52 | } 53 | 54 | background: $color; 55 | transition: all $fast; 56 | 57 | &:hover, 58 | &:focus { 59 | background: darken($color, 5%); 60 | } 61 | 62 | &:active { 63 | background: lighten($color, 5%); 64 | } 65 | } 66 | 67 | .delayed-scale-in-out { 68 | animation: delayed-scale-in-out; 69 | animation-duration: 1s; 70 | } 71 | 72 | .fade-in { 73 | animation: fade-in forwards; 74 | animation-duration: 1s; 75 | } 76 | 77 | @keyframes fade-in { 78 | 0% { 79 | opacity: 0; 80 | } 81 | 82 | 100% { 83 | opacity: 1; 84 | } 85 | } 86 | 87 | @keyframes delayed-scale-in-out { 88 | 0% { 89 | transform: scale(1); 90 | } 91 | 92 | 20% { 93 | transform: scale(1); 94 | } 95 | 96 | 40% { 97 | transform: scale(1.2); 98 | } 99 | 100 | 60% { 101 | transform: scale(1); 102 | } 103 | 104 | 80% { 105 | transform: scale(1.2); 106 | } 107 | 108 | 100% { 109 | transform: scale(1); 110 | } 111 | 112 | } 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /app/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | //COLOR VARIABLES 2 | $primary: #0095dd; 3 | $body-background: #f2f2f2; 4 | $white: #ffffff; 5 | $black: #000000; 6 | $light-grey: #eeeeee; 7 | $dark-grey: #333333; 8 | $middle-grey: #4d4d4d; 9 | $satan-grey: #666666; 10 | $baby-grey: #dfe1e4; 11 | $success: #00ff00; 12 | $warning: #ff0000; 13 | $translucent-black: rgba(0, 0, 0, 0.4); 14 | $translucenter-black: rgba(0, 0, 0, 0.05); 15 | $translucent-white: rgba(255, 255, 255, 0.9); 16 | $translucenter-white: rgba(255, 255, 255, 0.7); 17 | $date-gradient: linear-gradient(to bottom, #92a0ac 0%, #85909c 100%); 18 | 19 | //TYPE VARIABLES 20 | $body-font: 'Fira Sans', sans-serif; 21 | $small-font-size: 12px; 22 | $default-font-size: 16px; 23 | $content-header-font-size: 22px; 24 | $large-font-size: 24px; 25 | 26 | //GAP VARIABLES 27 | $default-gap: 10px; 28 | $small-gap: $default-gap * 0.6; 29 | $double-gap: $default-gap * 2; 30 | $quad-gap: $default-gap * 4; 31 | $octo-gap: $default-gap * 8; 32 | 33 | //GRID VARIABLES 34 | $default-grid: 32px; 35 | $half-grid: $default-grid * 0.5; 36 | $double-grid: $default-grid * 2; 37 | 38 | //BORDER VARIABLES 39 | $default-radius: 4px; 40 | $double-radius: $default-radius * 2; 41 | $round-radius: 50%; 42 | 43 | //TIMING VARIABLES 44 | $default: 150ms; 45 | $fast: $default * 0.5; 46 | -------------------------------------------------------------------------------- /app/styles/main.scss: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | @import 'variables'; 6 | @import 'mixins'; 7 | @import 'breakpoints'; 8 | @import 'state'; 9 | @import 'base'; 10 | @import 'layout'; 11 | @import 'modules'; 12 | -------------------------------------------------------------------------------- /app/styles/modules/_actions.scss: -------------------------------------------------------------------------------- 1 | .actions { 2 | list-style: none; 3 | margin: 0; 4 | padding: 0; 5 | 6 | .medium &, 7 | .large & { 8 | @extend .l-flex; 9 | clear: both; 10 | 11 | li { 12 | @extend .l-flex-horizontal; 13 | } 14 | 15 | a { 16 | flex: 0 0 $default-grid; 17 | height: $default-grid; 18 | } 19 | } 20 | 21 | .small & { 22 | 23 | a { 24 | display: block; 25 | float: left; 26 | height: $default-grid - $small-gap; 27 | width: $default-grid - $small-gap; 28 | } 29 | } 30 | 31 | 32 | 33 | .destroy a { 34 | @include hidpi-background-image(icon-delete, 20px 20px); 35 | background-position: center center; 36 | background-repeat: no-repeat; 37 | 38 | &:hover { 39 | @include hidpi-background-image(icon-delete-hover, 20px 20px); 40 | } 41 | 42 | &:active { 43 | @include hidpi-background-image(icon-delete-active, 20px 20px); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/styles/modules/_buttons.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | @include scale-state-machine(); 3 | border: 0; 4 | border-radius: $default-radius; 5 | min-height: $default-grid; 6 | min-width: $double-grid; 7 | padding: 0 $default-gap; 8 | } 9 | 10 | .button-big { 11 | box-shadow: 0 -2px 1px $translucent-black inset; 12 | font-size: $large-font-size; 13 | max-width: $default-grid * 10; 14 | min-height: $double-grid; 15 | min-width: $default-grid * 8; 16 | text-shadow: 0 2px 1px $translucenter-black; 17 | } 18 | 19 | .button-group { 20 | @extend .l-flex; 21 | 22 | .button { 23 | @include bg-state-machine($light-grey, false); 24 | } 25 | } 26 | 27 | .button-default { 28 | @include bg-state-machine($light-grey, false); 29 | } 30 | 31 | .button-warn { 32 | @include bg-state-machine($warning, true); 33 | } 34 | 35 | .button-primary { 36 | @include bg-state-machine($primary, true); 37 | border-bottom: 2px solid darken($primary, 10); 38 | } 39 | 40 | .button-border { 41 | border: 2px solid $dark-grey; 42 | border-radius: $double-radius; 43 | color: $dark-grey; 44 | } 45 | -------------------------------------------------------------------------------- /app/styles/modules/_global_footer.scss: -------------------------------------------------------------------------------- 1 | #global-footer { 2 | @include color-scheme($white, $middle-grey); 3 | margin-top: $default-gap; 4 | padding: $double-gap 0; 5 | 6 | p { 7 | margin: 0; 8 | text-align: center; 9 | } 10 | 11 | a { 12 | color: $white; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/styles/modules/_global_header.scss: -------------------------------------------------------------------------------- 1 | #global-header { 2 | @include color-scheme($white, $middle-grey); 3 | position: fixed; 4 | top: 0; 5 | z-index: 10; 6 | 7 | .row-inner { 8 | height: $double-grid; 9 | } 10 | 11 | h1 { 12 | flex: 1; 13 | font-size: $large-font-size; 14 | line-height: $large-font-size; 15 | margin: 0 $small-gap; 16 | 17 | @include respond-to(small) { 18 | display: none; 19 | } 20 | 21 | a { 22 | color: $white; 23 | text-decoration: none; 24 | } 25 | } 26 | 27 | #search-box { 28 | flex-basis: $default-grid * 10; 29 | 30 | @include respond-to(small) { 31 | flex: 1; 32 | } 33 | 34 | form { 35 | flex: 1; 36 | } 37 | 38 | input { 39 | @include hidpi-background-image(icon-search, 16px 16px); 40 | @include color-scheme($middle-grey, $white); 41 | @include border-state-machine(2px, $middle-grey, $dark-grey); 42 | background-position: top 50% right 12px; 43 | background-repeat: no-repeat; 44 | border-radius: $double-radius; 45 | flex: 1; 46 | font-size: $default-font-size; 47 | padding: $small-gap $quad-gap $small-gap $small-gap; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/styles/modules/_modal.scss: -------------------------------------------------------------------------------- 1 | #modal { 2 | background: $white; 3 | left: 0; 4 | position: absolute; 5 | top: 0; 6 | z-index: 20; 7 | 8 | &.full-screen { 9 | height: 100%; 10 | width: 100%; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/styles/modules/_notifications.scss: -------------------------------------------------------------------------------- 1 | .notification { 2 | @extend .row; 3 | height: $double-grid; 4 | 5 | &.dismissable { 6 | background: $primary; 7 | color: $white; 8 | 9 | a { 10 | color: $white; 11 | text-decoration: underline; 12 | } 13 | } 14 | 15 | &.static { 16 | background: $white; 17 | box-shadow: 0 1px 1px $translucenter-black; 18 | position: absolute; 19 | top: 0; 20 | z-index: 100; 21 | 22 | p { 23 | margin: $double-gap 0; 24 | text-align: center; 25 | } 26 | } 27 | 28 | > div { 29 | align-items: center; 30 | } 31 | 32 | p { 33 | flex: 1; 34 | font-size: $default-font-size; 35 | margin: 0; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/styles/modules/_row.scss: -------------------------------------------------------------------------------- 1 | .row { 2 | width: 100%; 3 | } 4 | 5 | .row-inner { 6 | margin-left: auto; 7 | margin-right: auto; 8 | max-width: 740px; 9 | position: relative; 10 | width: 100%; 11 | 12 | &-padded { 13 | @extend .row-inner; 14 | 15 | @include respond-to(big) { 16 | padding-left: $double-gap; 17 | padding-right: $double-gap; 18 | } 19 | 20 | @include respond-to(small) { 21 | padding-left: $default-gap; 22 | padding-right: $default-gap; 23 | } 24 | } 25 | 26 | &-break { 27 | @extend .row-inner; 28 | 29 | @include respond-to(big) { 30 | padding-left: $double-gap; 31 | padding-right: $double-gap; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/styles/modules/_search_result.scss: -------------------------------------------------------------------------------- 1 | // This is a specialized version of .user-page 2 | .search-result { 3 | .actions { 4 | .destroy { 5 | display: none; 6 | } 7 | } 8 | } 9 | 10 | .number-of-results { 11 | padding-top: $default-grid; 12 | } 13 | -------------------------------------------------------------------------------- /app/styles/modules/_user_info.scss: -------------------------------------------------------------------------------- 1 | #user-info { 2 | flex: 0 0 $default-grid + $half-grid; 3 | height: $default-grid + $half-grid; 4 | 5 | @include respond-to(small) { 6 | margin-right: $default-gap; 7 | } 8 | 9 | .trigger { 10 | border-radius: $double-radius; 11 | cursor: pointer; 12 | height: 100%; 13 | transition: background $default; 14 | width: 100%; 15 | 16 | 17 | .mask { 18 | border: 1px solid lighten($satan-grey, 10); 19 | border-radius: $round-radius; 20 | height: $default-grid + $small-gap; 21 | overflow: hidden; 22 | width: $default-grid + $small-gap; 23 | } 24 | } 25 | 26 | .menu { 27 | @include color-scheme($middle-grey, $white); 28 | border: 1px solid lighten($satan-grey, 20); 29 | border-radius: $default-radius; 30 | box-shadow: 0 5px 5px $translucent-black; 31 | display: none; 32 | font-size: $small-font-size; 33 | margin: 4px 0 0 -6px; 34 | position: absolute; 35 | width: $default-grid * 8; 36 | 37 | // leaving fussy caret border/position CSS unvariablized for clarity 38 | &:before { 39 | border-bottom: 9px solid lighten($satan-grey, 20); 40 | border-left: 8px solid transparent; 41 | border-right: 8px solid transparent; 42 | content: ''; 43 | height: 0; 44 | left: $half-grid + 4; 45 | position: absolute; 46 | top: -10.5px; 47 | width: 0; 48 | } 49 | 50 | &:after { 51 | border-bottom: 10px solid $white; 52 | border-left: 9px solid transparent; 53 | border-right: 9px solid transparent; 54 | content: ''; 55 | height: 0; 56 | left: $half-grid + 3; 57 | position: absolute; 58 | top: -9.5px; 59 | width: 0; 60 | } 61 | } 62 | 63 | .menu-chunk { 64 | padding: $default-font-size; 65 | 66 | h4 { 67 | margin: 0 0 $small-gap; 68 | } 69 | 70 | ul { 71 | list-style: none; 72 | margin: 0; 73 | padding: 0; 74 | } 75 | 76 | a { 77 | color: $dark-grey; 78 | display: block; 79 | padding: $default-gap 0 0 0; 80 | text-decoration: none; 81 | 82 | &:hover { 83 | text-decoration: underline; 84 | } 85 | } 86 | 87 | + .menu-chunk { 88 | border-top: 1px solid $baby-grey; 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/styles/modules/_user_page.scss: -------------------------------------------------------------------------------- 1 | .user-page { 2 | background: $white; 3 | border: 1px solid $baby-grey; 4 | // negative spread keeps the shadow from leaking out the sides 5 | box-shadow: 0 2px 4px -3px rgba(0, 0, 0, 0.14); 6 | overflow: auto; 7 | padding: $double-gap; 8 | 9 | .image { 10 | display: block; 11 | 12 | img { 13 | @include two-by-three(270px); 14 | } 15 | 16 | &.right { 17 | float: right; 18 | margin-left: $double-gap; 19 | } 20 | 21 | &.left { 22 | float: left; 23 | margin-left: 0; 24 | margin-right: $double-gap; 25 | } 26 | } 27 | 28 | .provider { 29 | margin-bottom: $default-gap; 30 | 31 | .favicon { 32 | height: $half-grid; 33 | margin-right: 5px; 34 | vertical-align: -3px; 35 | width: $half-grid; 36 | } 37 | 38 | .name { 39 | font-size: $small-font-size; 40 | font-weight: 500; 41 | } 42 | } 43 | 44 | .title { 45 | font-size: $large-font-size; 46 | line-height: 120%; 47 | margin: 0 0 $default-gap 0; 48 | 49 | a { 50 | color: inherit; 51 | text-decoration: none; 52 | } 53 | } 54 | 55 | .description { 56 | color: $satan-grey; 57 | font-size: $default-font-size - 2; 58 | } 59 | 60 | // Size variations 61 | 62 | &.medium { 63 | .image img { 64 | @include two-by-three(160px); 65 | } 66 | } 67 | 68 | &.medium, 69 | &.small { 70 | .provider { 71 | float: left; 72 | height: $half-grid; 73 | margin: 0; 74 | 75 | .favicon { 76 | margin-right: 8px; 77 | vertical-align: 1px; 78 | } 79 | 80 | .name { 81 | display: none; 82 | } 83 | } 84 | 85 | /* 86 | * tweaked position to give better 87 | * alignment w/ favicon 88 | */ 89 | 90 | .title { 91 | font-size: $default-font-size - 1; 92 | position: relative; 93 | top: 1px; 94 | } 95 | } 96 | 97 | &.small { 98 | @extend .l-flex; 99 | align-items: center; 100 | background: lighten($body-background, 1); 101 | margin-top: $default-gap; 102 | padding: $small-gap $double-gap; 103 | 104 | .image, 105 | .provider .name, 106 | .description { 107 | display: none; 108 | } 109 | 110 | .title { 111 | flex: 1; 112 | margin: 0; 113 | overflow: hidden; 114 | padding-right: $default-gap; 115 | text-overflow: ellipsis; 116 | white-space: nowrap; 117 | } 118 | } 119 | 120 | // Fancy sibling selectors to achieve groupings 121 | // scss-lint:disable MergeableSelector 122 | 123 | // small after another small 124 | &.small + &.small { 125 | border-top-width: 0; 126 | margin-top: 0; 127 | } 128 | 129 | // medium or large after a small 130 | &.small + &.medium, 131 | &.small + &.large { 132 | margin-top: $default-gap; 133 | } 134 | 135 | // medium or large after one another 136 | &.medium + &.large, 137 | &.large + &.medium, 138 | &.large + &.large, 139 | &.medium + &.medium { 140 | border-top-width: 0; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /app/styles/modules/_visits.scss: -------------------------------------------------------------------------------- 1 | .visits { 2 | 3 | &:before { 4 | background: $baby-grey; 5 | content: ''; 6 | height: 100%; 7 | margin: ($quad-gap * -1) 0 0 ($default-gap * 2.8); 8 | overflow: hidden; 9 | position: absolute; 10 | width: 1px; 11 | z-index: -1; 12 | } 13 | 14 | .visit-date-divider { 15 | background: $date-gradient; 16 | border-radius: $double-gap; 17 | box-shadow: 0 1px 1px $translucent-black inset; 18 | color: $white; 19 | display: inline-block; 20 | font-size: $small-font-size; 21 | font-weight: 200; 22 | margin: $default-grid 0 $small-gap; 23 | padding: $small-gap $double-gap ($small-gap - 2); 24 | text-transform: uppercase; 25 | 26 | & + .user-page { 27 | margin-top: $default-gap; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/styles/modules/_welcome.scss: -------------------------------------------------------------------------------- 1 | #modal .welcome { 2 | } 3 | -------------------------------------------------------------------------------- /bin/create_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | # TODO convert to node when things slow down 8 | 9 | # Accept user for psql as first parameter but default to the current user 10 | # ./create_db.sh postgres 11 | PSQLUSER=${1-$USER} 12 | 13 | psql -c 'DROP DATABASE IF EXISTS chronicle;' -U $PSQLUSER 14 | psql -c "REASSIGN OWNED BY chronicle TO $PSQLUSER;" -U $PSQLUSER 15 | psql -c 'DROP USER IF EXISTS chronicle;' -U $PSQLUSER 16 | 17 | psql -c "CREATE USER chronicle WITH PASSWORD 'chronicle';" -U $PSQLUSER 18 | psql -c "CREATE DATABASE chronicle ENCODING 'UTF-8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8' TEMPLATE template0;" -U $PSQLUSER 19 | psql -c 'GRANT ALL PRIVILEGES ON DATABASE chronicle to chronicle;' -U $PSQLUSER 20 | psql -c 'ALTER SCHEMA public OWNER TO chronicle;' -U $PSQLUSER 21 | 22 | # lest we forget! clean out our elasticsearch index, too 23 | curl -XDELETE 'http://localhost:9200/chronicle/' 24 | curl -XPUT 'http://localhost:9200/chronicle/' 25 | -------------------------------------------------------------------------------- /bin/create_test_data.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | // this script assumes you've run the create_db script. 8 | 9 | 'use strict'; 10 | 11 | var config = require('../server/config'); 12 | 13 | // doing this above the other requires to avoid starting the queue unnecessarily 14 | // TODO properly shut down the queue so we don't have to do dumb stuff like this 15 | if (!config.get('testUser_enabled')) { 16 | throw new Error('To create test data, you must set testUser.enabled in the config.'); 17 | } 18 | 19 | var log = require('../server/logger')('bin.createTestData'); 20 | var user = require('../server/models/user'); 21 | var visitsController = require('../server/controllers/visits'); 22 | var testUrls = require('../config/test-urls'); 23 | 24 | var HOURS_IN_MS = 1000 * 60 * 60; 25 | 26 | function createTestUser(cb) { 27 | var fakeUser = { 28 | id: config.get('testUser_id'), 29 | email: config.get('testUser_email'), 30 | oauthToken: 'fakeOauthToken' 31 | }; 32 | log.verbose('fakeUser is ' + JSON.stringify(fakeUser)); 33 | user.create(fakeUser.id, fakeUser.email, fakeUser.oauthToken, function(err) { 34 | return cb && cb(err); 35 | }); 36 | } 37 | 38 | function createTestData(cb) { 39 | var userId = config.get('testUser_id'); 40 | // we'll use this date as a starting point for generating records. Each successive 41 | // record will be a number of hours in the future. 42 | var historyDate = new Date('2015-01-01T21:26:23.795Z'); 43 | 44 | function generateTestRequest(item, n) { 45 | return { 46 | auth: { 47 | credentials: userId 48 | }, 49 | payload: { 50 | url: item.url, 51 | title: item.title, 52 | visitedAt: new Date(historyDate.getTime() + (5 * HOURS_IN_MS * n)).toJSON() 53 | } 54 | }; 55 | } 56 | 57 | testUrls.forEach(function(item, i) { 58 | log.verbose(i, item); 59 | visitsController.post(generateTestRequest(item, i), function(resp) { 60 | if (resp instanceof Error) { 61 | log.warn('visit creation failed: ' + JSON.stringify(resp)); 62 | } else { 63 | log.verbose('visit creation success: ' + JSON.stringify(resp)); 64 | } 65 | }); 66 | }); 67 | 68 | // for now, just wait 30 seconds, then fire the callback blindly 69 | setTimeout(function() { 70 | cb(null, '30 seconds is up, hopefully the scraper jobs are all done!'); 71 | process.exit(); 72 | }, 1000 * 30); 73 | } 74 | 75 | log.verbose('about to call createTestUser'); 76 | createTestUser(function (err) { 77 | log.verbose('inside createTestUser callback'); 78 | if (err) { 79 | log.warn(err); 80 | log.warn('createTestUser failed, quitting'); 81 | return process.exit(); 82 | } 83 | log.verbose('about to call createTestData'); 84 | createTestData(function(err) { 85 | log.verbose('inside createTestData callback'); 86 | if (err) { 87 | log.warn(err); 88 | log.warn('createTestData failed'); 89 | } else { 90 | log.verbose('should now exit; done invoked'); 91 | } 92 | process.exit(); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /bin/migrate.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | // use this script to call our pg-patcher wrapper via the command line. 8 | // TODO add 'next' and 'prev' convenience methods? 9 | // TODO bring back commander, I guess 10 | 11 | 'use strict'; 12 | 13 | var fs = require('fs'); 14 | var path = require('path'); 15 | var log = require('../server/logger')('bin.migrate'); 16 | var migrator = require('../server/db/migrator'); 17 | var targetLevel = parseInt(process.argv[2], 10); 18 | var files; 19 | 20 | // if no args were passed, or if the arg wasn't an integer, 21 | // then, by default, use the highest defined patch level 22 | if (isNaN(targetLevel)) { 23 | files = fs.readdirSync(path.join(__dirname , '..', 'server', 'db', 'migrations')); 24 | var max = 0; 25 | files.forEach(function(file) { 26 | // assume files are of the conventional form 'patch-n-m.sql' 27 | // we want the largest m in the dir 28 | // the regex splits the filename into ['patch', 'n', 'm', 'sql'] 29 | var curr = file.split(/[-\.]/)[2]; 30 | if (curr > max) { max = curr; } 31 | }); 32 | targetLevel = max; 33 | } 34 | 35 | migrator(targetLevel) 36 | .done(function onSuccess(resp) { 37 | log.info('migration succeeded'); 38 | }, function onFailure(err) { 39 | log.error('migration failed: ' + err); 40 | }); 41 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | 'use strict'; 8 | 9 | var server = require('../server/index'); 10 | 11 | // TODO manage SIGTERM and friends 12 | server.start(function (err) { 13 | if (err) { 14 | console.warn('server failed to start: ' + err); 15 | throw err; // TODO should we fail to start in some other way? AppError? 16 | } 17 | console.info('chronicle server running on ' + server.info.uri); 18 | }); 19 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mozilla-chronicle", 3 | "version": "0.1.0", 4 | "homepage": "https://github.com/mozilla/chronicle", 5 | "authors": [ 6 | "Mozilla (https://mozilla.org)" 7 | ], 8 | "description": "find everything you've ever found", 9 | "moduleType": [ 10 | "amd" 11 | ], 12 | "license": "MPL 2.0", 13 | "private": true, 14 | "ignore": [ 15 | "**/.*", 16 | "node_modules", 17 | "bower_components", 18 | "test", 19 | "tests" 20 | ], 21 | "dependencies": { 22 | "backbone": "~1.1.2", 23 | "jquery": "~2.1.1", 24 | "normalize-scss": "~3.0.2", 25 | "requirejs-mustache": "*", 26 | "requirejs-text": "~2.0.12", 27 | "requirejs": "~2.1.15", 28 | "mustache": "~0.8.2", 29 | "moment": "~2.9.0", 30 | "velocity": "~1.2.1", 31 | "fullpage": "~2.5.6" 32 | }, 33 | "resolutions": { 34 | "jquery": "~2.1.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/local.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "alpha_allowedUsers": ["your@emailhere.com"], 3 | "db_elasticsearch_host": "127.0.0.1", 4 | "db_elasticsearch_port": 9200, 5 | "db_elasticsearch_queryTimeout": 15000, 6 | "db_postgres_host": "localhost", 7 | "db_postgres_port": 5432, 8 | "db_postgres_user": "chronicle", 9 | "db_postgres_database": "chronicle", 10 | "db_postgres_password": "chronicle", 11 | "db_postgres_queryTimeout": 15000, 12 | "db_redis_host": "127.0.0.1", 13 | "db_redis_port": 6379, 14 | "env": "local", 15 | "embedly_apiKey": "", 16 | "embedly_enabled": true, 17 | "url2png_apiKey": "", 18 | "url2png_secretKey": "", 19 | "email_fromEmail": "info@chronicle.firefox.com", 20 | "email_smtp_host": "localhost", 21 | "email_smtp_port": 25, 22 | "email_smtp_auth_user": "", 23 | "email_smtp_auth_pass": "", 24 | "server_host": "127.0.0.1", 25 | "server_port": 8080, 26 | "server_staticPath": "public", 27 | "server_staticDirListing": true, 28 | "server_log_app": "chronicle", 29 | "server_log_debug": false, 30 | "server_log_fmt": "pretty", 31 | "server_log_level": "info", 32 | "server_session_password": "Wh4t3ver.", 33 | "server_session_isSecure": false, 34 | "server_session_duration": 2592000000, 35 | "server_oauth_clientId": "1f9bbddcb3e160ab", 36 | "server_oauth_clientSecret": "24bf8caeaa685e2e42d9a75b48511f83adffaf6fcdd174ce8749358a376be911", 37 | "server_oauth_scope": ["chronicle", "profile"], 38 | "server_oauth_version": "2.0", 39 | "server_oauth_protocol": "oauth2", 40 | "server_oauth_redirectEndpoint": "http://localhost:8080/auth/complete", 41 | "server_oauth_authEndpoint": "https://oauth-latest.dev.lcip.org/v1/authorization", 42 | "server_oauth_tokenEndpoint": "https://oauth-latest.dev.lcip.org/v1/token", 43 | "server_oauth_profileEndpoint": "https://latest.dev.lcip.org/profile/v1/profile", 44 | "testUser_enabled": true, 45 | "addon_firefox_url": "", 46 | "sass_outputStyle": "nested" 47 | } 48 | -------------------------------------------------------------------------------- /config/local.json.travis: -------------------------------------------------------------------------------- 1 | { 2 | "testUser_enabled": true, 3 | "embedly_enabled": false, 4 | "server_log_level": "verbose" 5 | } 6 | -------------------------------------------------------------------------------- /config/test-urls.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | // some of the alexa US top 50 sites and subpages 8 | module.exports = [ 9 | { url: 'https://www.google.com/?gws_rd=ssl', 10 | title: 'Google' }, 11 | { url: 'https://www.facebook.com/TheSimpsons', 12 | title: 'The Simpsons | Facebook' }, 13 | { url: 'https://www.facebook.com/TheSimpsons/videos?ref=page_internal', 14 | title: 'The Simpsons - Videos | Facebook' }, 15 | { url: 'https://www.facebook.com/TheSimpsons/photos_stream?ref=page_internal', 16 | title: 'The Simpsons - Photos | Facebook' }, 17 | { url: 'https://www.facebook.com/TheSimpsons/info?ref=page_internal', 18 | title: 'The Simpsons - About | Facebook' }, 19 | { url: 'https://www.facebook.com/TheSimpsons/events?ref=page_internal', 20 | title: 'The Simpsons - Events | Facebook' }, 21 | { url: 'https://www.youtube.com/', 22 | title: 'YouTube' }, 23 | { url: 'https://www.youtube.com/watch?v=kvptRAfYpWM', 24 | title: 'Can - Paperhouse 1971 (Tago Mago) - YouTube' }, 25 | { url: 'https://www.linkedin.com/', 26 | title: 'World\'s Largest Professional Network | LinkedIn' }, 27 | { url: 'http://www.amazon.com/', 28 | title: 'Amazon.com: Online Shopping for Electronics, Apparel, Computers, Books, DVDs & more' }, 29 | { url: 'http://www.amazon.com/s/ref=nb_sb_noss/192-8155327-3884911?url=search-alias%3Daps&field-keywords=silly+string+gun', 30 | title: 'Amazon.com: silly string gun' }, 31 | { url: 'http://www.amazon.com/Silly-Crazy-String-Gun-Blaster/dp/B00EQ8HUHS/ref=sr_1_1?ie=UTF8&qid=1422476757&sr=8-1&keywords=silly+string+gun', 32 | title: 'Amazon.com: Silly Crazy String Gun - Party String Blaster Gun: Toys & Games' }, 33 | { url: 'http://www.amazon.com/s/ref=nb_sb_noss_2?url=search-alias%3Daps&field-keywords=javascript%20', 34 | title: 'Amazon.com: javascript' }, 35 | { url: 'http://www.amazon.com/Professional-JavaScript-Developers-Nicholas-Zakas/dp/1118026691/ref=sr_1_14?ie=UTF8&qid=1422476771&sr=8-14&keywords=javascript', 36 | title: 'Professional JavaScript for Web Developers: Nicholas C. Zakas: 9781118026694: Amazon.com: Books' }, 37 | { url: 'https://search.yahoo.com/yhs/search?p=javascript+books&ei=UTF-8&hspart=mozilla&hsimp=yhs-001', 38 | title: 'javascript books - - Yahoo Search Results' }, 39 | { url: 'http://finance.yahoo.com/', 40 | title: 'Yahoo Finance - Business Finance, Stock Market, Quotes, News' }, 41 | { url: 'http://finance.yahoo.com/q?s=yhoo&fr=uh3_finance_web&uhb=uhb2', 42 | title: 'YHOO: Summary for Yahoo! Inc.- Yahoo! Finance' }, 43 | { url: 'http://en.wikipedia.org/w/index.php?search=javascript+scope&title=Special%3ASearch&go=Go', 44 | title: 'javascript scope - Search results - Wikipedia, the free encyclopedia' }, 45 | { url: 'https://www.linkedin.com/today/author/204068115-Richard-Branson', 46 | title: 'Richard Branson | LinkedIn Influencer | LinkedIn' }, 47 | { url: 'http://en.wikipedia.org/wiki/Troma_Entertainment', 48 | title: 'Troma Entertainment - Wikipedia, the free encyclopedia' }, 49 | { url: 'http://en.wikipedia.org/wiki/JavaScript', 50 | title: 'JavaScript - Wikipedia, the free encyclopedia' }, 51 | { url: 'https://twitter.com/mozilla', 52 | title: 'Mozilla (@mozilla) | Twitter' }, 53 | { url: 'http://t.co/DKiimCLEUR', 54 | title: 'http://twitter.com/mozilla/status/560513225872392192/photo/1' }, 55 | { url: 'http://www.ebay.com/', 56 | title: 'Electronics, Cars, Fashion, Collectibles, Coupons and More | eBay' }, 57 | { url: 'http://www.ebay.com/rpp/sports-mem', 58 | title: 'Sports Memorabilia - Memorabilia, Cards, Autographs & more | eBay' }, 59 | { url: 'http://www.ebay.com/rpp/collectibles-live-auctions', 60 | title: 'eBay | Live Auctions & Events - Premium Auction Houses' }, 61 | { url: 'http://www.reddit.com/r/adviceanimals', 62 | title: 'reddit\'s gold mine.' }, 63 | { url: 'http://www.reddit.com/r/javascript', 64 | title: 'JavaScript' }, 65 | { url: 'http://www.reddit.com/r/javascript/comments/2u00ys/i_have_never_used_javascript_and_have_no_idea/', 66 | title: 'I have never used javascript and have no idea what im doing! Need help tho : javascript' }, 67 | { url: 'https://www.linkedin.com/pulse/discover', 68 | title: 'Pulse Discover | LinkedIn' }, 69 | { url: 'https://www.linkedin.com/in/rbranson?trk=mp-ph-pn', 70 | title: 'Richard Branson | LinkedIn' }, 71 | { url: 'http://sfbay.craigslist.org/search/apa', 72 | title: 'SF bay area apts/housing for rent - craigslist' }, 73 | { url: 'http://sfbay.craigslist.org/search/apa#list', 74 | title: 'SF bay area apts/housing for rent - craigslist' }, 75 | { url: 'http://imgur.com/', 76 | title: 'imgur: the simple image sharer' }, 77 | { url: 'http://imgur.com/gallery/APKFK4i', 78 | title: 'Wind made a double helix in the snow - Imgur' }, 79 | { url: 'http://go.com/', 80 | title: 'Go.com | The Walt Disney Company ' }, 81 | { url: 'http://abcnews.go.com/international', 82 | title: 'International News | World News - ABC News' }, 83 | { url: 'http://abcnews.go.com/topics/news/ukraine.htm', 84 | title: 'Ukraine News, Photos and Videos - ABC News' }, 85 | { url: 'http://abcnews.go.com/International/wireStory/russian-govt-proposes-spending-cuts-view-crisis-28540813', 86 | title: 'Russian Government Details Spending Cuts in View of Crisis - ABC News' }, 87 | { url: 'https://www.tumblr.com/', 88 | title: 'Sign up | Tumblr' }, 89 | { url: 'http://mozillachronicle.tumblr.com/', 90 | title: 'Mozilla Chronicle' }, 91 | { url: 'http://mozillachronicle.tumblr.com/post/104443045652/it-begins', 92 | title: 'Mozilla Chronicle — It Begins' }, 93 | { url: 'https://www.netflix.com/us/', 94 | title: 'Netflix - Watch TV Shows Online, Watch Movies Online' }, 95 | { url: 'https://www.pinterest.com/', 96 | title: 'Pinterest' }, 97 | { url: 'https://www.pinterest.com/explore/most-popular/', 98 | title: 'Most Popular on Pinterest' }, 99 | { url: 'http://espn.go.com/', 100 | title: 'ESPN: The Worldwide Leader In Sports' } 101 | ]; 102 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | ## Chronicle Server API 2 | 3 | TODO: formally document supported HTTP status codes, format of error and success responses, HTTPS cookie requirements, and which requirements are relaxed when developing locally. 4 | 5 | ### Auth API 6 | 7 | Chronicle uses Firefox Accounts to log users in, but manages its own session duration. 8 | 9 | The session cookie is encrypted server-side, so it is not accessible to the client-side code. 10 | 11 | The Firefox Accounts OAuth flow is handled by the server; the front-end just needs to redirect logged-out users to the `/auth/login` endpoint. 12 | 13 | Note, if the `testUser` config option is set, the cookie will be created directly by visiting `/auth/login`, ignoring the OAuth step. 14 | 15 | ### Auth API Methods 16 | 17 | - Start the login flow: `GET /auth/login` 18 | - Logout: `GET /auth/logout` 19 | 20 | ### Visits API 21 | 22 | A `Visit` is a representation of a user's visit to a website at a particular time. Endpoints that return multiple `Visits` return them as simple lists (arrays) of `Visit` objects. 23 | 24 | Example `Visit`: 25 | ```js 26 | { 27 | id: '6134da4b-cc3e-4c62-802b-7da40c5fae44', 28 | url: 'http://awyeah.co', 29 | title: 'aw yesh', 30 | visitedAt: '2015-01-06T19:59:31.808Z' 31 | } 32 | ``` 33 | 34 | #### Visits don't explicitly contain FxA IDs 35 | Note that the `Visit` object returned to a given user never contains that user's Firefox Accounts ID. The client currently consuming this API only makes requests on behalf of a single user per session, and only accesses visits belonging to that user, so the user's Firefox Account ID is never explicitly passed as a property of the `Visit`; it's a property of the session. This may change in the future. 36 | 37 | ### Visit Properties: 38 | - `id`: a visit identifier. 39 | - format: UUIDv4, formatted as a string. 40 | - example: `6134da4b-cc3e-4c62-802b-7da40c5fae44` 41 | - Clients may optionally generate this; if not, it will be provided in the POST response. See notes section below for a discussion of our use of UUIDs. 42 | - `url`: the visited website URL. 43 | - format: string, no max length. 44 | - URLs are not currently canonicalized or normalized by the server. 45 | - `title`: the visited page title. 46 | - format: string, max length is 128 chars. 47 | - `visitedAt`: time of visit. 48 | - format: string in ISO 8601 UTC format with fractional seconds. 49 | - example: `2015-01-06T19:59:31.808Z` 50 | - This is the format returned by `Date.toJSON()` in modern browsers. 51 | - See notes section below for a discussion of our choice of timestamp format. 52 | 53 | ### Visits API Methods 54 | 55 | Note: Partial updates via `PATCH` are not currently supported, but we can add support later to optimize network traffic. 56 | 57 | #### Visit 58 | - Create a new visit: `POST /v1/visits` 59 | - required fields: `url`, `title`, `visitedAt` 60 | - optional fields: `id` (see also [#78](https://github.com/mozilla/chronicle/issues/78)) 61 | - Read a visit: `GET /v1/visits/:visitId` 62 | - Update a visit: `PUT /v1/visits/:visitId` 63 | - required fields: `url`, `title`, `visitedAt` 64 | - Delete a visit: `DELETE /v1/visits/:visitId` 65 | 66 | #### Visits 67 | - Retrieve the 25 newest visits: `GET /v1/visits` 68 | - Retrieve `count` visits older than `visitId`: `GET /v1/visits?count=:count&visitId=:visitId` 69 | - `count` is optional, defaults to 25, max is 100 70 | - currently we only sort from newest to oldest, so there's not (yet) a way to insert newer visits at the top of a view. we can easily add this when we need it. 71 | 72 | ### Search API 73 | 74 | Visits are indexed in elasticsearch for full-text search. Currently terms are only matched against the `title` field. 75 | 76 | #### Request 77 | 78 | - Search for visits containing 'gif': `GET /v1/search?q=gif` 79 | - `q` is a required field, containing the search term 80 | - for the moment, it's only one term per search 81 | - `count` is an optional field: max number of records to return 82 | - defaults to 25 83 | 84 | #### Response 85 | 86 | - JSON object with two keys: 87 | - `resultCount`, the number of search results 88 | - `results`, an array of `Visit` objects 89 | -------------------------------------------------------------------------------- /docs/signoff.md: -------------------------------------------------------------------------------- 1 | # Chronicle Prerelease Deployment Checklist 2 | 3 | Before we hand a build off to ops, we should perform the following checks (at a minimum) to make sure the app is ready to be deployed. 4 | 5 | ## General: 6 | 7 | 1. Clone the git repo locally (`$ git clone git@github.com:mozilla/chronicle.git`) and verify that the code installs in a clean directory, and all the "happy paths" work as expected: 8 | - Sign up 9 | - Sign in 10 | - Create/destroy visits 11 | - Get recent visits in sorted order 12 | - Search returns expected results, sorted by relevance. 13 | 14 | **Note:** In order to to run the project locally, you'll need to have _Node.js_ (0.10.x), _ElasticSearch_, _PostgreSQL_, and _Redis_ already installed. See https://github.com/mozilla/chronicle#large-tools for more details. 15 | 16 | 2. Download and/or compile latest version of [Chronicle Firefox add-on](https://github.com/mozilla/chronicle-addon) (and any other supported browsers). 17 | 3. Verify that browsing using different supported browsers adds all new visits into Chronicle's history. 18 | 19 | ## QA/Ops-ish tasks: 20 | 1. `npm run lint` — make sure there aren't any lint errors in the JavaScript or JSON. 21 | 2. `npm run validate-shrinkwrap` — make sure that current dependencies don't have any unexpected possible vulnerabilities. 22 | 3. `npm run outdated` — check if there are any updates to currently installed _dependencies_ or _devDependencies_. 23 | 4. Make sure the generated JavaScript and CSS are minified, production quality, and include source maps for debugging. 24 | 5. Run the functional tests using Selenium Server. For more information, see https://github.com/mozilla/chronicle#tests. 25 | 26 | ### Optional: 27 | 0. Install the [**npm-check**](https://www.npmjs.com/package/npm-check) module and check for any unused modules in the package.json file. 28 | 1. Run [**scss-lint**](https://github.com/causes/scss-lint) against the Sass files to check for any potential issues; `$ scss-lint app/styles/`. The Sass "rules" are currently located in [/app/styles/.scss-lint.yml](https://github.com/mozilla/chronicle/blob/master/app/styles/.scss-lint.yml). 29 | 2. Run [**grunt-contrib-csslint**](https://www.npmjs.com/package/grunt-contrib-csslint) against the generated CSS files to check for any potential issues in the generated CSS. 30 | 3. Run [**grunt-htmllint**](https://www.npmjs.com/package/grunt-htmllint) against the HTML templates to check for any potential HTML issues. 31 | 32 | **Note:** Currently there are no `grunt csslint` or `grunt htmllint` tasks checked into GitHub, you may need to create these tasks locally. See https://gist.github.com/pdehaan/4584edcfacc7632e60e9 Gist for boilerplate. 33 | 34 | ## Deploying a Release to GitHub 35 | 36 | 1. Bump minor/patch version in /package.json (and /bower.json file). 37 | 2. `grunt changelog` — generate a ./CHANGELOG.md file with the latest changes. 38 | 3. `grunt contributors` — generate a ./AUTHORS file with the latest list of project contributors. 39 | 4. Create a new release/tag in GitHub with the generated CHANGELOG.md and AUTHORS files. 40 | 41 | **Note:** See **fxa-content-server**'s [/grunttasks/bump.js](https://github.com/mozilla/fxa-content-server/blob/master/grunttasks/bump.js) and [/grunttasks/version.js](https://github.com/mozilla/fxa-content-server/blob/master/grunttasks/version.js) tasks for a possibly automated way of releasing builds using the [**grunt-bump**](https://github.com/vojtajina/grunt-bump) package. 42 | -------------------------------------------------------------------------------- /grunttasks/autoprefixer.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('autoprefixer', { 9 | dist: { 10 | src: '<%= staticPath %>/styles/compiled.css', 11 | dest: '<%= staticPath %>/styles/compiled.css', 12 | options: { 13 | map: true 14 | } 15 | } 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /grunttasks/build.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.registerTask('build', 'Build front-end assets and copy them to `staticPath`', function (target) { 9 | if (!target) { 10 | target = 'development'; 11 | } 12 | 13 | var taskArr; 14 | 15 | if (target === 'development') { 16 | taskArr = [ 17 | 'clean', 18 | 'copy', 19 | 'css', 20 | 'requirejs:development', 21 | 'template' 22 | ]; 23 | } else { 24 | taskArr = [ 25 | 'lint', 26 | 'clean', 27 | 'copy', 28 | 'css', 29 | 'requirejs:' + target, 30 | 'template', 31 | 'useminPrepare', 32 | 'rev', 33 | 'usemin' 34 | ]; 35 | } 36 | 37 | grunt.task.run(taskArr); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /grunttasks/changelog.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('changelog', { 9 | dest: './CHANGELOG.md' 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /grunttasks/clean.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('clean', { 9 | dist: [ 10 | '!<%= staticPath %>/.gitignore', 11 | '<%= staticPath %>/*' 12 | ] 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /grunttasks/contributors.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('contributors', { 9 | app: { 10 | path: './AUTHORS', 11 | branch: true 12 | } 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /grunttasks/copy.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('copy', { 9 | all: { 10 | files: [ 11 | { 12 | cwd: 'app/', 13 | dest: '<%= staticPath %>', 14 | expand: true, 15 | src: [ 16 | '*.html', 17 | 'images/**/*.{gif,jpeg,jpg,png,svg}' 18 | ] 19 | } 20 | ] 21 | } 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /grunttasks/copyright.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('copyright', { 9 | options: { 10 | pattern: /This Source Code Form is subject to the terms of the Mozilla Public/ 11 | }, 12 | app: { 13 | src: [ 14 | '{,app/**/,bin/**/,grunttasks/**/,server/**/,tests/**/}*.js', 15 | '!app/bower_components/**' 16 | ] 17 | } 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /grunttasks/css.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.registerTask('css', [ 9 | 'sass', 10 | 'autoprefixer' 11 | ]); 12 | }; 13 | -------------------------------------------------------------------------------- /grunttasks/default.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.registerTask('default', ['build']); 9 | }; 10 | -------------------------------------------------------------------------------- /grunttasks/hapi.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('hapi', { 9 | custom_options: { 10 | options: { 11 | server: require('path').resolve('./server/index'), 12 | bases: {} 13 | } 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /grunttasks/intern.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('intern', { 9 | all: { 10 | options: { 11 | runType: 'runner', 12 | config: 'tests/intern' 13 | } 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /grunttasks/jscs.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('jscs', { 9 | options: { 10 | config: '.jscsrc' 11 | }, 12 | app: { 13 | src: [ 14 | '{,app/**/,bin/**/,grunttasks/**/,server/**/,tests/**/}*.js', 15 | '!app/bower_components/**' 16 | ] 17 | } 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /grunttasks/jshint.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | var stylish = require('jshint-stylish'); 9 | 10 | grunt.config('jshint', { 11 | options: { 12 | jshintrc: '.jshintrc', 13 | reporter: stylish 14 | }, 15 | app: { 16 | src: [ 17 | '{,app/**/,bin/**/,grunttasks/**/,server/**/,tests/**/}*.js', 18 | '!app/bower_components/**' 19 | ] 20 | } 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /grunttasks/jsonlint.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('jsonlint', { 9 | app: { 10 | src: [ 11 | '{.bowerrc,.jshintrc,.jscsrc}', 12 | '{,app/**/,bin/**/,grunttasks/**/,server/**/,tests/**/}*.json', 13 | 'config/*.{json,example}', 14 | '!app/bower_components/**' 15 | ] 16 | } 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /grunttasks/lint.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | var SUBTASKS = [ 9 | 'jshint', 10 | 'jscs', 11 | 'jsonlint', 12 | 'copyright' 13 | ]; 14 | 15 | grunt.registerTask('lint', SUBTASKS); 16 | grunt.registerTask('quicklint', SUBTASKS.map(function (task) { 17 | return 'newer:' + task; 18 | })); 19 | }; 20 | -------------------------------------------------------------------------------- /grunttasks/requirejs.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var _ = require('underscore'); 6 | 7 | module.exports = function (grunt) { 8 | 'use strict'; 9 | 10 | var DEFAULT_OPTIONS = { 11 | almond: true, 12 | baseUrl: 'app/scripts', 13 | generateSourceMaps: true, 14 | mainConfigFile: 'app/scripts/main.js', 15 | name: 'main', 16 | optimize: 'none', 17 | out: '<%= staticPath %>/scripts/compiled.js', 18 | preserveLicenseComments: false, 19 | removeCombined: true, 20 | replaceRequireScript: [{ 21 | files: ['<%= staticPath %>/*.html'], 22 | module: 'main', 23 | modulePath: 'scripts/compiled' 24 | }], 25 | stubModules: ['text', 'stache'], 26 | useStrict: true 27 | }; 28 | 29 | grunt.config('requirejs', { 30 | development: { 31 | options: DEFAULT_OPTIONS 32 | }, 33 | production: { 34 | options: _.extend({}, DEFAULT_OPTIONS, { optimize: 'uglify2' }) 35 | } 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /grunttasks/rev.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('rev', { 9 | app: { 10 | files: { 11 | src: [ 12 | '<%= staticPath %>/scripts/*.js', 13 | '<%= staticPath %>/styles/*.css', 14 | '<%= staticPath %>/images/*.{gif,jpeg,jpg,png,svg}' 15 | ] 16 | } 17 | } 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /grunttasks/sass.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | var config = require('../server/config'); 9 | 10 | grunt.config('sass', { 11 | options: { 12 | imagePath: '/images', 13 | outputStyle: config.get('sass_outputStyle'), 14 | precision: 3, 15 | sourceMap: true 16 | }, 17 | styles: { 18 | files: [{ 19 | src: ['app/styles/main.scss'], 20 | dest: '<%= staticPath %>/styles/compiled.css' 21 | }] 22 | } 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /grunttasks/serve.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.registerTask('serve', 'Builds the dist assets, starts the Hapi server, and watches for changes.', function () { 9 | grunt.task.run([ 10 | 'build:development', 11 | 'hapi', 12 | 'watch' 13 | ]); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /grunttasks/template.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | var config = require('../server/config'); 9 | 10 | // Add custom delimiters since our javascript has both mustache and underscore delimiters embedded 11 | grunt.template.addDelimiters('js-config-delimiters', '{%', '%}'); 12 | 13 | grunt.config('template', { 14 | scripts: { 15 | options: { 16 | data: { 17 | config: config 18 | }, 19 | delimiters: 'js-config-delimiters' 20 | }, 21 | files: { 22 | '<%= staticPath %>/scripts/compiled.js': ['<%= staticPath %>/scripts/compiled.js'] 23 | } 24 | } 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /grunttasks/test.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.registerTask('test', ['build', 'hapi', 'intern']); 9 | }; 10 | -------------------------------------------------------------------------------- /grunttasks/todo.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('todo', { 9 | app: { 10 | files: { 11 | src: [ 12 | 'app/**/*.{js,css,scss,html}', 13 | '!app/bower_components/**' 14 | ] 15 | } 16 | }, 17 | grunttasks: { 18 | files: { 19 | src: [ 20 | 'grunttasks/*.js', 21 | '!grunttasks/todo.js' 22 | ] 23 | } 24 | }, 25 | server: { 26 | files: { 27 | src: [ 28 | 'server/**/*.js' 29 | ] 30 | } 31 | } 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /grunttasks/usemin.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('useminPrepare', { 9 | app: { 10 | src: [ 11 | '<%= staticPath %>/*.html' 12 | ], 13 | dest: '<%= staticPath %>', 14 | type: 'html' 15 | } 16 | }); 17 | 18 | grunt.config('usemin', { 19 | options: { 20 | assetsDirs: [ 21 | '<%= staticPath %>' 22 | ], 23 | patterns: { 24 | js: [ 25 | [/(\/images\/.*?\.png)/gm, 'Update the JS to reference revved images'] 26 | ] 27 | } 28 | }, 29 | css: ['<%= staticPath %>/styles/*.css'], 30 | html: ['<%= staticPath %>/*.html'], 31 | js: ['<%= staticPath %>/scripts/*.js'] 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /grunttasks/validate-shrinkwrap.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('validate-shrinkwrap', { 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /grunttasks/watch.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | module.exports = function (grunt) { 6 | 'use strict'; 7 | 8 | grunt.config('watch', { 9 | config: { 10 | files: ['Gruntfile.js', 'grunttasks/*'], 11 | tasks: ['build:development'], 12 | options: { 13 | reload: true 14 | } 15 | }, 16 | hapi: { 17 | files: ['server/**'], 18 | tasks: ['hapi'], 19 | options: { 20 | spawn: false 21 | } 22 | }, 23 | html: { 24 | files: ['app/*.html'], 25 | tasks: ['copy'] 26 | }, 27 | images: { 28 | files: ['app/images/**/*.{gif,jpeg,jpg,png,svg}'], 29 | tasks: ['copy'] 30 | }, 31 | livereload: { 32 | // Watch for file changes in dist to trigger livereload 33 | files: ['<%= staticPath %>/**'], 34 | options: { 35 | livereload: true 36 | } 37 | }, 38 | scripts: { 39 | files: ['app/scripts/**'], 40 | tasks: ['lint', 'requirejs:development', 'template'] 41 | }, 42 | styles: { 43 | files: ['app/styles/**'], 44 | tasks: ['css'] 45 | } 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mozilla-chronicle", 3 | "description": "This storm is what we call progress", 4 | "version": "0.1.0", 5 | "author": "Mozilla (https://mozilla.org)", 6 | "bugs": { 7 | "url": "https://github.com/mozilla/chronicle/issues" 8 | }, 9 | "dependencies": { 10 | "bell": "2.1.0", 11 | "boom": "2.6.1", 12 | "commander": "2.6.0", 13 | "convict": "0.6.1", 14 | "elasticsearch": "3.1.3", 15 | "embedly": "1.0.4", 16 | "hapi": "8.2.0", 17 | "hapi-auth-cookie": "2.0.0", 18 | "joi": "5.1.0", 19 | "kue": "0.8.11", 20 | "mozlog": "1.0.2", 21 | "nodemailer": "1.3.0", 22 | "nodemailer-smtp-transport": "0.1.13", 23 | "pg": "4.2.0", 24 | "pg-patcher": "0.3.0", 25 | "q": "1.1.2", 26 | "redis": "0.12.1", 27 | "underscore": "1.7.0", 28 | "underscore.string": "3.0.3", 29 | "url2png": "6.0.2", 30 | "uuid": "2.0.1" 31 | }, 32 | "devDependencies": { 33 | "grunt-autoprefixer": "2.2.0", 34 | "grunt-contrib-clean": "0.6.0", 35 | "grunt-contrib-copy": "0.7.0", 36 | "grunt-contrib-jshint": "0.11.0", 37 | "grunt-contrib-watch": "0.6.1", 38 | "grunt-conventional-changelog": "1.1.0", 39 | "grunt-copyright": "0.1.0", 40 | "grunt-git-contributors": "0.1.5", 41 | "grunt-hapi": "0.9.1", 42 | "grunt-jscs": "1.5.0", 43 | "grunt-jsonlint": "1.0.4", 44 | "grunt-newer": "1.1.0", 45 | "grunt-nsp-shrinkwrap": "0.0.3", 46 | "grunt-requirejs": "0.4.2", 47 | "grunt-rev": "0.1.0", 48 | "grunt-sass": "0.18.0", 49 | "grunt-template": "0.2.3", 50 | "grunt-todo": "0.4.0", 51 | "grunt-usemin": "3.0.0", 52 | "husky": "0.6.2", 53 | "intern": "2.2.2", 54 | "jshint-stylish": "1.0.0", 55 | "load-grunt-tasks": "3.1.0", 56 | "lout": "6.2.0" 57 | }, 58 | "engines": { 59 | "node": ">=0.10.33" 60 | }, 61 | "homepage": "https://github.com/mozilla/chronicle", 62 | "license": "MPL 2.0", 63 | "main": "index.js", 64 | "repository": { 65 | "type": "git", 66 | "url": "git://github.com/mozilla/chronicle.git" 67 | }, 68 | "scripts": { 69 | "authors": "grunt contributors", 70 | "clean": "rm -rf ./node_modules app/bower_components public && npm i --silent", 71 | "lint": "grunt lint", 72 | "outdated": "npm outdated --depth 0", 73 | "postinstall": "bower update --config.interactive=false -s", 74 | "prepush": "grunt quicklint", 75 | "scss-lint": "scss-lint app/styles || true", 76 | "shrinkwrap": "npm shrinkwrap --dev && npm run validate-shrinkwrap", 77 | "start": "grunt serve", 78 | "test": "echo \"Error: no test specified\"", 79 | "validate-shrinkwrap": "grunt validate-shrinkwrap --force" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /server/bell-oauth-profile.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var log = require('./logger')('server.bell.profile'); 8 | var user = require('./models/user'); 9 | var config = require('./config'); 10 | var queue = require('./work-queue/queue'); 11 | var allowedUsers = config.get('alpha_allowedUsers'); 12 | 13 | // this is the custom provider profile function used by Bell to allow us 14 | // to convert oauth tokens into profile information. 15 | // TODO `profileCb`, as defined by Bell, doesn't seem to take errors in the 16 | // callback. So we will just throw errors from here :-\ 17 | function profile (credentials, params, get, profileCb) { 18 | log.verbose('obtained oauth tokens: ' + JSON.stringify(credentials)); 19 | var headers = { headers: {'authorization': 'Bearer ' + params.access_token} }; 20 | get(config.get('server_oauth_profileEndpoint'), headers, function(data) { 21 | // Bell returns the parsed data and handles errors internally 22 | log.verbose('exchanged tokens for profile data:' + JSON.stringify(data)); 23 | 24 | // XXX temporary while we're in alpha: only allow whitelisted users, but let 25 | // the controller handle the error; Bell seems to lock us into 500ing. 26 | if (allowedUsers.indexOf(data.email) === -1) { 27 | log.warn('Non-whitelisted user attempted to log in: ' + data.email); 28 | credentials.profile = {email: data.email, userId: null, isAllowed: false}; 29 | return profileCb(credentials); 30 | } 31 | 32 | // TODO use Joi to validate `data` before sending to DB 33 | user.get(data.uid, function (err, result) { 34 | if (err) { 35 | log.warn('user.get failed: ' + err); 36 | throw err; 37 | } 38 | if (result) { 39 | log.verbose('user exists! updating oauth token and setting session cookie...'); 40 | user.update(data.uid, data.email, params.access_token, function(err) { 41 | if (err) { 42 | log.warn('user.update failed: ' + err); 43 | throw err; 44 | } 45 | // finally, set the cookie and redirect. 46 | log.verbose('logged in existing user ' + data.email); 47 | credentials.profile = {userId: data.uid, isAllowed: true}; 48 | return profileCb(credentials); 49 | }); 50 | } else { 51 | log.verbose('new user! creating record and setting session cookie...'); 52 | user.create(data.uid, data.email, params.access_token, function(err) { 53 | if (err) { 54 | log.warn('user.create failed: ' + err); 55 | throw err; 56 | } 57 | log.verbose('created new user ' + data.email); 58 | credentials.profile = {userId: data.uid, isNewUser: true, isAllowed: true}; 59 | // FIX: This doesn't appear to be here. Disabling for now. 60 | //queue.sendWelcomeEmail({email: data.email}); 61 | return profileCb(credentials); 62 | }); 63 | } 64 | }); 65 | }); 66 | } 67 | module.exports = profile; 68 | -------------------------------------------------------------------------------- /server/controllers/auth.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Boom = require('boom'); 8 | var log = require('../logger')('server.controllers.auth'); 9 | var config = require('../config'); 10 | 11 | var authController = { 12 | login: function(request, reply) { 13 | // If we are in testUser mode, just set the cookie and skip the oauth part 14 | if (config.get('testUser_enabled')) { 15 | log.verbose('testUser enabled; creating session'); 16 | request.auth.session.set({userId: config.get('testUser_id')}); 17 | return reply.redirect('/'); 18 | } 19 | 20 | // Bell uses the same endpoint for both the start and redirect 21 | // steps in the flow. Let's keep this as the initial endpoint, 22 | // so the API is easy to read, and just redirect the user. 23 | log.verbose('request.auth.isAuthenticated is ' + request.auth.isAuthenticated); 24 | var endpoint = request.auth.isAuthenticated ? '/' : '/auth/complete'; 25 | reply.redirect(endpoint); 26 | }, 27 | logout: function (request, reply) { 28 | if (request.auth.isAuthenticated) { 29 | request.auth.session.clear(); 30 | } 31 | return reply.redirect('/'); 32 | }, 33 | complete: function (request, reply) { 34 | // Bell has obtained the oauth token, used it to get the profile, and 35 | // that profile is available as request.auth.credentials.profile. 36 | // Now, use it to set a cookie. 37 | log.verbose('inside auth/complete'); 38 | log.verbose('request.auth.credentials is ' + JSON.stringify(request.auth.credentials)); 39 | 40 | // XXX temporary while we're in alpha: only allow whitelisted users, show 41 | // a friendly error message if they're not on the list. 42 | // TODO: on the front-end, auto-populate their email in a "want us to email you when 43 | // we are adding new users?" form 44 | if (!request.auth.credentials.profile.isAllowed) { 45 | return reply(Boom.create(401, 'Sorry, only whitelisted users are allowed at this time.')); 46 | } 47 | 48 | var session = { 49 | userId: request.auth.credentials.profile.userId 50 | }; 51 | var duration = config.get('server_session_duration'); 52 | if (duration > 0) { 53 | session.expiresAt = new Date(new Date().getTime() + duration); 54 | } 55 | request.auth.session.set(session); 56 | if (request.auth.credentials.profile.isNewUser) { 57 | reply.redirect('/#welcome'); 58 | } else { 59 | reply.redirect('/'); 60 | } 61 | 62 | } 63 | }; 64 | 65 | module.exports = authController; 66 | -------------------------------------------------------------------------------- /server/controllers/base.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var fs = require('fs'); 8 | var path = require('path'); 9 | var config = require('../config'); 10 | var STATIC_PATH = path.join(__dirname, '..', '..', config.get('server_staticPath')); 11 | 12 | var baseController = { 13 | get: function (request, reply) { 14 | var page = request.auth.isAuthenticated ? 'app.html' : 'index.html'; 15 | 16 | fs.readFile(path.join(STATIC_PATH, page), 'utf8', function (err, data) { 17 | if (err) { 18 | throw err; 19 | } 20 | 21 | reply(data); 22 | }); 23 | } 24 | }; 25 | 26 | module.exports = baseController; 27 | -------------------------------------------------------------------------------- /server/controllers/profile.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var crypto = require('crypto'); 8 | var Boom = require('boom'); 9 | var config = require('../config'); 10 | var log = require('../logger')('server.controllers.visit'); 11 | var user = require('../models/user'); 12 | 13 | var profileController = { 14 | get: function (request, reply) { 15 | var userId = request.auth.credentials; 16 | user.get(userId, function(err, data) { 17 | if (err) { 18 | log.warn(err); 19 | return reply(Boom.create(500)); 20 | } 21 | // TODO tell a view to transform, then reply, with the data 22 | var emailHash = crypto.createHash('md5').update(data.email).digest('hex'); 23 | var out = { 24 | email: data.email, 25 | avatarUrl: config.get('avatarUrl') + emailHash + '?s=64' 26 | }; 27 | reply(out); 28 | }); 29 | } 30 | }; 31 | 32 | module.exports = profileController; 33 | -------------------------------------------------------------------------------- /server/controllers/search.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Boom = require('boom'); 8 | 9 | var log = require('../logger')('server.controllers.search'); 10 | 11 | // hmm, should this just be a method on the visits controller? 12 | var visits = require('../models/visits'); 13 | var searchView = require('../views/search'); 14 | 15 | var searchController = { 16 | get: function (request, reply) { 17 | var userId = request.auth.credentials; 18 | var searchTerm = request.query.q; 19 | var maxResults = request.query.count; 20 | visits.search(userId, searchTerm, maxResults, function (err, results) { 21 | var output; 22 | if (err) { 23 | log.warn(err); 24 | return reply(Boom.create(500)); 25 | } 26 | if (results && results.results) { 27 | output = searchView.render(results); 28 | } 29 | reply(output || results); 30 | }); 31 | } 32 | }; 33 | 34 | module.exports = searchController; 35 | -------------------------------------------------------------------------------- /server/controllers/ver.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** 6 | * Return version info based on package.json and the git hash 7 | * 8 | * We figure out the Git hash in the following order: 9 | * 10 | * (1) read config/version.json if exists (ie. staging, production) 11 | * (2) figure it out from git (either regular '.git', or 12 | * '/home/app/git' for AwsBox) 13 | */ 14 | 15 | 'use strict'; 16 | 17 | var fs = require('fs'); 18 | var path = require('path'); 19 | var util = require('util'); 20 | var exec = require('child_process').exec; 21 | 22 | var Q = require('q'); 23 | var logger = require('mozlog')('server.ver.json'); 24 | 25 | var version = require('../../package.json').version; 26 | var promise; 27 | 28 | function getGitDir() { 29 | if (!fs.existsSync(path.join(__dirname, '..', '..', '.git'))) { 30 | // try at '/home/app/git' for AwsBox deploys 31 | return path.sep + path.join('home', 'app', 'git'); 32 | } 33 | } 34 | 35 | function getCommitHashFromGit() { 36 | var deferred = Q.defer(); 37 | 38 | var gitDir = getGitDir(); 39 | var cmd = util.format('git %s rev-parse HEAD', gitDir ? '--git-dir=' + gitDir : ''); 40 | 41 | exec(cmd, function (err, stdout) { 42 | deferred.resolve(stdout.replace(/\s+/, '')); 43 | }); 44 | 45 | return deferred.promise; 46 | } 47 | 48 | function getCommitHashFromVersionJson() { 49 | return Q.fcall(function () { 50 | var configFile = path.join(__dirname, '..', '..', '..', 'config', 'version.json'); 51 | if (fs.existsSync(configFile)) { 52 | var commitHash; 53 | try { 54 | commitHash = require(configFile).version.hash; 55 | } catch (e) { 56 | logger.error('could not read version.hash from version.json'); 57 | } 58 | return commitHash; 59 | } 60 | }); 61 | } 62 | 63 | function getVersionInfo() { 64 | // only resolve once, the data does not need to be re-calculated. 65 | if (promise) { 66 | return promise; 67 | } 68 | 69 | // (1) read config/version.json if exists (ie. staging, production) 70 | promise = getCommitHashFromVersionJson() 71 | .then(function (commitHash) { 72 | if (commitHash) { 73 | return commitHash; 74 | } 75 | // (2) figure it out from git (either regular '.git', 76 | // or '/home/app/git' for AwsBox) 77 | return getCommitHashFromGit(); 78 | }) 79 | .then(function (commitHash) { 80 | logger.info('version set to: %s', version); 81 | logger.info('commit hash set to: %s', commitHash); 82 | return { 83 | version: version, 84 | commit: commitHash 85 | }; 86 | }); 87 | 88 | return promise; 89 | } 90 | 91 | // seed the info on startup. 92 | getVersionInfo(); 93 | 94 | var verJsonController = { 95 | get: function (request, reply) { 96 | getVersionInfo().then(function (versionInfo) { 97 | // charset must be set on json responses. 98 | reply(versionInfo); 99 | }); 100 | } 101 | }; 102 | 103 | module.exports = verJsonController; 104 | -------------------------------------------------------------------------------- /server/controllers/visit.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Boom = require('boom'); 8 | 9 | var config = require('../config'); 10 | var log = require('../logger')('server.controllers.visit'); 11 | var visit = require('../models/visit'); 12 | var visitView = require('../views/visit'); 13 | 14 | // TODO when we turn this into a real instantiable object, set req, reply as instance vars 15 | var visitController = { 16 | get: function (request, reply) { 17 | var userId = request.auth.credentials; 18 | visit.get(userId, request.params.visitId, function(err, result) { 19 | if (err) { 20 | log.warn(err); 21 | return reply(Boom.create(500)); 22 | } 23 | if (!result) { 24 | return reply(Boom.create(404, 'Visit not found')); 25 | } else { 26 | reply(visitView.render(result)); 27 | } 28 | }); 29 | }, 30 | put: function(request, reply) { 31 | var userId = request.auth.credentials; 32 | var visitId = request.params.visitId; 33 | var p = request.payload; 34 | visit.update(userId, visitId, p.visitedAt, p.url, p.title, function(err, result) { 35 | if (err) { 36 | log.warn(err); 37 | return reply(Boom.create(500)); 38 | } 39 | // return the visit so backbone can update the model 40 | reply(visitView.render(result)); 41 | }); 42 | }, 43 | delete: function (request, reply) { 44 | var userId = request.auth.credentials; 45 | visit.delete(userId, request.params.visitId, function(err) { 46 | if (err) { 47 | log.warn(err); 48 | return reply(Boom.create(500)); 49 | } 50 | reply(); 51 | }); 52 | } 53 | }; 54 | 55 | module.exports = visitController; 56 | -------------------------------------------------------------------------------- /server/controllers/visits.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var crypto = require('crypto'); 8 | var Boom = require('boom'); 9 | var uuid = require('uuid'); 10 | 11 | var config = require('../config'); 12 | var log = require('../logger')('server.controllers.visits'); 13 | var queue = require('../work-queue/queue'); 14 | var visits = require('../models/visits'); 15 | var visitsView = require('../views/visits'); 16 | 17 | var visitsController = { 18 | get: function (request, reply) { 19 | var userId = request.auth.credentials; 20 | var visitId = request.query.visitId; 21 | 22 | function onResults(err, results) { 23 | if (err) { 24 | log.warn(err); 25 | return reply(Boom.create(500)); // TODO distinguish between 4xx and 5xx? 26 | } 27 | if (!results) { 28 | return reply(Boom.create(404)); // not found 29 | } 30 | // for each visit in results, add the screenshot url 31 | reply(visitsView.render(results)); 32 | } 33 | 34 | // if there's a visitId provided, then we want a specific page 35 | if (visitId) { 36 | visits.getPaginated(userId, visitId, request.query.count, onResults); 37 | } else { 38 | visits.get(userId, request.query.count, onResults); 39 | } 40 | }, 41 | // moving this into visits (plural) because we're going to support multiple 42 | // uploads from this same endpoint 43 | // TODO handle multiple uploads :-) 44 | post: function(request, reply) { 45 | var p = request.payload; 46 | var userId = request.auth.credentials; 47 | var visitId = p.visitId || uuid.v4(); 48 | var urlHash = crypto.createHash('sha1').update(p.url).digest('hex').toString(); 49 | var o = { 50 | userId: userId, 51 | visitId: visitId, 52 | url: p.url, 53 | urlHash: urlHash, 54 | title: p.title, 55 | visitedAt: p.visitedAt 56 | }; 57 | queue.createVisit(o); 58 | if (config.get('embedly_enabled')) { 59 | // extractPage doesn't need all these keys, but the extras won't hurt anything 60 | // XXX the extractPage job checks if the user_page has been scraped recently 61 | queue.extractPage(o); 62 | } else { 63 | log.info('not extracting page because embedly is disabled'); 64 | } 65 | reply(visitsView.render({ 66 | id: visitId, 67 | url: p.url, 68 | urlHash: urlHash, 69 | title: p.title, 70 | visitedAt: p.visitedAt 71 | })); 72 | } 73 | }; 74 | 75 | module.exports = visitsController; 76 | -------------------------------------------------------------------------------- /server/db/elasticsearch.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | // TODO we should probably remove the elasticsearch-js library, it is garbage 8 | var es = require('elasticsearch'); 9 | var Q = require('q'); 10 | 11 | var config = require('../config'); 12 | var log = require('../logger')('server.db.elasticsearch'); 13 | var logLevel = config.get('server_log_level'); 14 | 15 | // elasticsearch doesn't have a verbose level, so shift to debug as needed 16 | if (logLevel === 'verbose') { logLevel = 'debug'; } 17 | 18 | // >:-( 19 | // https://github.com/elasticsearch/elasticsearch-js/issues/33 20 | var esParamsFactory = function() { 21 | return { 22 | host: { 23 | host: config.get('db_elasticsearch_host'), 24 | port: config.get('db_elasticsearch_port') 25 | }, 26 | log: logLevel, 27 | // doesn't seem to actually disable keepalives, just allows client to 28 | // close when http times out (15 sec). if not disabled, the process 29 | // will hang forever and have to be manually killed! 30 | keepAlive: false 31 | }; 32 | }; 33 | 34 | var esClient = new es.Client(esParamsFactory()); 35 | var timeout = config.get('db_elasticsearch_queryTimeout'); 36 | 37 | var elasticsearch = { 38 | // query elasticsearch; returns a promise 39 | // 40 | // queryType := es client API method, for example, 'create' 41 | // params := es query DSL object 42 | query: function query(queryType, params) { 43 | // force promises to eventually resolve 44 | // NOTE: I checked, elasticsearch uses bluebird, which supports Promise.timeout ^_^ 45 | return esClient[queryType](params).timeout(timeout, 'elasticsearch timed out'); 46 | } 47 | }; 48 | 49 | module.exports = elasticsearch; 50 | -------------------------------------------------------------------------------- /server/db/migrations/patch-0-1.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE property ( 2 | key TEXT PRIMARY KEY, 3 | value TEXT 4 | ); 5 | INSERT INTO property(key, value) VALUES('patch', 1); 6 | 7 | -------------------------------------------------------------------------------- /server/db/migrations/patch-1-0.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE property; 2 | -------------------------------------------------------------------------------- /server/db/migrations/patch-1-2.sql: -------------------------------------------------------------------------------- 1 | -- create the users table 2 | CREATE TABLE IF NOT EXISTS users ( 3 | user_id CHAR(32) PRIMARY KEY, 4 | email VARCHAR(255) NOT NULL, 5 | oauth_token TEXT, 6 | created_at TIMESTAMPTZ(3) NOT NULL, 7 | updated_at TIMESTAMPTZ(3) NOT NULL 8 | ); 9 | -- create the user_pages table 10 | CREATE TABLE IF NOT EXISTS user_pages ( 11 | id UUID PRIMARY KEY, 12 | user_id CHAR(32) REFERENCES users(user_id), 13 | url VARCHAR(2048) NOT NULL, 14 | raw_url VARCHAR(2048) NOT NULL, 15 | url_hash CHAR(40) NOT NULL, 16 | title TEXT NOT NULL, 17 | extracted_data JSON, 18 | created_at TIMESTAMPTZ(3) NOT NULL, 19 | updated_at TIMESTAMPTZ(3) NOT NULL 20 | ); 21 | -- create the user_pages index 22 | CREATE UNIQUE INDEX user_pages_url_hash_user_id 23 | ON user_pages (url_hash, user_id); 24 | -- create the visits table 25 | CREATE TABLE IF NOT EXISTS visits ( 26 | id UUID PRIMARY KEY, 27 | user_id CHAR(32) NOT NULL REFERENCES users, 28 | user_page_id UUID NOT NULL REFERENCES user_pages(id), 29 | visited_at TIMESTAMPTZ(3) NOT NULL, 30 | updated_at TIMESTAMPTZ(3) NOT NULL 31 | ); 32 | -- create the visits indexes 33 | -- used for visit.get 34 | CREATE UNIQUE INDEX visits_user_id_visited_at_id 35 | ON visits (user_id, visited_at, id); 36 | -- used to check if a user_page should be deleted on visit delete 37 | CREATE UNIQUE INDEX visits_user_id_user_page_id_id 38 | ON visits (user_id, user_page_id, id); 39 | -- used for batch reindexing based on creation time 40 | CREATE INDEX user_pages_created_at ON user_pages (created_at); 41 | -------------------------------------------------------------------------------- /server/db/migrations/patch-2-1.sql: -------------------------------------------------------------------------------- 1 | -- drop the visits table 2 | DROP TABLE visits; 3 | -- drop the user_pages table 4 | DROP TABLE user_pages; 5 | -- drop the users table 6 | DROP TABLE users; 7 | -------------------------------------------------------------------------------- /server/db/migrator.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var fs = require('fs'); 8 | var path = require('path'); 9 | var Joi = require('joi'); 10 | var pg = require('pg'); 11 | var pgpatcher = require('pg-patcher'); 12 | var q = require('q'); 13 | 14 | var config = require('../config'); 15 | var log = require('../logger')('server.db.migrator'); 16 | var reindex = require('./reindex'); 17 | 18 | var migrator = function (patchLevel) { 19 | log.debug('migrator called, patchLevel is ' + patchLevel); 20 | var deferred = q.defer(); 21 | // Joi handles input validation and string -> int coercion for us 22 | var result = Joi.validate(patchLevel, Joi.number().integer().required()); 23 | var level = result.value; 24 | if (result.error) { 25 | log.error('migrator failed, arguments failed to validate: ' + result.error); 26 | deferred.reject(result.error); 27 | return deferred.promise; 28 | } 29 | 30 | var dbParams = { 31 | user: config.get('db_postgres_user'), 32 | password: config.get('db_postgres_password'), 33 | host: config.get('db_postgres_host'), 34 | port: config.get('db_postgres_port'), 35 | database: config.get('db_postgres_database'), 36 | ssl: config.get('db_postgres_ssl') 37 | }; 38 | 39 | pg.connect(dbParams, function onConnect(err, client, done) { 40 | if (err) { 41 | log.warn('failed to connect to pg'); 42 | log.trace(err); 43 | deferred.reject(err); 44 | } else { 45 | pgpatcher(client, level, {dir: path.join(__dirname, 'migrations')}, function onPatched(err, result) { 46 | log.debug('pgpatcher callback fired'); 47 | if (err) { 48 | log.error('pgpatcher migration failed: ' + err); 49 | done(); 50 | pg.end(); 51 | return deferred.reject(err); 52 | } 53 | log.info('pgpatcher migration succeeded'); 54 | // at patchLevel 0 and 1, there is no user_pages table to reindex 55 | if (patchLevel < 2) { 56 | done(); 57 | pg.end(); 58 | return deferred.resolve(result); 59 | } 60 | reindex.start(function(err) { 61 | if (err) { 62 | log.error('elasticsearch reindexing failed: ' + err); 63 | done(); 64 | pg.end(); 65 | return deferred.reject(err); 66 | } 67 | log.info('elasticsearch reindexing succeeded'); 68 | done(); 69 | pg.end(); 70 | deferred.resolve(result); 71 | }); 72 | }); 73 | } 74 | }); 75 | return deferred.promise; 76 | }; 77 | 78 | module.exports = migrator; 79 | -------------------------------------------------------------------------------- /server/db/postgres.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var pg = require('pg'); 8 | var Q = require('q'); 9 | var camelize = require('underscore.string').camelize; 10 | 11 | var config = require('../config'); 12 | var log = require('../logger')('server.db.postgres'); 13 | 14 | var dbParams = { 15 | user: config.get('db_postgres_user'), 16 | password: config.get('db_postgres_password'), 17 | host: config.get('db_postgres_host'), 18 | port: config.get('db_postgres_port'), 19 | database: config.get('db_postgres_database'), 20 | ssl: config.get('db_postgres_ssl') 21 | }; 22 | 23 | // primitive DB object 24 | var postgres = { 25 | timeout: config.get('db_postgres_queryTimeout'), 26 | // postgres is case-insensitive, so we store keys as underscored there, 27 | // and transform back to camelCase when returning results here. 28 | // individual queries must be sure to use the underscored versions. 29 | // TODO replace with utils.camelize 30 | camelize: function(rows) { 31 | var outputRows = []; 32 | var output; 33 | rows.forEach(function(row) { 34 | output = {}; 35 | Object.keys(row).forEach(function(k) { 36 | output[camelize(k)] = row[k]; 37 | }); 38 | // special case: json extracted_data blob 39 | if (row.extracted_data) { 40 | Object.keys(row.extracted_data).forEach(function(key) { 41 | output.extractedData[camelize(key)] = row.extracted_data[key]; 42 | }); 43 | } 44 | outputRows.push(output); 45 | }); 46 | return outputRows; 47 | }, 48 | // query postgres; returns a promise 49 | // 50 | // query := a prepared query string 51 | // params := an array of the query params in correct order 52 | // noCamel := optional argument; if true, records are returned without being camelized, 53 | // necessary when inserting PG results into elasticsearch 54 | query: function query(queryString, params, noCamel) { 55 | var _defer = Q.defer(); 56 | var formatted; 57 | // force promises to eventually resolve 58 | _defer.promise = _defer.promise.timeout(postgres.timeout, 'postgres query timed out'); 59 | pg.connect(dbParams, function onConnect(err, client, done) { 60 | if (err) { 61 | log.warn('failed to connect to pg'); 62 | log.trace(err); 63 | return _defer.reject(err); 64 | } 65 | client.query(queryString, params, function onQueryResponse(err, results) { 66 | if (err) { 67 | log.warn('postgres query failed'); 68 | log.trace(err); 69 | return _defer.reject(err); 70 | } 71 | done(); 72 | // transform the underscored keys to camelcased before returning, unless we explicitly say no 73 | if (results && results.rows.length) { 74 | formatted = noCamel ? results.rows : postgres.camelize(results.rows); 75 | } 76 | // if it's a single thing, just return the single thing. 77 | if (formatted && formatted.length === 1) { 78 | formatted = formatted[0]; 79 | } 80 | _defer.resolve(formatted); 81 | }); 82 | }); 83 | return _defer.promise; 84 | } 85 | }; 86 | 87 | module.exports = postgres; 88 | -------------------------------------------------------------------------------- /server/embedly.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | // Facade that abstracts the embed.ly endpoint. 8 | // TODO use Joi to validate the url and ensure key is defined 9 | 10 | var camelize = require('underscore.string').camelize; 11 | var Embedly = require('embedly'); 12 | 13 | var config = require('./config'); 14 | var apiKey = config.get('embedly_apiKey'); 15 | var isEnabled = config.get('embedly_enabled'); 16 | var logFactory = require('./logger'); 17 | var log = logFactory('server.services.embedly'); 18 | var npmLog = logFactory('server.services.embedly.vendor'); 19 | 20 | module.exports = { 21 | extract: function extract(url, cb) { 22 | log.debug('embedly.extract called'); 23 | 24 | if (!isEnabled) { 25 | return cb('Embedly is currently disabled. Set `embedly_enabled` to enable.'); 26 | } 27 | if (!apiKey) { 28 | return cb('Embedly requires an API key. Set `embedly_apiKey` to enable.'); 29 | } 30 | 31 | // node-embedly logs failures, so we can skip that here 32 | new Embedly({key: apiKey, logger: npmLog}, function(err, api) { 33 | if (err) { return cb(err); } 34 | api.extract({url:url}, function(err, data) { 35 | log.verbose('embedly.extract callback for url ' + url + ' returned data ' + JSON.stringify(data)); 36 | if (err) { return cb(err); } 37 | var d = data && data[0]; 38 | if (!d) { return cb(new Error('embedly response was empty for url ' + url)); } 39 | if (d.type === 'error') { return cb(new Error('embedly response was of type error for url ' + url)); } 40 | 41 | // by default, we are now going to keep everything in the format embedly sends us, except: 42 | 43 | // publication date handling: 44 | // 45 | // embedly expresses date of publication as 'published' + 'offset'. 46 | // these are both represented as milliseconds since the epoch. 47 | // if embedly found no publication time, 'published' + 'offset' will both be empty. 48 | // if embedly found publication time but no timezone, 'published' will be UTC. 49 | // if embedly found publication time and timezone, 'published' is in that 50 | // timezone, and 'offset' expresses the difference from UTC. 51 | // we transform these keys to store ISO dates, not millis, in our database. 52 | var publicationDateMillis = (d.offset || 0) + (d.published || 0); 53 | if (publicationDateMillis) { 54 | d.published = new Date(publicationDateMillis).toJSON(); 55 | delete d.offset; 56 | } 57 | 58 | // not in the response, but we want extracted_at to be the current time 59 | d.extracted_at = new Date().toJSON(); 60 | return cb(err, d); 61 | }); 62 | }); 63 | } 64 | }; 65 | 66 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Hapi = require('hapi'); 8 | var HapiAuthCookie = require('hapi-auth-cookie'); 9 | var Bell = require('bell'); 10 | var Lout = require('lout'); 11 | var pg = require('pg'); 12 | 13 | var config = require('./config'); 14 | var log = require('./logger')('server.index'); 15 | var authProfileCb = require('./bell-oauth-profile'); 16 | var routes = require('./routes'); 17 | var user = require('./models/user'); 18 | 19 | var serverConfig = {}; 20 | 21 | // log extra error info if we're in ultra-chatty log mode 22 | if (config.get('server_log_level') === 'trace') { 23 | serverConfig = { 24 | debug: { 25 | request: ['error'] 26 | } 27 | }; 28 | } 29 | 30 | var server = new Hapi.Server(serverConfig); 31 | server.connection({ 32 | host: config.get('server_host'), 33 | port: config.get('server_port'), 34 | router: { 35 | stripTrailingSlash: true 36 | } 37 | }); 38 | 39 | server.register([ 40 | HapiAuthCookie, 41 | Bell, 42 | Lout 43 | ], function (err) { 44 | if (err) { 45 | log.warn('failed to load plugin: ' + err); 46 | throw err; 47 | } 48 | 49 | // hapi-auth-cookie init 50 | server.auth.strategy('session', 'cookie', { 51 | password: config.get('server_session_password'), 52 | cookie: config.get('server_session_cookieName'), 53 | isSecure: config.get('server_session_isSecure'), 54 | keepAlive: config.get('server_session_keepAlive'), 55 | ttl: config.get('server_session_duration'), 56 | clearInvalid: config.get('server_session_clearInvalid'), 57 | // this function validates that the user exists + session is valid 58 | validateFunc: function(session, cb) { 59 | log.verbose('inside hapi-auth-cookie validateFunc.'); 60 | log.verbose('session is ' + JSON.stringify(session)); 61 | 62 | var ttl = config.get('server_session_duration'); 63 | if (ttl > 0 && new Date() > new Date(session.expiresAt)) { 64 | log.verbose('cookie is expired.'); 65 | return cb(null, false); 66 | } 67 | log.verbose('cookie is not expired.'); 68 | 69 | var userId = session.userId; 70 | user.get(userId, function(err, result) { 71 | if (err) { 72 | log.warn('unable to get user to validate user session: ' + err); 73 | return cb(err, false); 74 | } 75 | cb(null, !!result, userId); 76 | }); 77 | } 78 | }); 79 | 80 | // bell init 81 | server.auth.strategy('oauth', 'bell', { 82 | provider: { 83 | protocol: config.get('server_oauth_protocol'), 84 | auth: config.get('server_oauth_authEndpoint'), 85 | token: config.get('server_oauth_tokenEndpoint'), 86 | version: config.get('server_oauth_version'), 87 | scope: config.get('server_oauth_scope'), 88 | profile: authProfileCb 89 | }, 90 | password: config.get('server_session_password'), 91 | clientId: config.get('server_oauth_clientId'), 92 | clientSecret: config.get('server_oauth_clientSecret'), 93 | isSecure: config.get('server_session_isSecure') 94 | }); 95 | }); 96 | 97 | // shutdown the pg pool on process.exit, so that we don't have to 98 | // call pg.end anywhere else in the app 99 | process.on('exit', function onExit() { 100 | log.verbose('received exit signal, closing pool'); 101 | pg.end(); 102 | }); 103 | 104 | 105 | // register routes 106 | server.route(routes); 107 | 108 | module.exports = server; 109 | -------------------------------------------------------------------------------- /server/logger.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | var mozlog = require('mozlog'); 6 | var config = require('./config'); 7 | 8 | // TODO we shouldn't need copypasta when we are composing 2 log libraries already :-\ 9 | mozlog.config({ 10 | app: config.get('server_log_app'), 11 | level: config.get('server_log_level'), 12 | fmt: config.get('server_log_fmt'), 13 | debug: config.get('server_log_debug') 14 | }); 15 | var root = mozlog(config.get('server_log_app')); 16 | if (root.isEnabledFor('debug')) { 17 | root.warn('\t*** CAREFUL! Louder logs (less than INFO)' + 18 | ' may include SECRETS! ***'); 19 | } 20 | module.exports = mozlog; 21 | -------------------------------------------------------------------------------- /server/models/user-page.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | // we need a user-pages abstraction so that async scraper jobs 8 | // can separately handle creating the visit and creating the 9 | // user page. someday this might also be used by the client-facing API layer. 10 | 11 | var uuid = require('uuid'); 12 | 13 | var postgres = require('../db/postgres'); 14 | var elasticsearch = require('../db/elasticsearch'); 15 | var log = require('../logger')('server.models.user-page'); 16 | 17 | var _verbose = function() { 18 | var logline = [].join.call(arguments, ', '); 19 | log.verbose(logline); 20 | }; 21 | 22 | var userPage = { 23 | _onFulfilled: function _onFulfilled(msg, callback, results) { 24 | _verbose(msg); 25 | return callback(null, results); 26 | }, 27 | _onRejected: function _onRejected(msg, callback, err) { 28 | log.warn(msg); 29 | callback(err); 30 | }, 31 | update: function(userId, url, urlHash, title, data, cb) { 32 | // fetch the page id, lazily creating it if it doesn't exist, 33 | // then update the page with the huge blob of embedly data 34 | // 35 | // TODO if we don't fire a callback on creation, we should return a promise _or_ 36 | // fire 'userPage::updated' or 'userPage::updateError' events 37 | var name = 'models.user-page.update'; 38 | _verbose(name + ' called', userId, url); 39 | var noCamel = true; // do not camelize the results 40 | var currentTime = new Date().toJSON(); 41 | var lazyCreateParams = [uuid.v4(), userId, url, urlHash, title, currentTime]; 42 | var lazyCreateUserPageQuery = 'WITH new_page AS ( ' + 43 | ' INSERT INTO user_pages (id, user_id, url, raw_url, url_hash, title, created_at, updated_at) ' + 44 | ' SELECT $1, $2, $3, $3, $4, $5, $6, $6 ' + 45 | ' WHERE NOT EXISTS (SELECT id FROM user_pages WHERE user_id = $2 AND url_hash = $4) ' + 46 | ' RETURNING id ' + 47 | ') SELECT id FROM new_page ' + 48 | 'UNION SELECT id FROM user_pages WHERE user_id = $2 AND url_hash = $4'; 49 | var addExtractedDataQuery = 'UPDATE user_pages ' + 50 | 'SET (extracted_data, updated_at) = ($1, $2) ' + 51 | 'WHERE user_id = $3 and id = $4 ' + 52 | 'RETURNING *'; 53 | var addDataParams = [data, currentTime, userId]; 54 | _verbose('about to issue lazy user page creation query'); 55 | var userPageId; 56 | postgres.query(lazyCreateUserPageQuery, lazyCreateParams) 57 | .then(function(result) { 58 | // we just got the page_id; push it onto the end of the params array 59 | _verbose('the lazy create result is ' + JSON.stringify(result)); 60 | _verbose('the userPageId is ' + result.id); 61 | userPageId = result.id; 62 | addDataParams.push(result.id); 63 | return postgres.query(addExtractedDataQuery, addDataParams, noCamel); 64 | }) 65 | .then(function(completeResult) { 66 | // make the ES record mirror the PG record, for simplicity's sake 67 | var esQuery = { 68 | index: 'chronicle', 69 | type: 'user_pages', 70 | id: userPageId, 71 | body: completeResult 72 | }; 73 | _verbose('the elasticsearch query is ' + JSON.stringify(esQuery)); 74 | return elasticsearch.query('index', esQuery); 75 | }) 76 | .done(userPage._onFulfilled.bind(userPage, name + ' succeeded', cb), 77 | userPage._onRejected.bind(userPage, name + ' failed', cb)); 78 | 79 | }, 80 | get: function(userId, userPageId, cb) { 81 | var name = 'models.user-page.get'; 82 | _verbose(name + ' called', userId, userPageId); 83 | postgres.query('SELECT * FROM user_pages WHERE user_id = $1 and id = $2', [userId, userPageId]) 84 | .done(userPage._onFulfilled.bind(userPage, name + ' succeeded', cb), 85 | userPage._onRejected.bind(userPage, name + ' failed', cb)); 86 | }, 87 | // XXX this means "does it exist and has it been scraped yet", not "does it exist". 88 | // but an 'exists' function might be nice eventually 89 | hasMetadata: function(userId, urlHash, cb) { 90 | var name = 'models.user-page.hasMetadata'; 91 | _verbose(name + ' called', userId, urlHash); 92 | var query = 'SELECT exists(SELECT 1 FROM user_pages WHERE ' + 93 | 'user_id = $1 AND url_hash = $2 AND extracted_data IS NOT NULL)'; 94 | postgres.query(query, [userId, urlHash]) 95 | .done(function(result) { 96 | userPage._onFulfilled(name + ' succeeded', cb, result.exists); 97 | }, 98 | userPage._onRejected.bind(userPage, name + ' failed', cb)); 99 | } 100 | }; 101 | module.exports = userPage; 102 | -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Q = require('q'); 8 | 9 | var postgres = require('../db/postgres'); 10 | var log = require('../logger')('server.models.user'); 11 | 12 | var _verbose = function() { 13 | var logline = [].join.call(arguments, ', '); 14 | log.verbose(logline); 15 | }; 16 | 17 | var user = { 18 | _onFulfilled: function _onFulfilled(msg, callback, results) { 19 | _verbose(msg); 20 | _verbose('onfulfilled results are: ' + JSON.stringify(results)); 21 | callback(null, results); 22 | }, 23 | _onRejected: function _onRejected(msg, callback, err) { 24 | log.warn(msg); 25 | callback(err); 26 | }, 27 | create: function(userId, email, oauthToken, cb) { 28 | var name = 'models.user.create'; // RIP `arguments.callee` *snif* 29 | _verbose(name + ' called', userId, email, oauthToken); 30 | var query = 'INSERT INTO users (user_id, email, oauth_token, created_at, updated_at) ' + 31 | 'VALUES ($1, $2, $3, $4, $4)'; 32 | var params = [userId, email, oauthToken, new Date().toJSON()]; 33 | postgres.query(query, params) 34 | .done(user._onFulfilled.bind(user, name + ' succeeded', cb), 35 | user._onRejected.bind(user, name + ' failed', cb)); 36 | }, 37 | // TODO TODO use this! :-) 38 | exists: function(userId, cb) { 39 | var name = 'models.user.exists'; 40 | var query = 'SELECT exists(SELECT 1 FROM users WHERE user_id = $1)'; 41 | postgres.query(query, [userId]) 42 | .done(function(result) { 43 | user._onFulfilled(name + ' succeeded', cb, result.exists); 44 | }, 45 | user._onRejected.bind(user, name + ' failed', cb)); 46 | }, 47 | get: function(userId, cb) { 48 | var name = 'models.user.get'; 49 | _verbose(name + ' called', userId); 50 | var query = 'SELECT user_id, email FROM users WHERE user_id = $1'; 51 | postgres.query(query, [userId]) 52 | .done(user._onFulfilled.bind(user, name + ' succeeded', cb), 53 | user._onRejected.bind(user, name + ' failed', cb)); 54 | }, 55 | update: function(userId, email, oauthToken, cb) { 56 | var name = 'models.user.update'; 57 | _verbose(name + ' called', userId, email, oauthToken); 58 | var query = 'UPDATE users SET email = $1, oauth_token = $2, updated_at = $3 ' + 59 | 'WHERE user_id = $4 RETURNING email, user_id'; 60 | postgres.query(query, [email, oauthToken, new Date().toJSON(), userId]) 61 | .done(user._onFulfilled.bind(user, name + ' succeeded', cb), 62 | user._onRejected.bind(user, name + ' failed', cb)); 63 | } 64 | }; 65 | 66 | module.exports = user; 67 | -------------------------------------------------------------------------------- /server/models/visits.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var q = require('q'); 8 | 9 | var postgres = require('../db/postgres'); 10 | var elasticsearch = require('../db/elasticsearch'); 11 | var log = require('../logger')('server.models.visits'); 12 | var visit = require('./visit'); 13 | 14 | var _verbose = function() { 15 | var logline = [].join.call(arguments, ', '); 16 | log.verbose(logline); 17 | }; 18 | 19 | var visits = { 20 | _onFulfilled: function _onFulfilled(msg, callback, results) { 21 | _verbose(msg); 22 | callback(null, results); 23 | }, 24 | _onRejected: function _onRejected(msg, callback, err) { 25 | log.warn(msg); 26 | callback(err); 27 | }, 28 | _transform: function _transform(results) { 29 | if (!results || !results.length) { return; } 30 | var transformed = []; 31 | results.forEach(function(result) { 32 | transformed.push(visit._transform(result)); 33 | }); 34 | return transformed; 35 | }, 36 | getPaginated: function(userId, visitId, count, cb) { 37 | var name = 'models.visits.getPaginated'; 38 | _verbose(name + ' invoked', userId, visitId, count); 39 | var query = 'SELECT visits.id as visit_id, visits.user_id as user_id, visits.visited_at, * ' + 40 | 'FROM visits LEFT JOIN user_pages ON visits.user_page_id = user_pages.id ' + 41 | 'WHERE visits.user_id = $1 ' + 42 | 'AND visits.visited_at < (SELECT visited_at FROM visits WHERE id = $2) ' + 43 | 'ORDER BY visits.visited_at DESC LIMIT $3'; 44 | var params = [userId, visitId, count]; 45 | postgres.query(query, params) 46 | .then(function(results) { 47 | // return a promise that resolves to the transformed results 48 | return q(visits._transform(results)); 49 | }) 50 | .done(visits._onFulfilled.bind(visits, name + ' succeeded', cb), 51 | visits._onRejected.bind(visits, name + ' failed', cb)); 52 | }, 53 | get: function(userId, count, cb) { 54 | var name = 'models.visits.get'; 55 | _verbose(name + ' invoked', userId, count); 56 | var query = 'SELECT visits.id as visit_id, visits.user_id as user_id, visits.visited_at, * ' + 57 | 'FROM visits LEFT JOIN user_pages ON visits.user_page_id = user_pages.id ' + 58 | 'WHERE visits.user_id = $1 ORDER BY visits.visited_at DESC LIMIT $2'; 59 | var params = [userId, count]; 60 | postgres.query(query, params) 61 | .then(function(results) { 62 | // return a promise that resolves to the transformed results 63 | return q(visits._transform(results)); 64 | }) 65 | .done(visits._onFulfilled.bind(visits, name + ' succeeded', cb), 66 | visits._onRejected.bind(visits, name + ' failed', cb)); 67 | }, 68 | search: function(userId, searchTerm, count, cb) { 69 | var name = 'models.visits.search'; 70 | _verbose(name + ' invoked', userId, count); 71 | var esQuery = { 72 | index: 'chronicle', 73 | type: 'user_pages', 74 | size: count, 75 | body: { 76 | query: { 77 | filtered: { 78 | query: { 79 | multiMatch: { 80 | query: searchTerm, 81 | fuzziness: 'AUTO', 82 | operator: 'and', 83 | fields: [ 84 | 'title', 85 | 'extracted_data.title', 86 | 'extracted_data.content', 87 | 'extracted_data.lead', 88 | 'extracted_data.description', 89 | 'extracted_data.url', 90 | 'extracted_data.provider_display', 91 | 'extracted_data.provider_name', 92 | // TODO: use nested objects if we need more structured searches 93 | 'extracted_data.authors.name' 94 | ] 95 | } 96 | } 97 | } 98 | }, 99 | filter: { term: { user_id: userId } } 100 | } 101 | }; 102 | log.verbose('searching elasticsearch for the search term ' + searchTerm); 103 | elasticsearch.query('search', esQuery) 104 | .done(function(resp) { 105 | log.verbose('response from elasticsearch: ' + JSON.stringify(resp)); 106 | var output = {}; 107 | output.resultCount = resp.hits.total; 108 | output.results = resp.hits; 109 | _verbose(name + ' succeeded'); 110 | cb(null, output); 111 | }, visits._onRejected.bind(visits, name + ' failed', cb)); 112 | } 113 | }; 114 | 115 | module.exports = visits; 116 | -------------------------------------------------------------------------------- /server/routes/auth.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var authController = require('../controllers/auth'); 8 | 9 | var authRoutes = [{ 10 | method: 'GET', 11 | path: '/auth/login', 12 | config: { 13 | handler: authController.login, 14 | auth: { 15 | strategy: 'session', 16 | mode: 'try' 17 | }, 18 | plugins: { 19 | 'hapi-auth-cookie': { 20 | redirectTo: false // don't redirect users who don't have a session 21 | } 22 | }, 23 | tags: ['auth'] 24 | } 25 | }, { 26 | method: 'GET', 27 | path: '/auth/logout', 28 | config: { 29 | handler: authController.logout, 30 | auth: { 31 | strategy: 'session', 32 | mode: 'try' 33 | }, 34 | tags: ['auth'] 35 | } 36 | }, { 37 | // Bell uses the same endpoint for both the start and redirect 38 | // steps in the flow. The front end starts the user here, and 39 | // Bell redirects here after we're done. 40 | method: ['GET', 'POST'], 41 | path: '/auth/complete', 42 | config: { 43 | handler: authController.complete, 44 | auth: { 45 | strategy: 'oauth', 46 | mode: 'try' 47 | }, 48 | tags: ['auth'] 49 | } 50 | }]; 51 | 52 | module.exports = authRoutes; 53 | -------------------------------------------------------------------------------- /server/routes/base.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var path = require('path'); 8 | var config = require('../config'); 9 | var baseController = require('../controllers/base'); 10 | var STATIC_PATH = path.join(__dirname, '..', '..', config.get('server_staticPath')); 11 | 12 | var baseRoutes = [{ 13 | method: 'GET', 14 | path: '/', 15 | config: { 16 | handler: baseController.get, 17 | auth: { 18 | strategy: 'session', 19 | // 'try': allow users to visit the route with good, bad, or no session 20 | mode: 'try' 21 | }, 22 | plugins: { 23 | 'hapi-auth-cookie': { 24 | redirectTo: false // don't redirect users who don't have a session 25 | } 26 | }, 27 | description: 'Serves the welcome page or home page.', 28 | tags: ['home'] 29 | } 30 | }, { 31 | method: 'GET', 32 | path: '/{param*}', 33 | config: { 34 | handler: { 35 | directory: { 36 | path: STATIC_PATH, 37 | listing: config.get('server_staticDirListing') 38 | } 39 | }, 40 | description: 'A catch-all route for serving static files.', 41 | tags: ['static'] 42 | } 43 | }]; 44 | 45 | module.exports = baseRoutes; 46 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var fs = require('fs'); 8 | var routes = []; 9 | 10 | // Require each of the file in the current directory (except index.js). 11 | fs.readdirSync(__dirname).forEach(function(file) { 12 | if (file === 'index.js') { return; } 13 | routes = routes.concat(require('./' + file)); 14 | }); 15 | module.exports = routes; 16 | -------------------------------------------------------------------------------- /server/routes/ops.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Q = require('q'); 8 | var es = require('elasticsearch'); 9 | var pg = require('pg'); 10 | var redis = require('redis'); 11 | 12 | var config = require('../config'); 13 | 14 | var pgParams = { 15 | user: config.get('db_postgres_user'), 16 | password: config.get('db_postgres_password'), 17 | host: config.get('db_postgres_host'), 18 | port: config.get('db_postgres_port'), 19 | database: config.get('db_postgres_database'), 20 | ssl: config.get('db_postgres_ssl') 21 | }; 22 | 23 | var redisParams = { 24 | host: config.get('db_redis_host'), 25 | port: config.get('db_redis_port'), 26 | password: config.get('db_redis_password'), 27 | database: config.get('db_redis_database') 28 | }; 29 | 30 | // >:-( 31 | // https://github.com/elasticsearch/elasticsearch-js/issues/33 32 | var esParamsFactory = function() { 33 | return { 34 | host: { 35 | host: config.get('db_elasticsearch_host'), 36 | post: config.get('db_elasticsearch_port'), 37 | log: 'verbose' 38 | } 39 | }; 40 | }; 41 | 42 | var pgCheck = function () { 43 | var deferred = Q.defer(); 44 | pg.connect(pgParams, function(err, client, done) { 45 | done(); // Close database connection 46 | if (err) { 47 | return deferred.reject(err); 48 | } 49 | deferred.resolve(); 50 | }); 51 | return deferred.promise; 52 | }; 53 | 54 | var esCheck = function () { 55 | var esClient = new es.Client(esParamsFactory()); 56 | return esClient.ping({ 57 | index: 'chronicle', 58 | type: 'visits' 59 | }); 60 | }; 61 | 62 | var redisCheck = function () { 63 | var deferred = Q.defer(); 64 | var client = redis.createClient(redisParams.port, redisParams.host); 65 | client.on('connect', function () { 66 | client.quit(); 67 | return deferred.resolve(); 68 | }); 69 | client.on('error', function (err) { 70 | client.quit(); 71 | return deferred.reject(err); 72 | }); 73 | return deferred.promise; 74 | }; 75 | 76 | module.exports = [{ 77 | method: 'GET', 78 | path: '/__heartbeat__', 79 | config: { 80 | handler: function (request, reply) { 81 | Q.all([ 82 | pgCheck(), 83 | esCheck(), 84 | redisCheck() 85 | ]).then(function () { 86 | reply('ok').code(200); 87 | }, function (err) { 88 | reply(err.message).code(503); 89 | }); 90 | }, 91 | description: 'An endpoint for the OPs team to test server health.', 92 | tags: ['ops'] 93 | } 94 | }]; 95 | -------------------------------------------------------------------------------- /server/routes/profile.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Joi = require('joi'); 8 | 9 | var log = require('../logger')('server.routes.profile'); 10 | var profileController = require('../controllers/profile'); 11 | 12 | var profileRoutes = [{ 13 | method: 'GET', 14 | path: '/v1/profile', 15 | config: { 16 | handler: profileController.get, 17 | auth: 'session', 18 | tags: ['profile'] 19 | } 20 | }]; 21 | 22 | module.exports = profileRoutes; 23 | -------------------------------------------------------------------------------- /server/routes/search.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Joi = require('joi'); 8 | var Boom = require('boom'); 9 | 10 | var searchController = require('../controllers/search'); 11 | var log = require('../logger')('server.routes.search'); 12 | 13 | var searchRoutes = [{ 14 | method: 'GET', 15 | path: '/v1/search', 16 | config: { 17 | handler: searchController.get, 18 | auth: 'session', 19 | validate: { 20 | query: { 21 | q: Joi.string().required(), 22 | count: Joi.number().integer().min(1).max(100).default(25) 23 | } 24 | }, 25 | tags: ['search'] 26 | } 27 | }]; 28 | 29 | module.exports = searchRoutes; 30 | -------------------------------------------------------------------------------- /server/routes/ver.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var verJsonController = require('../controllers/ver'); 8 | 9 | module.exports = [{ 10 | method: 'GET', 11 | path: '/ver.json', 12 | config: { 13 | handler: verJsonController.get, 14 | description: 'Displays the Git SHAs for the deployed server.', 15 | tags: ['ops'] 16 | } 17 | }]; 18 | -------------------------------------------------------------------------------- /server/routes/visit.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var Joi = require('joi'); 8 | 9 | var log = require('../logger')('server.routes.visit'); 10 | var visitController = require('../controllers/visit'); 11 | 12 | var visitRoutes = [{ 13 | method: 'GET', 14 | path: '/v1/visits/{visitId}', 15 | config: { 16 | handler: visitController.get, 17 | auth: 'session', 18 | validate: { 19 | params: { 20 | visitId: Joi.string().guid().required() 21 | } 22 | }, 23 | description: 'Get a specific visit, by visitId.', 24 | tags: ['visit'] 25 | } 26 | }, { 27 | method: 'PUT', 28 | path: '/v1/visits/{visitId}', 29 | config: { 30 | handler: visitController.put, 31 | auth: 'session', 32 | validate: { 33 | // all fields are required, keep life simple for the DB 34 | payload: { 35 | url: Joi.string().required(), 36 | title: Joi.string().max(128).required(), 37 | visitedAt: Joi.date().iso().required() 38 | }, 39 | params: { 40 | visitId: Joi.string().guid().required() 41 | } 42 | }, 43 | description: 'Create a specific visit and set the specified url, title, visitedAt fields.', 44 | tags: ['visit'] 45 | } 46 | }, { 47 | method: 'DELETE', 48 | path: '/v1/visits/{visitId}', 49 | config: { 50 | handler: visitController.delete, 51 | auth: 'session', 52 | validate: { 53 | params: { 54 | visitId: Joi.string().guid().required() 55 | } 56 | }, 57 | description: 'Delete a specific visit, by visitId.', 58 | tags: ['visit'] 59 | } 60 | }]; 61 | 62 | module.exports = visitRoutes; 63 | -------------------------------------------------------------------------------- /server/routes/visits.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var crypto = require('crypto'); 8 | var Joi = require('joi'); 9 | 10 | var log = require('../logger')('server.routes.visits'); 11 | var visitsController = require('../controllers/visits'); 12 | 13 | var visitsRoutes = [{ 14 | method: 'GET', 15 | path: '/v1/visits', 16 | config: { 17 | handler: visitsController.get, 18 | auth: 'session', 19 | validate: { 20 | query: { 21 | count: Joi.number().integer().min(1).max(100).default(25), 22 | visitId: Joi.string().guid() 23 | } 24 | }, 25 | description: 'Get count visits from the database, starting at visitId.', 26 | tags: ['visits'] 27 | } 28 | }, { 29 | method: 'POST', 30 | path: '/v1/visits', 31 | config: { 32 | handler: visitsController.post, 33 | auth: 'session', 34 | validate: { 35 | payload: { 36 | url: Joi.string().required(), 37 | title: Joi.string().max(128).required(), 38 | visitedAt: Joi.date().iso().required(), 39 | // client can optionally provide uuid 40 | visitId: Joi.string().guid() 41 | } 42 | }, 43 | tags: ['visits'] 44 | } 45 | }]; 46 | 47 | module.exports = visitsRoutes; 48 | -------------------------------------------------------------------------------- /server/utils.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var camelizeLib = require('underscore.string').camelize; 8 | 9 | var utils = { 10 | camelize: function(item) { 11 | // Object.keys throws if not passed an array or object 12 | // typeof item == 'object' for array, object, null. 13 | // check truthiness to exclude null. TODO use Joi instead, way too clever 14 | if (!item || typeof item !== 'object') { return item; } 15 | var output = {}; 16 | Object.keys(item).forEach(function(k) { 17 | // special case: don't modify elasticsearch keys like _id, _type, _source 18 | if (k.indexOf('_') === 0) { 19 | output[k] = item[k]; 20 | } else { 21 | output[camelizeLib(k)] = item[k]; 22 | } 23 | }); 24 | // special case: json extracted_data blob, seen in postgres 25 | if (item.extracted_data) { 26 | output.extractedData = utils.camelize(item.extracted_data); 27 | } 28 | // special case: elasticsearch _source field 29 | // TODO: maybe just recurse instead of this terrible hackiness 30 | if (item._source) { 31 | output._source = utils.camelize(item._source); 32 | } 33 | return output; 34 | } 35 | }; 36 | 37 | module.exports = utils; 38 | -------------------------------------------------------------------------------- /server/views/search.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | // We really should have one visit datatype returned by the API 8 | // It's a temporary weirdness that this is formatted differently 9 | 10 | var config = require('../config'); 11 | var utils = require('../utils'); 12 | var url2png = require('url2png')(config.get('url2png_apiKey'), config.get('url2png_secretKey')); 13 | 14 | var legacyTransform = function(item) { 15 | if (!item._source.extracted_data) { return item; } 16 | item._source.extractedFaviconUrl = item._source.extracted_data.favicon_url; 17 | if (item._source.extracted_data.images && item._source.extracted_data.images.length) { 18 | item._source.extractedImageUrl = item._source.extracted_data.images[0].url; 19 | item._source.extractedImageEntropy = item._source.extracted_data.images[0].entropy; 20 | item._source.extractedImageWidth = item._source.extracted_data.images[0].width; 21 | item._source.extractedImageHeight = item._source.extracted_data.images[0].height; 22 | } 23 | item._source.extractedTitle = item._source.extracted_data.title; 24 | item._source.extractedDescription = item._source.extracted_data.description; 25 | return item; 26 | }; 27 | 28 | var addScreenshot = function(item) { 29 | item._source.screenshot_url = url2png.buildURL(item._source.url, {viewport: '1024x683', thumbnail_max_width: 540}); 30 | return item; 31 | }; 32 | 33 | var searchView = { 34 | render: function(results) { 35 | var items = results.results.hits; 36 | var out = []; 37 | if (!items.length) { items = [items]; } 38 | items.forEach(function(item) { 39 | // same basic deal as the visit view, but just different enough to be annoying >:-| 40 | out.push(utils.camelize(addScreenshot(legacyTransform(item)))); 41 | }); 42 | results.results.hits = out; 43 | } 44 | }; 45 | 46 | module.exports = searchView; 47 | -------------------------------------------------------------------------------- /server/views/visit.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var config = require('../config'); 8 | var url2png = require('url2png')(config.get('url2png_apiKey'), config.get('url2png_secretKey')); 9 | 10 | var transform = function(results) { 11 | // keep visit properties top level, move all other page properties under a userPage key 12 | var newResult = {}; 13 | ['id', 'url', 'title', 'visited_at'].forEach(function(item) { 14 | newResult[item] = results[item]; 15 | delete results[item]; 16 | }); 17 | 18 | // remove the userId from the visit, if present 19 | delete results.userId; 20 | 21 | newResult.userPage = results; 22 | return newResult; 23 | }; 24 | 25 | var legacyTransform = function(item) { 26 | if (!item.userPage.extractedData) { return item; } 27 | // send down the old keys that were getting used 28 | item.userPage.extractedFaviconUrl = item.userPage.extractedData.faviconUrl; 29 | if (item.userPage.extractedData.images && item.userPage.extractedData.images.length) { 30 | item.userPage.extractedImageUrl = item.userPage.extractedData.images[0].url; 31 | item.userPage.extractedImageEntropy = item.userPage.extractedData.images[0].entropy; 32 | item.userPage.extractedImageWidth = item.userPage.extractedData.images[0].width; 33 | item.userPage.extractedImageHeight = item.userPage.extractedData.images[0].height; 34 | } 35 | item.userPage.extractedTitle = item.userPage.extractedData.title; 36 | item.userPage.extractedDescription = item.userPage.extractedData.description; 37 | return item; 38 | }; 39 | 40 | var addScreenshot = function(item) { 41 | item.screenshot_url = url2png.buildURL(item.url, {viewport: '1024x683', thumbnail_max_width: 540}); 42 | return item; 43 | }; 44 | 45 | var visitView = { 46 | // TODO listen for changes on a model instead, get fancy ^_^ 47 | render: function(data) { 48 | return addScreenshot(legacyTransform(transform(data))); 49 | }, 50 | }; 51 | 52 | module.exports = visitView; 53 | -------------------------------------------------------------------------------- /server/views/visits.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var visitView = require('./visit'); 8 | 9 | var visitsView = { 10 | render: function(visits) { 11 | if (!Array.isArray(visits)) { visits = [visits]; } 12 | var out = []; 13 | visits.forEach(function(visit) { 14 | out.push(visitView.render(visit)); 15 | }); 16 | return out; 17 | } 18 | }; 19 | 20 | module.exports = visitsView; 21 | -------------------------------------------------------------------------------- /server/work-queue/README.md: -------------------------------------------------------------------------------- 1 | ## work queues == awesoem 2 | 3 | Background tasks are offloaded to a work queue. 4 | 5 | Currently, each webhead runs its own queue and workers. Queues are implemented using the [node-resque](https://github.com/taskrabbit/node-resque) library on top of [Redis](http://redis.io/). 6 | 7 | Pros: 8 | - simple / easy to get started quickly 9 | - don't have to distribute redis 10 | - don't have to get additional work queue instances talking with webhead instances 11 | 12 | Cons: 13 | - not persistent / redundant: if we lose a webhead, we lose everything in its queue 14 | - can't add workers easily (need to scale up the whole box) 15 | - can't let idle machines help hammered machines (uneven workload across webhead tier) 16 | 17 | Luckily, none of the cons affect us in the early early stages: we don't have significant traffic, and we are dogfooding our own product, and understand that data loss will probably happen. 18 | 19 | ### code layout 20 | 21 | ### job interface 22 | 23 | This is defined by node-resque: 24 | - a job exposes a `perform` function, with a callback as the last argument in its function signature: `function perform(some, job, args, callback)` 25 | - as usual in node-land, the function signature of the callback is err-first: `function callback(err, result)`. 26 | 27 | ### flow chart 28 | 29 | TBD 30 | 31 | ### really rough guess at a work queue roadmap 32 | 33 | #### version 0: one happy path 34 | 35 | - there is a queue 36 | - there is one kind of job 37 | - there is one worker thread 38 | - if work is added by the API code, it gets run by a worker asynchronously 39 | 40 | #### version 1: multiple happy paths, one sad path 41 | 42 | - define more jobs 43 | - add a larger worker pool 44 | - prioritize the different job types 45 | - handle job failures by logging them (to filesystem/s3?) and deleting from the queue 46 | 47 | #### version 2: fancy 48 | 49 | - handle recoverable job failures by retrying 50 | - add monitoring / dashboard 51 | - handle queue failure (what if redis is gone or fills up?) 52 | - dynamically adjust number of workers based on system load and monitoring status 53 | 54 | ### background reading 55 | 56 | I actually haven't been able to find a good language-agnostic description of the "work queue" or "task queue" concept, together with a list of app-level implementation concerns, like monitoring or retry logic. 57 | 58 | - More practical, app-focused guide from Heroku: https://devcenter.heroku.com/articles/background-jobs-queueing 59 | - This doc is really high-level, kinda has that UML fluff thing about it, but might be informative: http://parlab.eecs.berkeley.edu/wiki/_media/patterns/taskqueue.pdf 60 | -------------------------------------------------------------------------------- /server/work-queue/jobs/create-visit.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var visit = require('../../models/visit'); 8 | var log = require('../../logger')('server.work-queue.jobs.create-visit'); 9 | 10 | // for now, just pass around the queue reference 11 | module.exports = { 12 | work: function(queue) { 13 | queue.process('createVisit', 10, function(job, done) { 14 | var d = job.data; 15 | log.info('createVisit.job.running', job.id); 16 | visit.create(d.userId, d.visitId, d.visitedAt, d.url, d.urlHash, d.title, function(err) { 17 | return done(err); 18 | }); 19 | }); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /server/work-queue/jobs/extract-page.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var log = require('../../logger')('server.work-queue.jobs.extract-page'); 8 | var userPage = require('../../models/user-page'); 9 | var embedly = require('../../embedly'); 10 | var embedlyEnabled = require('../../config').get('embedly_enabled'); 11 | 12 | module.exports = { 13 | work: function(queue) { 14 | queue.process('extractPage', 10, function(job, done) { 15 | var d = job.data; 16 | if (!embedlyEnabled) { 17 | var msg = 'embedly disabled; extract-page job returning immediately'; 18 | log.warn(msg); 19 | return done(new Error(msg)); 20 | } 21 | // check if userPage exists in the db already 22 | userPage.hasMetadata(d.userId, d.urlHash, function(err, pageExists) { 23 | if (err) { 24 | var msg = 'failed to check if page has metadata'; 25 | log.warn(msg, err); 26 | return done(new Error(msg)); 27 | } else if (pageExists) { 28 | log.info('page already has metadata, no need to scrape it', d.url); 29 | return done(); 30 | } 31 | embedly.extract(d.url, function(err, extracted) { 32 | if (err) { 33 | var msg = 'failed at embedly.extract step'; 34 | log.warn(msg, d.url); 35 | // we should retry on failure. leave that to the queue. 36 | return done(new Error(msg)); 37 | } 38 | log.verbose('succeeded at embedly.extract step', d.url); 39 | // the visit creation job has probably created a record in the user_page table. 40 | // if not, update will lazily create it. 41 | userPage.update(d.userId, d.url, d.urlHash, d.title, extracted, function (err) { 42 | var msg; 43 | if (err) { 44 | msg = 'failed at userPage.update step'; 45 | log.warn(msg, d.url); 46 | done(new Error(msg)); 47 | } 48 | done(); 49 | }); 50 | }); 51 | }); 52 | }); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /server/work-queue/jobs/index.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | module.exports = { 8 | createVisit: require('./create-visit'), 9 | extractPage: require('./extract-page'), 10 | sendWelcomeEmail: require('./send-welcome-email') 11 | }; 12 | -------------------------------------------------------------------------------- /server/work-queue/jobs/send-welcome-email.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | var nodemailer = require('nodemailer'); 8 | var smtp = require('nodemailer-smtp-transport'); 9 | 10 | var log = require('../../logger')('server.work-queue.jobs.send-welcome-email'); 11 | var config = require('../../config'); 12 | 13 | var transporter = nodemailer.createTransport(); 14 | 15 | // Use SMTP transport if we aren't developing locally. 16 | if (config.get('env') !== 'local') { 17 | transporter = nodemailer.createTransport(smtp({ 18 | host: config.get('email_smtp_host'), 19 | port: config.get('email_smtp_port'), 20 | auth: { 21 | user: config.get('email_smtp_auth_user'), 22 | pass: config.get('email_smtp_auth_pass') 23 | } 24 | })); 25 | } 26 | 27 | module.exports = { 28 | work: function(queue) { 29 | queue.process('sendWelcomeEmail', function(job, done) { 30 | var toEmail = job.data.email; 31 | transporter.sendMail({ 32 | from: config.get('email_fromEmail'), 33 | to: toEmail, 34 | subject: 'Welcome to Chronicle', 35 | text: 'Welcome to Chronicle, ' + toEmail + '!', 36 | html: 'Welcome to chronicle, ' + toEmail + '!' 37 | }, function (err, info) { 38 | if (err) { 39 | var msg = 'unable to send email'; 40 | log.error(msg, err); 41 | return done(new Error(msg)); 42 | } 43 | log.verbose('welcome email sent'); 44 | done(); 45 | }); 46 | }); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /server/work-queue/queue.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | 'use strict'; 6 | 7 | 8 | 9 | var kue = require('kue'); 10 | 11 | var config = require('../config'); 12 | var log = require('../logger')('server.work-queue'); 13 | var workers = require('./jobs'); 14 | 15 | // high-level TODOs: 16 | // TODO use OOP, ES5 Object.create is fine. Just pick something and get on w/it. 17 | // TODO use cluster support to fork additional processes: 18 | // https://github.com/learnboost/kue#parallel-processing-with-cluster 19 | // TODO handle resuming dropped jobs across process restarts 20 | 21 | 22 | // startup 23 | 24 | 25 | var _queue = kue.createQueue({ 26 | redis: { 27 | host: config.get('db_redis_host'), 28 | password: config.get('db_redis_password'), 29 | port: config.get('db_redis_port'), 30 | db: config.get('db_redis_database') 31 | } 32 | }); 33 | 34 | 35 | // shutdown / process mgmt 36 | 37 | 38 | var onUncaughtException = function(err) { 39 | log.warn('uncaught exception caught at process level, doh!', err); 40 | }; 41 | 42 | var onShutdown = function(signal) { 43 | log.info('signal ' + signal + ' received, queue exiting when all jobs complete...'); 44 | _queue.shutdown(function(err) { 45 | log.info('all jobs complete, queue exiting'); 46 | }); 47 | }; 48 | 49 | process.on('uncaughtException', onUncaughtException); 50 | process.once('SIGINT', onShutdown.bind('SIGINT')); 51 | process.once('SIGTERM', onShutdown.bind('SIGTERM')); 52 | process.once('exit', onShutdown.bind('exit')); 53 | 54 | 55 | // job management 56 | 57 | 58 | var _removeJob = function(id) { 59 | // TODO check if it's an id or a job, don't get the job twice 60 | kue.Job.get(id, function(err, job) { 61 | if (err) { return; } 62 | job.remove(function(err) { 63 | if (err) { 64 | log.warn('error removing job ' + job.id, err); 65 | // TODO: add to a cleanup queue? what possible errors could occur here? 66 | } 67 | log.info('removed job ' + job.id); 68 | }); 69 | }); 70 | }; 71 | 72 | var onJobComplete = function(id, result) { 73 | log.info('queue.job.completed', {id: id}); 74 | _removeJob(id); 75 | }; 76 | 77 | var onJobFailed = function(id) { 78 | log.warn('queue.job.failed', {id: id}); 79 | kue.Job.get(id, function(err, job) { 80 | if (err) { return; } 81 | log.verbose('queue.job.failed.err', {id: id, err: job.error}); 82 | _removeJob(id); 83 | }); 84 | }; 85 | 86 | var onJobError = function(id, err) { 87 | log.warn('queue.job.error', {id: id}); 88 | kue.Job.get(id, function(err, job) { 89 | if (err) { return; } 90 | log.verbose('queue.job.error.err', {id: id, err: job.error}); 91 | _removeJob(id); 92 | }); 93 | }; 94 | 95 | var onJobRetry = function(id) { 96 | log.info('queue.job.retrying', {id: id}); 97 | kue.Job.get(id, function(err, job) { 98 | if (err) { return; } 99 | log.verbose('queue.job.retry.err', {id: id, err: job.error}); 100 | }); 101 | }; 102 | 103 | var onJobEnqueued = function(id) { 104 | log.info('queue.job.enqueued', {id: id}); 105 | }; 106 | 107 | var onJobProgress = function(id, completed, total) { 108 | log.info('queue.job.progress', {id: id, completed: completed, total: total}); 109 | }; 110 | 111 | _queue.on('job complete', onJobComplete); 112 | _queue.on('job failed', onJobFailed); 113 | _queue.on('job error', onJobError); 114 | _queue.on('job failed attempt', onJobRetry); 115 | _queue.on('job enqueue', onJobEnqueued); 116 | _queue.on('job progress', onJobProgress); 117 | 118 | 119 | // job creation 120 | 121 | 122 | // TODO allow job creation to set priorities; we're setting priority here for the moment 123 | // opts := job data object 124 | var exported = { 125 | enqueue: function(job, data, priority) { 126 | log.debug(job + '.called'); 127 | _queue.create(job, data) 128 | .priority(priority) 129 | .save(); 130 | }, 131 | createVisit: function(opts) { 132 | exported.enqueue('createVisit', opts, 'high'); 133 | }, 134 | extractPage: function(opts) { 135 | exported.enqueue('extractPage', opts, 'medium'); 136 | }, 137 | sendWelcomeEmail: function(opts) { 138 | exported.enqueue('sendWelcomeEmail', opts, 'low'); 139 | } 140 | }; 141 | 142 | 143 | // job processing 144 | 145 | 146 | // let's start with 10 workers per job (TODO put this in a config somewhere) 147 | workers.createVisit.work(_queue); 148 | workers.extractPage.work(_queue); 149 | workers.sendWelcomeEmail.work(_queue); 150 | 151 | module.exports = exported; 152 | -------------------------------------------------------------------------------- /tests/functional/visits.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | define([ 6 | 'intern', 7 | 'intern!bdd', 8 | 'intern/chai!expect' 9 | ], function (intern, bdd, expect) { 10 | 'use strict'; 11 | 12 | var URL = intern.config.chronicle.url; 13 | 14 | bdd.describe('visits', function () { 15 | bdd.before(function () { 16 | // login automatically as the fake user 17 | return this.remote.get(URL + '/auth/login'); 18 | }); 19 | 20 | bdd.it('should show a list of recent visits', function () { 21 | return this.remote 22 | .get(URL) 23 | .findAllByCssSelector('.visits') 24 | .findAllByCssSelector('.visit') 25 | .then(function (els) { 26 | expect(els.length).to.be.at.least(1); 27 | }); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/intern.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | // Learn more about configuring this file at . 6 | // These default settings work OK for most people. The options that *must* be changed below are the 7 | // packages, suites, excludeInstrumentation, and (if you want functional tests) functionalSuites. 8 | define(['intern/lib/args'], function (args) { 9 | 'use strict'; 10 | 11 | return { 12 | // Chronicle configuration 13 | chronicle: { 14 | url: 'http://localhost:8080' 15 | }, 16 | 17 | // The port on which the instrumenting proxy will listen 18 | proxyPort: 9000, 19 | 20 | // A fully qualified URL to the Intern proxy 21 | proxyUrl: 'http://localhost:9000/', 22 | 23 | // Default desired capabilities for all environments. Individual capabilities can be overridden by any of the 24 | // specified browser environments in the `environments` array below as well. See 25 | // https://code.google.com/p/selenium/wiki/DesiredCapabilities for standard Selenium capabilities and 26 | // https://saucelabs.com/docs/additional-config#desired-capabilities for Sauce Labs capabilities. 27 | // Note that the `build` capability will be filled in with the current commit ID from the Travis CI environment 28 | // automatically 29 | capabilities: { 30 | 'selenium-version': '2.44.0' 31 | }, 32 | 33 | // Browsers to run integration testing against. Note that version numbers must be strings if used with Sauce 34 | // OnDemand. Options that will be permutated are browserName, version, platform, and platformVersion; any other 35 | // capabilities options specified for an environment will be copied as-is 36 | environments: [ 37 | { browserName: 'firefox' } 38 | ], 39 | 40 | // Maximum number of simultaneous integration tests that should be executed on the remote WebDriver service 41 | maxConcurrency: 3, 42 | 43 | reporters: [ 'console' ], 44 | 45 | // Whether or not to start Sauce Connect before running tests 46 | useSauceConnect: false, 47 | 48 | // Functional test suite(s) to run in each browser once non-functional tests are completed 49 | functionalSuites: [ 50 | 'tests/functional/visits' 51 | ], 52 | 53 | // A regular expression matching URLs to files that should not be included in code coverage analysis 54 | excludeInstrumentation: /^(?:test|node_modules)\// 55 | }; 56 | }); 57 | --------------------------------------------------------------------------------