commit f7abd808a31625a433317ac74dffa478bbc2aa37 Author: Frederic Date: Fri Mar 2 12:33:41 2018 +0100 initial commit diff --git a/journalmarks/__init__.py b/journalmarks/__init__.py new file mode 100644 index 0000000..f320bc1 --- /dev/null +++ b/journalmarks/__init__.py @@ -0,0 +1 @@ +from .journalmarks import app diff --git a/journalmarks/journalmarks.py b/journalmarks/journalmarks.py new file mode 100644 index 0000000..5ea9b5a --- /dev/null +++ b/journalmarks/journalmarks.py @@ -0,0 +1,194 @@ + +import re +import json +import random +import string +import hashlib +import datetime +import functools +from urllib.parse import urlparse +from flask import Flask, request, session, render_template, url_for, redirect +from flask import g as flask_g +from peewee import CharField, DateTimeField, ForeignKeyField, DoesNotExist +from playhouse.flask_utils import FlaskDB +from passlib.hash import pbkdf2_sha256 + + +app = Flask(__name__) +app.config.from_envvar('JOURNALMARKS_SETTINGS', silent=True) +app.secret_key = app.config['SECRET_KEY'] +db_wrapper = FlaskDB(app) + + +class User(db_wrapper.Model): + username = CharField(unique=True) + password = CharField() + + +class AccessToken(db_wrapper.Model): + token = CharField() + redeemed = DateTimeField(null=True, default=None) + + +class Journalmark(db_wrapper.Model): + user = ForeignKeyField(User, backref='journalmarks') + created = DateTimeField(default=datetime.datetime.now()) + tag = CharField(unique=True) + content = CharField() + + +def login_required(f): + @functools.wraps(f) + def decorated_function(*args, **kwargs): + if 'username' not in session: + return redirect(url_for('login', next=request.path)) + try: + u = User.select().where(User.username == session['username']).get() + except DoesNotExist: + return ('user not found', 400, None) + flask_g.user = u + return f(*args, **kwargs) + return decorated_function + + +@app.cli.command('initdb') +def initdb_command(): + db_wrapper.database.create_tables([User, AccessToken, Journalmark]) + + +@app.route('/register', methods=['POST']) +def register(): + if len(request.form) != 3: + return ('wrong number of fields', 400, None) + try: + token = request.form['token'] + username = request.form['username'] + password_hash = request.form['password_hash'] + except ValueError: + return ('invalid field names', 400, None) + if not re.match('^[a-zA-Z0-9]*$', token): + return ('invalid token', 400, None) + if not re.match('^[a-zA-Z0-9._]*$', username): + return ('invalid username', 400, None) + if not re.match('^[a-f0-9]{64}$', password_hash): + return ('invalid password hash', 400, None) + password_hash = pbkdf2_sha256.encrypt(password_hash) + + try: + at = AccessToken.select().where(AccessToken.token == token).get() + except DoesNotExist: + return ('token does not exist', 400, None) + + if at.redeemed is not None: + return ('token already used', 400, None) + + if User.select().where(User.username == username).count() > 0: + return ('user already exists', 400, None) + + with db_wrapper.database.atomic(): + u = User(username=username, password=password_hash) + at.redeemed = datetime.datetime.now() + u.save() + at.save() + return json.dumps('ok') + + +@app.route('/login', methods=['GET']) +def show_login(): + if 'next' in request.args and urlparse(request.args['next']).netloc == '': + next = request.args['next'] + else: + next = url_for('index') + return render_template('login.html', next=next) + + +@app.route('/login', methods=['POST']) +def login(): + print(request.json) + if len(request.json) != 2: + return ('wrong number of fields', 400, None) + try: + username = request.json['username'] + password_hash = request.json['password_hash'] + except ValueError: + return ('invalid field names', 400, None) + if not re.match('^[a-zA-Z0-9._]*$', username): + return ('invalid username', 400, None) + if not re.match('^[a-f0-9]{64}$', password_hash): + return ('invalid password hash', 400, None) + + try: + u = User.select().where(User.username == username).get() + except DoesNotExist: + return ('invalid credentials', 400, None) + if not pbkdf2_sha256.verify(password_hash, u.password): + return ('invalid credentials', 400, None) + + session['username'] = u.username + session.permanent = True + return json.dumps('ok') + + +@app.route('/logout') +@login_required +def logout(): + del session['username'] + return render_template('logout.html') + + +@app.route('/') +@login_required +def index(): + return render_template('index.html') + + +@app.route('/create', methods=['POST']) +@login_required +def create(): + if len(request.json) != 1 or 'content' not in reqjest.json: + return ('invalid fields', 400, None) + content = request.json['content'] + + tag = None + while tag is None: + tag = ''.join([ random.choice(string.ascii_lowercase + string.digits) \ + for i in range(4) ]) + if Journalmark.select().where(Journalmark.tag == tag).count() > 0: + tag = None + j = Journalmark(user=flask_g.user, tag=tag, content=json.dumps(content)) + j.save() + return json.dumps(tag) + + +@app.route('/overview', methods=['GET']) +@login_required +def overview(): + return render_template('overview.html') + + +@app.route('/overview', methods=['POST']) +@login_required +def overview_get_journalmarks(): + jms = Journalmark.select().where(Journalmark.user == flask_g.user) \ + .order_by(Journalmark.created.desc()) + ret = [] + for j in jms: + try: + content = json.loads(j.content) + except ValueError: + content = None + ret.append({'created': j.created.isoformat(), 'tag': j.tag, 'content': content}) + return json.dumps(ret) + + +@app.route('/') +@login_required +def get_journalmark(tag): + print(tag) + try: + j = Journalmark.select().where( + (Journalmark.tag == tag) & (Journalmark.user == flask_g.user) + ).get() + except DoesNotExist: + return ('tag not found', 404, None) + return render_template('get_journalmark.html', j=j) diff --git a/journalmarks/static/journalmarks.js b/journalmarks/static/journalmarks.js new file mode 100644 index 0000000..7610335 --- /dev/null +++ b/journalmarks/static/journalmarks.js @@ -0,0 +1,186 @@ +var enc = new TextEncoder("utf-8"); +var dec = new TextDecoder("utf-8"); + +var userkey; + + +function computeKey(username, password) { + return new Promise(function(resolve, reject) { + window.crypto.subtle.importKey( + 'raw', + password, + {name: 'PBKDF2'}, + false, + ['deriveBits', 'deriveKey'] + ).then(function(key) { + return window.crypto.subtle.deriveKey( + {name: 'PBKDF2', salt: username, iterations: 4096, hash: 'SHA-256'}, + key, + {name: 'AES-GCM', length: 256}, + true, + [ "encrypt", "decrypt" ] + ); + }).then(function(aeskey) { + resolve(aeskey); + }).catch(function(error) { + reject(error); + }); + }); +} + + +function storeKey(aeskey) { + return new Promise(function(resolve, reject) { + window.crypto.subtle.exportKey('jwk', aeskey).then(function(jwk) { + window.localStorage['userkey'] = JSON.stringify(jwk); + resolve(); + }).catch(function(error) { + reject(error); + }); + }); +} + + +function loadKey() { + return new Promise(function(resolve, reject) { + var jwk = JSON.parse(window.localStorage['userkey']); + window.crypto.subtle.importKey( + 'jwk', + jwk, + {name: 'AES-GCM', length: 256}, + true, + ['encrypt', 'decrypt'] + ).then(function(aeskey) { + resolve(aeskey); + }).catch(function(error) { + reject(error); + }); + }); +} + + +function deleteKey() { + return new Promise(function(resolve, reject) { + window.localStorage.removeItem('userkey'); + }); +} + + +function encrypt(data, key) { + return new Promise(function(resolve, reject) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + window.crypto.subtle.encrypt({name: 'AES-GCM', iv: iv}, key, data).then(function(encr) { + resolve([ + base64js.fromByteArray(new Uint8Array(encr)), + base64js.fromByteArray(iv) + ]); + }).catch(function(error) { + reject(error); + }); + }); +} + + +function decrypt(edata, key) { + return new Promise(function(resolve, reject) { + const encr = base64js.toByteArray(edata[0]); + const iv = base64js.toByteArray(edata[1]); + window.crypto.subtle.decrypt({name: 'AES-GCM', iv: iv}, key, encr).then(function(decr) { + resolve(decr); + }).catch(function(error) { + reject(error); + }); + }); +} + + + +function journalmarks_loadkey() { + return new Promise(function(resolve, reject) { + loadKey().then(function(aeskey) { + userkey = aeskey; + resolve(); + }).catch(function(error) { + reject(error); + }); + }); +} + + +function journalmarks_initkey(username, password) { + return new Promise(function(resolve, reject) { + computeKey(enc.encode(username), enc.encode(password)) + .then(storeKey).then(function() { + resolve(); + }).catch(function(error) { + reject(error); + }); + }); +} + + +function journalmarks_encrypturl(url) { + return new Promise(function(resolve, reject) { + var bytes = enc.encode(JSON.stringify({url: url})); + encrypt(bytes, userkey).then(function(encurl) { + resolve(encurl); + }).catch(function(error) { + reject(error); + }); + }); +} + +function journalmarks_decrypturl(encurl) { + return new Promise(function(resolve, reject) { + decrypt(encurl, userkey).then(function(bytes) { + resolve(JSON.parse(dec.decode(bytes))); + }).catch(function(error) { + reject(error); + }); + }); +} + + +function post_object(url, data) { + return new Promise(function(resolve, reject) { + var req = new XMLHttpRequest(); + req.open('POST', url); + req.setRequestHeader('Content-type', 'application/json'); + req.onload = function() { + if (req.status == 200) + resolve(JSON.parse(req.response)); + else + reject(Error(req.statusText)); + }; + req.onerror = function() { + reject(Error("Network Error")); + }; + req.send(JSON.stringify(data)); + }); +} + + +// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest +function sha256(str) { + var buffer = new TextEncoder("utf-8").encode(str); + return crypto.subtle.digest("SHA-256", buffer).then(function (hash) { + return hex(hash); + }); +} +function hex(buffer) { + var hexCodes = []; + var view = new DataView(buffer); + for (var i = 0; i < view.byteLength; i += 4) { + // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time) + var value = view.getUint32(i) + // toString(16) will give the hex representation of the number without padding + var stringValue = value.toString(16) + // We use concatenation and slice for padding + var padding = '00000000' + var paddedValue = (padding + stringValue).slice(-padding.length) + hexCodes.push(paddedValue); + } + + // Join all the hex strings into one + return hexCodes.join(""); +} diff --git a/journalmarks/templates/base.html b/journalmarks/templates/base.html new file mode 100644 index 0000000..377df57 --- /dev/null +++ b/journalmarks/templates/base.html @@ -0,0 +1,7 @@ + +Journalmarks + + + +{% block script %}{% endblock %} +{% block body %}{% endblock %} diff --git a/journalmarks/templates/get_journalmark.html b/journalmarks/templates/get_journalmark.html new file mode 100644 index 0000000..f41bff6 --- /dev/null +++ b/journalmarks/templates/get_journalmark.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block script %} + +{% endblock %} +{% block body %} + +{% endblock %} diff --git a/journalmarks/templates/index.html b/journalmarks/templates/index.html new file mode 100644 index 0000000..8f14853 --- /dev/null +++ b/journalmarks/templates/index.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block script %} + +{% endblock %} +{% block body %} +Welcome! +

+overview +logout +

+ + + + +{% endblock %} diff --git a/journalmarks/templates/login.html b/journalmarks/templates/login.html new file mode 100644 index 0000000..d2be261 --- /dev/null +++ b/journalmarks/templates/login.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block script %} + +{% endblock %} +{% block body %} +

Login

+ + + + +{% endblock %} diff --git a/journalmarks/templates/logout.html b/journalmarks/templates/logout.html new file mode 100644 index 0000000..55e5f53 --- /dev/null +++ b/journalmarks/templates/logout.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block script %} + +{% endblock %} +{% block body %} +Goodbye! +{% endblock %} diff --git a/journalmarks/templates/overview.html b/journalmarks/templates/overview.html new file mode 100644 index 0000000..1372bf3 --- /dev/null +++ b/journalmarks/templates/overview.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block script %} + + +{% endblock %} +{% block body %} +
+

+Tag:
+URL:
+Created: +

+
+{% endblock %}