diff options
Diffstat (limited to 'fbin')
-rw-r--r-- | fbin/__init__.py | 5 | ||||
-rw-r--r-- | fbin/api.py | 45 | ||||
-rw-r--r-- | fbin/db.py | 2 | ||||
-rwxr-xr-x | fbin/fbin.py | 80 | ||||
-rw-r--r-- | fbin/templates/api.html | 90 | ||||
-rw-r--r-- | fbin/templates/base.html | 1 | ||||
-rw-r--r-- | fbin/templates/help.html | 30 |
7 files changed, 205 insertions, 48 deletions
diff --git a/fbin/__init__.py b/fbin/__init__.py index bdeaa5d..6c6a9f5 100644 --- a/fbin/__init__.py +++ b/fbin/__init__.py @@ -30,10 +30,9 @@ def context_processors(): } with app.app_context(): - # TODO: Enable API when done from .fbin import app as fbin - #from .api import app as api + from .api import app as api from .login import login_manager app.register_blueprint(fbin) - #app.register_blueprint(api, url_prefix = '/api') + app.register_blueprint(api, url_prefix = '/api') login_manager.init_app(app) diff --git a/fbin/api.py b/fbin/api.py index e652019..4f605f0 100644 --- a/fbin/api.py +++ b/fbin/api.py @@ -1,17 +1,16 @@ +import datetime import functools -from flask import Blueprint, current_app, request, jsonify +from flask import Blueprint, current_app, request, jsonify, abort, g from flask.views import MethodView from flask_login import current_user +import jwt from . import db -# FIXME -from .fbin import get_file +from .fbin import upload as fbin_upload, get_file app = Blueprint('api', __name__) -# TODO: Implement this stuff. - def makejson(f): @functools.wraps(f) def wrapper(*args, **kwargs): @@ -21,6 +20,30 @@ def makejson(f): return r return wrapper +@app.before_request +def authenticate(): + g.user = None + if not 'Authorization' in request.headers: + abort(403) + scheme, token = request.headers['Authorization'].split(None, 1) + if scheme != 'Bearer': + abort(400) + try: + token = jwt.decode(token, current_app.config['SECRET_KEY'], issuer = request.url_root) + except jwt.InvalidTokenError: + abort(403) + with db.session_scope() as s: + try: + user = s.query(db.User).filter(db.User.id == token['sub']).one() + token_datetime = datetime.datetime.fromtimestamp(token['iat']) + # If token was issued before api_key_date was updated, consider it invalid. + if token_datetime < user.api_key_date: + abort(403) + else: + g.user = user + except db.NoResultFound: + abort(403) + def api_login_required(f): def wrapper(*args, **kwargs): if not current_user.is_authenticated: @@ -31,6 +54,10 @@ def api_login_required(f): return f(*args, **kwargs) return wrapper +@app.route('/upload', methods = ['POST']) +def upload(): + return fbin_upload(api = True, user = g.user) + class FileAPI(MethodView): decorators = [api_login_required, makejson] @@ -57,6 +84,10 @@ class FileAPI(MethodView): def delete(self, hash): pass -file_api_view = FileAPI.as_view('file_api') -app.add_url_rule('/file/<hash>', view_func = file_api_view, methods = ['PUT', 'DELETE']) +# TODO: Add back FileAPI when ready. +#file_api_view = FileAPI.as_view('file_api') +#app.add_url_rule('/file/<hash>', view_func = file_api_view, methods = ['PUT', 'DELETE']) +@app.route('/test') +def test(): + return g.user.username @@ -1,4 +1,5 @@ from contextlib import contextmanager +import datetime import mimetypes import os @@ -20,6 +21,7 @@ class User(Base): id = Column(Integer, primary_key = True) username = Column(String, unique = True, index = True) jab_id = Column(String(24), unique = True, index = True) + api_key_date = Column(DateTime, default = datetime.datetime.utcnow) files = relation('File', backref = 'user', order_by = 'File.date.desc()') def __init__(self, username, jab_id): diff --git a/fbin/fbin.py b/fbin/fbin.py index 708243e..7a97194 100755 --- a/fbin/fbin.py +++ b/fbin/fbin.py @@ -115,18 +115,40 @@ def index(): @app.route('/u') @app.route('/upload', methods = ['GET', 'POST']) -def upload(): +def upload(api=False, user=None): + def error(message): + if api: + return jsonify({ + 'status': False, + 'message': message, + }) + elif old_api: + return 'ERROR {}'.format(message) + else: + flash(message, 'warning') + return render_template('upload.html', **context) + context = { 'title': 'Upload', } + + old_api = bool(request.form.get('api')) + if request.method == 'GET': + if api or old_api: + # API calls shouldn't use GET. + abort(405) return render_template('upload.html', **context) - if not current_user.is_authenticated and not current_app.config.get('ALLOW_ANONYMOUS_UPLOADS'): + + if not user and current_user.is_authenticated: + user = current_user.user + + if not user and not current_app.config.get('ALLOW_ANONYMOUS_UPLOADS'): abort(403) + uploaded_file = request.files.get('file') if not uploaded_file or not uploaded_file.filename: - flash('No valid file or filename was provided.', 'warning') - return render_template('upload.html', **context) + return error('No valid file or filename was provided.') if hasattr(uploaded_file.stream, 'file'): temp = None temp_path = uploaded_file.stream.name @@ -134,7 +156,7 @@ def upload(): temp = tempfile.NamedTemporaryFile(prefix = 'upload_', dir = current_app.config['FILE_DIRECTORY'], delete = False) uploaded_file.save(temp.file) temp_path = temp.name - new_file = add_file(temp_path, uploaded_file.filename, current_user.user if current_user.is_authenticated else None, request.remote_addr) + new_file = add_file(temp_path, uploaded_file.filename, user, request.remote_addr) mime = new_file.get_mime_type() # TODO: Apparently TIFF also supports EXIF, test this. @@ -144,7 +166,18 @@ def upload(): # NOTE: The file hash is only used to detect duplicates when uploading, so this should not be a problem. subprocess.call(['mogrify', '-auto-orient', new_file.get_path()]) - if bool(request.form.get('api')): + if api: + return jsonify({ + 'status': True, + 'hash': new_file.hash, + 'urls': { + 'base': url_for('fbin.file', hash = '', _external = True), + 'full': url_for('fbin.file', hash = new_file.hash, filename = new_file.filename, _external = True), + 'ext': url_for('fbin.file', hash = new_file.hash, ext = new_file.ext, _external = True), + 'hash': url_for('fbin.file', hash = new_file.hash, _external = True), + }, + }) + elif old_api: return 'OK {hash}'.format(hash = new_file.hash) else: context = { @@ -346,9 +379,40 @@ def thumb(hash): @app.route('/h') @app.route('/help') def help(): + return redirect(url_for('.api')) + +@app.route('/api') +def api(): context = { - 'title': 'Help', + 'title': 'API', + 'subtitle': 'keys and usage', } - return render_template('help.html', **context) + return render_template('api.html', **context) + +@app.route('/generate-api-key') +def generate_api_key(): + if not current_user.is_authenticated: + abort(403) + now = datetime.datetime.utcnow() + user_id = int(current_user.get_id().split(':')[0]) + data = { + 'iss': request.url_root, + 'iat': now, + 'nbf': now, + 'sub': user_id, + } + token = jwt.encode(data, current_app.config['SECRET_KEY']) + return token + +@app.route('/invalidate-api-keys') +@login_required +def invalidate_api_keys(): + with db.session_scope() as s: + user = current_user.user + s.add(user) + user.api_key_date = datetime.datetime.utcnow() + s.commit() + flash('All API keys invalidated.', 'success') + return redirect(request.referrer) login_manager.login_view = '.login' diff --git a/fbin/templates/api.html b/fbin/templates/api.html new file mode 100644 index 0000000..605fedf --- /dev/null +++ b/fbin/templates/api.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block content %} +<p>To start using the API you need an API key. +{% if current_user.is_authenticated %} +New keys can be generated using the form below. +There is currently no limit to the number of API keys that can be issued. +</p> +<div class="input-group"> + <div class="input-group-btn"> + <button type="button" class="btn btn-default" onclick="generate_api_key(event)"> + <span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> + Generate + </button> + </div> + <input type="text" id="api-key" class="form-control" aria-label="API key" placeholder="API key"> +</div> +</p> + +<p> +API keys don't expire, however previously issued API keys can be made unusable by +<a href="#invalidate-collapse" data-toggle="collapse" aria-expanded="false" aria-controls="invalidate-collapse">invalidating</a> +them. +<div class="collapse" id="invalidate-collapse"> + <div class="alert alert-danger"> + <span class="glyphicon glyphicon-danger" aria-hidden="true"></span> + <strong>Warning!</strong> This cannot be undone. + </div> + <a href="{{ url_for('.invalidate_api_keys') }}" role="button" class="btn btn-danger"> + <span class="glyphicon glyphicon-remove" aria-hidden="true"></span> + Invalidate existing API keys + </a> +</div> +{% else %} +Please <a href="{{ url_for('.login') }}">login</a> to generate an API key. +{% endif %} +</p> + +<h2>Uploading</h2> +<p> +HTTP POST to <code>{{ url_for('api.upload', _external = True) }}</code> with the file to be uploaded in the <code>file</code> field. +The regular URL <code>{{ url_for('.upload', _external = True) }}</code> can also be used, but files uploaded via this URL can't be registered to your account as this requires a valid browser session. +</p> + +<p> +cURL example: +<pre> +$ curl -F file=@image.png {{ url_for('api.upload', _external = True) }} +</pre> +Returns the following JSON, which should be self-explanatory: +<pre> +{ + "hash": "hash", + "status": true, + "urls": { + "base": "{{ url_for('.file', hash = '', _external = True) }}", + "full": "{{ url_for('.file', hash = 'hash', filename = 'image.png', _external = True) }}", + "ext": "{{ url_for('.file', hash = 'hash', ext = '.png', _external = True) }}", + "hash": "{{ url_for('.file', hash = 'hash', _external = True) }}" + } +} +</pre> +</p> + +<p> +To register files to your account, the API key must be specified as the bearer token: +<pre>$ curl -H 'Authorization: Bearer <em>api_key</em>' -F file=@image.png {{ url_for('api.upload', _external = True) }}</pre> +(Replace <em>api_key</em> with a generated API key) +</p> + +<h3>Legacy upload API</h3> +<p> +By using the regular URL and adding <code>api=1</code> you will get machine-readable responses in the form: <code>response result</code> where <code>response</code> is either <code>ERROR</code> or <code>OK</code>, +and <code>result</code> is the file hash in the case of <code>OK</code>, or an error message in the case of <code>ERROR</code>. +The hash can be used to construct URLs in which the paths begin with <code>{{ url_for('.file', hash = 'hash') }}</code> where <code>hash</code> is the hash received. +</p> + +<p> +Any file extension an be appended to the hash, and for convenience the original filename (or whatever filename you prefer) can be appended after an additional slash after the hash. See JSON response above for examples on how to construct URLs. +</p> +{% endblock %} +{% block scripts %} +<script src="{{ url_for('static', filename = 'js/bootstrap.min.js') }}"></script> +<script> +function generate_api_key(event) { + $.get('{{ url_for('.generate_api_key') }}', function(data) { + $('#api-key').val(data); + }); +} +</script> +{% endblock %} diff --git a/fbin/templates/base.html b/fbin/templates/base.html index bab0f7b..dfd6d06 100644 --- a/fbin/templates/base.html +++ b/fbin/templates/base.html @@ -29,6 +29,7 @@ {{ nav_html('.files', 'Files') }} {{ nav_html('.images', 'Images') }} {{ nav_html('.account', 'Account') }} + {{ nav_html('.api', 'API') }} {{ nav_html('.logout', 'Logout') }} {% else %} {{ nav_html('.login', 'Login') }} diff --git a/fbin/templates/help.html b/fbin/templates/help.html deleted file mode 100644 index 899cd78..0000000 --- a/fbin/templates/help.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "base.html" %} -{% block content %} -<div class="alert alert-danger"> - <h2> - <span class="glyphicon glyphicon-alert" aria-hidden="true"></span> - TODO: Update this page. - </h2> -</div> -<div class="alert alert-warning"> - <span class="glyphicon glyphicon-alert" aria-hidden="true"></span> - Everything below this point is outdated and should be disregarded until further notice. -</div> -<s> -<p>Usage: POST to <a href="$scheme://${host}${settings.virtual_root}u">$scheme://${host}${settings.virtual_root}u</a> with filedata given to "file" and original filename to "filename". Login is done by generating a login token and sending it as the cookie "token".</p> -<p>cURL examples, <code>get_token</code>: -<blockquote><pre><code>$ curl $scheme://${host}${settings.virtual_root}a?method=get_token -F username=foo -F password=bar -{"status": true, "message": null, "method": "get_token", "token": "cb42eb38eb516d9dfcaaa742d1da0b3ad454b2bd05a8b4daa6d01e9587d7c759"}</code></pre></blockquote> -Upload using the token: -<blockquote><pre><code>$ curl -b 'token=cb42eb38eb516d9dfcaaa742d1da0b3ad454b2bd05a8b4daa6d01e9587d7c759' -F 'file=@image.png' -F 'filename=image.png' -F 'api=1' $scheme://${host}${settings.virtual_root}u -OK sjLUD</code></pre></blockquote> -To expire the current token: -<blockquote><pre><code>$ curl $scheme://${host}${settings.virtual_root}a?method=expire_token -F token=cb42eb38eb516d9dfcaaa742d1da0b3ad454b2bd05a8b4daa6d01e9587d7c759 -{"status": true, "message": null, "method": "expire_token"}</code></pre></blockquote> -If you get HTTP 417 responses, try adding:<code>-H 'Expect:'</code>.</p> -<p>By adding the key-value pair "api=1" you will get machine-readable responses in the form: <code>response result</code> where <code>response</code> is either <code>ERROR</code> or <code>OK</code>, -and <code>result</code> is the file hash in the case of <code>OK</code>, or an error message in the case of <code>ERROR</code> (see example above). -The hash can be used to construct URLs in which the paths begin with <code>/f/hash</code> where <code>hash</code> is the hash received.</p> -<p>Any file extension an be appended to the hash, and for convenience the original filename (or whatever filename you prefer) can be appended after an additional slash after the hash.</p> -</s> -{% endblock %} |