diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..175b463 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[report] +show_missing = True +skip_covered = True diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..ab5af1a --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +exclude = bin,lib diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6abbe82 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: python +python: + - "2.7" + - "3.5" +install: "pip install -r requirements.txt" +script: + - make test +after_success: + - coveralls diff --git a/Makefile b/Makefile index 5911500..a67a006 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,9 @@ deps: bin/python .PHONY: run run: - bin/python polycules.py + python polycules.py + +.PHONY: test +test: + flake8 --config=.flake8 + nosetests --with-coverage --cover-erase --verbosity=2 --cover-package=polycules,model diff --git a/README.md b/README.md index 38e562c..e5015eb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # polycul.es + +[![Build Status](https://travis-ci.org/makyo/polycul.es.svg?branch=master)](https://travis-ci.org/makyo/polycul.es) +[![Coverage Status](https://coveralls.io/repos/github/makyo/polycul.es/badge.svg?branch=master)](https://coveralls.io/github/makyo/polycul.es?branch=master) + Graphing polyamorous relationships with force directed layouts. diff --git a/delete_legacy.py b/delete_legacy.py new file mode 100644 index 0000000..137088e --- /dev/null +++ b/delete_legacy.py @@ -0,0 +1,31 @@ +from six.moves import input +import sys + +from model import Polycule +import polycules + + +db = polycules.connect_db() + +id = input('Enter the ID of the polycule to delete > ') +polycule = Polycule.get(db, int(id), None, force=True) +if polycule is None: + print('\nNo polycule with that ID found.') + db.close() + sys.exit(1) + +if polycule.delete_pass is not None: + confirm = input( + '\nPolycule has a delete password, are you sure? [y/n] > ') + if confirm[0].lower() != 'y': + print('\nOkay, exiting without deleting.') + db.close() + sys.exit(1) + +confirm = input( + '\nAre you sure you want to delete {}? [y/n] > '.format(id)) +if confirm[0].lower() == 'y': + print('\nDeleting {}...'.format(id)) + polycule.delete(None, force=True) + print('Polycule deleted.') +db.close() diff --git a/schema.sql b/migrations/000-initial.sql similarity index 54% rename from schema.sql rename to migrations/000-initial.sql index 4be74e2..39dec1f 100644 --- a/schema.sql +++ b/migrations/000-initial.sql @@ -1,5 +1,4 @@ -drop table if exists polycules; -create table polycules ( +create table if not exists polycules ( id integer primary key autoincrement, graph text not null ); diff --git a/migrations/001-add-view-pass.sql b/migrations/001-add-view-pass.sql new file mode 100644 index 0000000..82fb0ed --- /dev/null +++ b/migrations/001-add-view-pass.sql @@ -0,0 +1,2 @@ +alter table polycules + add column view_pass char(60); diff --git a/migrations/002-add-delete-pass.sql b/migrations/002-add-delete-pass.sql new file mode 100644 index 0000000..aa1b801 --- /dev/null +++ b/migrations/002-add-delete-pass.sql @@ -0,0 +1,2 @@ +alter table polycules + add column delete_pass char(60); diff --git a/model.py b/model.py new file mode 100644 index 0000000..59a58fb --- /dev/null +++ b/model.py @@ -0,0 +1,71 @@ +import bcrypt + + +class Polycule(object): + def __init__(self, db=None, id=None, graph=None, view_pass=None, + delete_pass=None): + self._db = db + self.id = id + self.graph = graph + self.view_pass = view_pass + self.delete_pass = delete_pass + + @classmethod + def get(cls, db, id, password, force=False): + result = db.execute('select * from polycules where id = ?', [id]) + graph = result.fetchone() + if graph is None: + return None + polycule = Polycule( + db=db, + id=graph[0], + graph=graph[1], + view_pass=graph[2], + delete_pass=graph[3]) + if not force and ( + polycule.view_pass is not None and + not bcrypt.checkpw(password.encode('utf-8'), + polycule.view_pass.encode('utf-8'))): + raise Polycule.PermissionDenied + return polycule + + @classmethod + def create(cls, db, graph, raw_view_pass, raw_delete_pass): + if raw_view_pass is not None: + view_pass = bcrypt.hashpw( + raw_view_pass.encode(), bcrypt.gensalt()).decode() + else: + view_pass = None + if raw_delete_pass is not None: + delete_pass = bcrypt.hashpw( + raw_delete_pass.encode(), bcrypt.gensalt()).decode() + else: + delete_pass = None + result = db.execute('select count(*) from polycules where graph = ?', + [graph]) + existing = result.fetchone()[0] + if existing != 0: + raise Polycule.IdenticalGraph + cur = db.cursor() + result = cur.execute('''insert into polycules + (graph, view_pass, delete_pass) values (?, ?, ?)''', [ + graph, + view_pass, + delete_pass, + ]) + db.commit() + return Polycule.get(db, result.lastrowid, None, force=True) + + def delete(self, password, force=False): + if not force and not bcrypt.checkpw(password.encode('utf-8'), + self.delete_pass.encode('utf-8')): + raise Polycule.PermissionDenied + cur = self._db.cursor() + cur.execute('delete from polycules where id = ?', [self.id]) + self._db.commit() + + class PermissionDenied(Exception): + pass + + class IdenticalGraph(Exception): + pass diff --git a/polycules.py b/polycules.py index c1bfa54..8587d5f 100644 --- a/polycules.py +++ b/polycules.py @@ -1,10 +1,9 @@ import base64 import os import sqlite3 - from contextlib import closing + from flask import ( - abort, Flask, g, redirect, @@ -13,6 +12,8 @@ session, ) +from model import Polycule + # Config DATABASE = 'dev.db' DEBUG = True @@ -28,11 +29,14 @@ def connect_db(): return sqlite3.connect(app.config['DATABASE']) -def init_db(): +def migrate(): + migrations_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'migrations') with closing(connect_db()) as db: - with app.open_resource('schema.sql', mode='r') as f: - db.cursor().executescript(f.read()) - db.commit() + for filename in os.listdir(migrations_dir): + with open(os.path.join(migrations_dir, filename), 'rb') as f: + db.cursor().execute(f.read()) def generate_csrf_token(): @@ -40,6 +44,7 @@ def generate_csrf_token(): session['_csrf_token'] = base64.b64encode(os.urandom(12)) return session['_csrf_token'] + app.jinja_env.globals['csrf_token'] = generate_csrf_token @@ -48,7 +53,7 @@ def before_request(): if request.method == 'POST': token = session.pop('_csrf_token', None) if not token or token != request.form.get('_csrf_token'): - abort(403) + return render_template('error.jinja2', error='Token expired :(') g.db = connect_db() @@ -66,40 +71,42 @@ def front(): return render_template('front.jinja2') -@app.route('/') +@app.route('/', methods=['GET', 'POST']) def view_polycule(polycule_id): """ View a polycule. """ - cur = g.db.execute('select graph from polycules where id = ?', - [polycule_id]) - graph = cur.fetchone() - if graph is None: - abort(404) - return render_template('view_polycule.jinja2', graph=graph[0], id=polycule_id) + try: + polycule = Polycule.get(g.db, polycule_id, + request.form.get('view_pass', b'')) + except Polycule.PermissionDenied: + return render_template('view_auth.jinja2') + if polycule is None: + return render_template('error.jinja2', error='Polycule not found :(') + return render_template('view_polycule.jinja2', polycule=polycule) @app.route('/embed/') def embed_polycule(polycule_id): """ View just a polycule for embedding in an iframe. """ - cur = g.db.execute('select graph from polycules where id = ?', - [polycule_id]) - graph = cur.fetchone() - if graph is None: - abort(404) - return render_template('embed_polycule.jinja2', graph=graph[0]) + polycule = Polycule.get(g.db, polycule_id, request.form.get('view_pass')) + if polycule is None: + return render_template('error.jinja2', error='Polycule not found :(') + return render_template('embed_polycule.jinja2', graph=polycule.graph) -@app.route('/inherit/') +@app.route('/inherit/', methods=['GET', 'POST']) def inherit_polycule(polycule_id): """ Take a given polycule and enter create mode, with that polycule's contents already in place """ - cur = g.db.execute('select graph from polycules where id = ?', - [polycule_id]) - graph = cur.fetchone() - if graph is None: - abort(404) - return render_template('create_polycule.jinja2', inherited=graph[0]) + try: + polycule = Polycule.get(g.db, polycule_id, + request.form.get('view_pass', b'')) + except Polycule.PermissionDenied: + return render_template('view_auth.jinja2') + if polycule is None: + return render_template('error.jinja2', error='Polycule not found :(') + return render_template('create_polycule.jinja2', inherited=polycule.graph) @app.route('/create') @@ -117,11 +124,28 @@ def create_polycule(): def save_polycule(): """ Save a created polycule. """ # TODO check json encoding, check size - g.db.execute('insert into polycules (graph) values (?)', - [request.form['graph']]) - g.db.commit() - cur = g.db.execute('select id from polycules order by id desc limit 1') - return redirect('/{}'.format(cur.fetchone()[0])) + try: + polycule = Polycule.create( + g.db, + request.form['graph'], + request.form.get('view_pass', b''), + request.form.get('delete_pass', b'')) + except Polycule.IdenticalGraph: + return render_template('error.jinja2', error='An identical polycule ' + 'to the one you submitted already exists!') + return redirect('/{}'.format(polycule.id)) + + +@app.route('/delete/', methods=['POST']) +def delete_polycule(polycule_id): + polycule = Polycule.get(g.db, polycule_id, None, force=True) + if polycule is None: + return render_template('error.jinja2', error='Polycule not found :(') + try: + polycule.delete(request.form.get('delete_pass', b'')) + except Polycule.PermissionDenied: + return render_template('view_auth.jinja2', polycule_id=polycule_id) + return redirect('/') if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index 60fef76..04430c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,20 @@ +bcrypt==3.1.1 +cffi==1.9.1 +configparser==3.5.0 +coverage==4.0.3 +enum34==1.1.6 +flake8==3.2.1 Flask==0.10.1 itsdangerous==0.24 Jinja2==2.8 MarkupSafe==0.23 +mccabe==0.5.2 +nose==1.3.7 +pycodestyle==2.2.0 +pycparser==2.17 +pyflakes==1.3.0 +python-coveralls==2.9.0 +PyYAML==3.12 +requests==2.12.1 +six==1.10.0 Werkzeug==0.11.4 -wheel==0.24.0 diff --git a/static/build.js b/static/build.js index 55db1f2..f5f1f06 100644 --- a/static/build.js +++ b/static/build.js @@ -329,3 +329,8 @@ svg.on('mousedown', mousedown) d3.select(window) .on('keydown', keydown) .on('keyup', keyup); +d3.select('.expand-help').on('click', function(e) { + d3.event.preventDefault(); + var body = d3.select('.instructions .body'); + body.classed('hidden', !body.classed('hidden')); +}); diff --git a/static/style.css b/static/style.css index cbe84c9..a3ce703 100644 --- a/static/style.css +++ b/static/style.css @@ -2,6 +2,7 @@ body, input, button { font-family: "Ubuntu Mono", monospace; + color: #333; } a, @@ -21,6 +22,26 @@ a:hover { margin: 0 auto; } +form { + padding-top: 1em; + text-align: center; +} + +.form-group { + display: inline-block; + width: 45%; + margin-right: 5%; + text-align: left; +} + +.form-group:last-of-type { + margin-right: 0; +} + +.help-block { + color: #999; +} + #graph { position: relative; border: 2px solid #eee; @@ -47,6 +68,17 @@ a:hover { left: 1em; } +#graph .instructions .expand-help .caret { + display: inline-block; + width: 0; + height: 0; + vertical-align: middle; + border-top: 5px dashed; + border-top: 5px solid ~"\9"; + border-right: 5px solid transparent; + border-left: 5px solid transparent; +} + #graph .link line { stroke: rgba(0,0,0,0.25); cursor: pointer; diff --git a/templates/create_polycule.jinja2 b/templates/create_polycule.jinja2 index ab6a745..e211e7c 100644 --- a/templates/create_polycule.jinja2 +++ b/templates/create_polycule.jinja2 @@ -7,12 +7,14 @@ {% block content %}
-

Polycule graphs are public
and not deletable, keep that in mind

-

Click to create nodes

-

Click and drag to create links

-

Hold ctrl to drag nodes

-

Click to select and edit
nodes and links

-

Known bugs

+

Help

+