Skip to content

Commit

Permalink
First working version
Browse files Browse the repository at this point in the history
A basic working version of the bouncer. How it works:

- This is a very simple Pyramid app with just one (real) view and a
  little JavaScript, no authorization or authentication, no sessions, no
  database, etc.

- The Pyramid app starts up and reads it configuration (e.g. the Via
  URL, the Elasticsearch server's URL) from environment variables
  (no paster, no `.ini` files).

- The user requests a page like `https://hpt.is/<id>/[path]`.

- The server-side code reads the annotation ID from the URL, fetches the
  annotation from Elasticsearch directly, and extracts the document URI from
  the annotation

- No consideration is given to whether the user is logged-in or whether
  annotations are private or belong to private groups. It may redirect a
  user to an annotation that they aren't authorized to see or that they
  need to login in order to see, at which point the client (Via or the
  Chrome extension) is responsible for dealing with that situation. The
  bouncer knows nothing about authentication or authorization.

- If the annotation ID isn't found in Elasticsearch then the server
  responds with a simple 404 page.

- Otherwise the server delivers a simple HTML page with a CSS animation
  that says "Redirecting".

- The server renders a JSON data object into the page that includes both
  the Chrome extension URL and the Via URL for the annotation.

- The page also includes a JavaScript file that runs on page load and
  redirects the browser by assigning to `window.location`.
  This JavaScript:

  - Reads the Chrome extension URL and Via URL from the JSON data object
    that the server rendered into the page.

  - Checks whether the global `chrome` object exists. If not then it
    redirects to the Via URL.

  - If the `chrome` object does exist then it checks whether our Chrome
    extension is installed by calling `chrome.runtime.sendMessage()`.

  - If the extension responds the JavaScript redirects the browser to
    the annotation's extension URL, if not it redirects to the Via URL.

Known issues:

- Sometimes the alt text "Redirecting" is shown in place of the
  Hypothesis logo image while the browser waits for the page being
  redirected to to load. This seems to happen in Firefox more than
  Chrome. What I think is happening (initial guess) is:

  * The JavaScript downloads and does the redirect before the image file
    has finished download.
  * At this point the browser no longer cares about the page, will not
    finish downloading and rendering the image, it's on to the next
    page.
  * However if the next page takes a while to load, the
    partially-rendered redirecting page may still be dsiplayed for a
    noticeable amount of time, and it shows the alt text instead of the
    image.

Notes:

- It supports Python version 3.5 only.

- The unit tests, JavaScript modularization, continuous integration and
  linting tools, etc may seem excessive for such simple functionality.
  But I think it's good to start off as we mean to continue and lay a
  good foundation for when the requirements of this app become more
  complex.

- The HTML page is a little heavy, it includes one HTML file, one CSS
  file, one SVG file, and one JavaScript file. This is all necessary to
  implement the redirecting page design as given.

  The CSS could be inlined in the HTML.

  The JavaScript could also be inlined but I prefer to keep it separate
  for unit testing, and also for modularity. The code as it is is very
  simple and doesn't require modularisation, but I think it's good to
  set a foundation for how we want to continue developing this if the
  logic required of the JavaScript becomes more complex.

  The design also calls for the Helvetica Neue or Helvetica fonts, I've
  left these font families in the CSS (they'll work if the user happens
  to have the fonts installed) but in the interests of keeping the page
  weight down I haven't included the font in the page. If the user
  doesn't have Helvetica it'll fall back to Arial or Lucida Grande.

- There is no support for browsers without JavaScript. Without
  JavaScript we'd have no way of detecting that the Chrome extension is
  installed (nor would the Chrome extension work without JavaScript) so
  all we could do would be to redirect to Via, but Via is also useless
  without JavaScript.

  Maybe it should redirect to the annotation's standalone page at
  https://hypothes.is/, however _those_ pages currently require
  JavaScript as well (but they could easily be reimplemented not to).

  For now I haven't supported no-JS.

- There's no reporting of stats/logs/crashes to anything yet.

- The configs for Prospector, jshint, jscs etc are duplicated from
  <https://github.com/hypothesis/h>. In the future we may want to move
  these into a separate "Hypothesis Coding Standards" repo and figure
  out how to share them between repos. For now I've just duplicated
  them. (Also `CONTRIBUTING.rst`.)
  • Loading branch information
seanh committed Feb 22, 2016
1 parent b19e1e6 commit afb3c84
Show file tree
Hide file tree
Showing 27 changed files with 915 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.egg-info
*.pyc
node_modules
.coverage
bouncer/static/scripts/bundle.js
.cache
6 changes: 6 additions & 0 deletions .hound.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
javascript:
config_file: .jshintrc
ignore_file: .jshintignore
jscs:
enabled: true
config_file: .jscsrc
23 changes: 23 additions & 0 deletions .jscsrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"preset": "google",
"requireSemicolons": true,

"excludeFiles": [
"node_modules/**",
],
"maxErrors": 10,
"disallowSpacesInAnonymousFunctionExpression": null,
"disallowSpacesInFunctionDeclaration": {
"beforeOpeningRoundBrace": true
},
"disallowSpacesInFunctionExpression": null,
"disallowSpacesInNamedFunctionExpression": {
"beforeOpeningRoundBrace": true
},
"requireSpacesInFunctionDeclaration": null,
"requireSpacesInFunctionExpression": null,
"requireSpacesInAnonymousFunctionExpression": {
"beforeOpeningRoundBrace": true,
"beforeOpeningCurlyBrace": true
}
}
1 change: 1 addition & 0 deletions .jshintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
33 changes: 33 additions & 0 deletions .jshintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"bitwise": true,
"curly": true,
"eqeqeq": true,
"forin": true,
"freeze": true,
"latedef": "nofunc",
"maxcomplexity": 10,
"strict": "global",
"undef": true,
"unused": true,
"globals": {
"chrome": false,
"chai": false,
"sinon": false,
"JSON": false
},
"browser": true,
"browserify": true,
"mocha": true,
"phantom": true,
"predef": [
"assert",
"after",
"afterEach",
"before",
"beforeEach",
"describe",
"it",
"require",
"sinon"
]
}
43 changes: 43 additions & 0 deletions .prospector.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
output-format: grouped
strictness: veryhigh
doc-warnings: true
max-line-length: 79
pep8:
full: true
pylint:
enable:
- relative-import
disable:
- line-too-long # PEP8 checks this and doesn't complain about
# unavoidable long lines (such as URLs).
- R0903 # Too few public methods
- W0142 # Used * or ** magic
options:
# Some good names that pylint would otherwise reject:
#
# - _: placeholder
# - i,j,k: counters
# - k,v: dict iteration
# - db,fn: common abbreviations
# - fp: python idiom for file handles
#
# Some good "constant" names that pylint would otherwise reject:
#
# - log: common in "log = logging.getLogger(__name__)" pattern
# - parser: common in modules that use argparse
# - id: Commonly used as a class attribute / database column name in
# sqlalchemy model classes. Note that if you use id as the name of
# a local variable or parameter, pylint will still complain that
# you're shadowing the builtin.
#
good-names: _,i,j,k,v,e,db,fn,fp,log,parser,id
pep257:
disable:
- D100 # Missing docstring in public module
- D101 # Missing docstring in public class
- D102 # Missing docstring in public method
- D103 # Missing docstring in public function
pyroma:
run: true
ignore-patterns:
- '.*\.egg'
12 changes: 12 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
sudo: false
language:
- python
python:
- "3.5"
install:
- make deps
- pip install coveralls
script:
- make test
after_success:
- coveralls
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
0.0.1
=====

- First release, working proof of concept.
1 change: 1 addition & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
For now, see the `hypothesis/h contributing guide <https://github.com/hypothesis/h/blob/master/CONTRIBUTING.rst>`_.
30 changes: 30 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
FROM frolvlad/alpine-python3
MAINTAINER Hypothes.is Project and contributors

# Install system build and runtime dependencies.
RUN apk add --update \
nodejs \
&& rm -rf /var/cache/apk/*

# Create the bouncer user, group, home directory and package directory.
RUN addgroup -S bouncer \
&& adduser -S -G bouncer -h /var/lib/bouncer bouncer
WORKDIR /var/lib/bouncer

# Copy packaging
COPY README.rst package.json setup.* ./

# Install application dependencies.
RUN npm install --production \
&& pip install --no-cache-dir -e . \
&& npm cache clean

# Copy the rest of the application files
COPY bouncer ./bouncer/

# Persist the static directory.
VOLUME ["/var/lib/bouncer/bouncer/static"]

# Start the web server by default
USER bouncer
CMD ["gunicorn", "bouncer:app()"]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Copyright (c) 2016 Hypothes.is Project and contributors

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
deps:
pip install --upgrade pip
pip install --upgrade wheel
pip install -e .[dev]
npm install

dev:
PYRAMID_RELOAD_TEMPLATES=1 gunicorn --reload "bouncer:app()"

docker:
docker build -t hypothesis/bouncer .

test:
py.test --cov=bouncer bouncer
./node_modules/karma/bin/karma start karma.config.js
71 changes: 71 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
.. image:: https://travis-ci.org/hypothesis/bouncer.svg?branch=master
:target: https://travis-ci.org/hypothesis/bouncer
:alt: Build Status


Hypothesis Direct-Link Bouncer Service
======================================

Production Deployment
---------------------

Requirements:

* `Docker <https://www.docker.com/>`_
* `Git <https://git-scm.com/>`_
* `Make <https://www.gnu.org/software/make/>`_

Build the ``hypothesis/bouncer`` Docker image and run bouncer in a Docker
container:

.. code-block:: bash
git clone https://github.com/hypothesis/bouncer.git
cd bouncer
make docker
docker run --net host -p 8000:8000 hypothesis/bouncer
Development
-----------

Requirements:

* `Git <https://git-scm.com/>`_
* `Make <https://www.gnu.org/software/make/>`_
* `Python 3.5 <https://www.python.org/>`_
* `Virtualenv <https://virtualenv.readthedocs.org/>`_

Install:

.. code-block:: bash
git clone https://github.com/hypothesis/bouncer.git
cd bouncer
virtualenv -p python3.5 .
. bin/activate
make deps
Run the tests:

.. code-block:: bash
make test
To debug the JavaScript tests in a browser, run:

.. code-block:: bash
./node_modules/karma/bin/karma start --no-single-rin
and open http://localhost:9876/ in your browser.

To run a dev instance on port 8000:

.. code-block:: bash
export CHROME_EXTENSION_ID=<id_of_your_local_dev_build_of_the_hypothesis_chrome_extension>
make dev
Other environment variables can be used to change other settings, see
`__init__.py <bouncer/__init__.py>`_.
43 changes: 43 additions & 0 deletions bouncer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import os

import pyramid.config


def settings():
"""
Return the app's configuration settings as a dict.
Settings are read from environment variables and fall back to hardcoded
defaults if those variables aren't defined.
"""
via_base_url = os.environ.get("VIA_BASE_URL", "https://via.hypothes.is")
if via_base_url.endswith("/"):
via_base_url = via_base_url[:-1]

return {
"chrome_extension_id": os.environ.get(
"CHROME_EXTENSION_ID",
"bjfhmglciegochdpefhhlphglcehbmek"),
"elasticsearch_host": os.environ.get("ELASTICSEARCH_HOST",
"localhost"),
"elasticsearch_index": os.environ.get("ELASTICSEARCH_INDEX",
"annotator"),
"elasticsearch_port": os.environ.get("ELASTICSEARCH_PORT",
"9200"),
"hypothesis_url": os.environ.get("HYPOTHESIS_URL",
"https://hypothes.is"),
"via_base_url": via_base_url,
}


def app():
"""Configure and return the WSGI app."""
config = pyramid.config.Configurator(settings=settings())
config.add_static_view(name="static", path="static")
config.include("pyramid_jinja2")
config.registry.settings["jinja2.filters"] = {
"static_url": "pyramid_jinja2.filters:static_url_filter"
}
config.include("bouncer.views")
return config.make_wsgi_app()
63 changes: 63 additions & 0 deletions bouncer/scripts/redirect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use strict';

/** The ID of the Chrome extension we want to talk to. */
var EDITOR_EXTENSION_ID = 'oldbkmekfdjiffgkconlamcngmkioffd';

/** Return the settings object that the server injected into the page. */
function getSettings(document) {
return JSON.parse(
document.querySelector('script.js-bouncer-settings').textContent);
}

/** Navigate the browser to the given URL. */
function navigateTo(url) {
window.location = url;
}

/** Navigate the browser to the requested annotation.
*
* If the browser is Chrome and our Chrome extension is installed then
* navigate to the annotation's direct link for the Chrome extension.
* If the Chrome extension isn't installed or the browser isn't Chrome then
* navigate to the annotation's Via direct link.
*
*/
function redirect(navigateToFn) {
// Use the test navigateTo() function if one was passed in, the real
// navigateTo() otherwise.
navigateTo = navigateToFn || navigateTo;

var settings = getSettings(document);

if (window.chrome && chrome.runtime && chrome.runtime.sendMessage) {
// The user is using Chrome, redirect them to our Chrome extension if they
// have it installed, via otherwise.
chrome.runtime.sendMessage(
EDITOR_EXTENSION_ID,
{type: 'ping'},
function (response) {
var url;
if (response) {
// The user has our Chrome extension installed :)
url = settings.extensionUrl;
} else {
// The user doesn't have our Chrome extension installed :(
url = settings.viaUrl;
}
navigateTo(url);
}
);
} else {
// The user isn't using Chrome, just redirect them to Via.
navigateTo(settings.viaUrl);
}
}

if (typeof module === 'object') {
// Browserify is present, this file must be being run by the tests.
module.exports = redirect;
} else {
// Browserify is not present, this file must be being run in development or
// production.
redirect();
}
Loading

0 comments on commit afb3c84

Please sign in to comment.