summaryrefslogtreecommitdiff
path: root/fbin/fbin.py
diff options
context:
space:
mode:
authorJon Bergli Heier <snakebite@jvnv.net>2017-04-09 09:02:09 +0200
committerJon Bergli Heier <snakebite@jvnv.net>2017-04-09 09:02:09 +0200
commitb36f9c05071ea549ed59e703270fcf223b60df03 (patch)
tree8992c6bcaa5b0d64cbd589588b2539523125548c /fbin/fbin.py
parentaf750a6598d53b8a5cb58092dd5b523ea7e967ca (diff)
Major rewrite to use jab/oauth.
Highlights: - Uses the oauth branch of jab. - Changed design to use bootstrap. - Some minor changes to functionality in file uploading and listing. - API is currently disabled and incomplete.
Diffstat (limited to 'fbin/fbin.py')
-rwxr-xr-xfbin/fbin.py353
1 files changed, 353 insertions, 0 deletions
diff --git a/fbin/fbin.py b/fbin/fbin.py
new file mode 100755
index 0000000..42c371f
--- /dev/null
+++ b/fbin/fbin.py
@@ -0,0 +1,353 @@
+#!/usr/bin/env python
+
+import base64
+import cgi
+import datetime
+import hashlib
+import io
+import json
+import mimetypes
+import os
+import random
+import subprocess
+import tempfile
+import urllib
+from urllib.parse import urlencode, urljoin
+
+from flask import Blueprint, redirect, current_app, url_for, request, render_template, session, flash, send_file, abort, jsonify, Markup
+from flask_login import login_user, logout_user, current_user, login_required
+import jwt
+from PIL import Image
+import requests
+from werkzeug.utils import secure_filename
+
+from . import db
+from .monkey import patch as monkey_patch
+from .login import login_manager, load_user
+
+monkey_patch()
+
+base62_alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
+
+if not os.path.isdir(current_app.config['FILE_DIRECTORY']):
+ os.mkdir(current_app.config['FILE_DIRECTORY'])
+
+if not os.path.isdir(current_app.config['THUMB_DIRECTORY']):
+ os.mkdir(current_app.config['THUMB_DIRECTORY'])
+
+try:
+ # Throws OSError if mogrify doesn't exist.
+ subprocess.call(['mogrify', '-quiet'])
+except OSError:
+ has_mogrify = False
+else:
+ has_mogrify = True
+
+def get_or_create_user(username, jab_id):
+ with db.session_scope() as sess:
+ try:
+ return sess.query(db.User).filter(db.User.jab_id == jab_id).one()
+ except db.NoResultFound:
+ try:
+ user = db.User(username, jab_id)
+ sess.add(user)
+ sess.commit()
+ sess.refresh(user)
+ return user
+ except db.IntegrityError:
+ return None
+
+def add_file(path, filename, user = None, ip = None):
+ file_hash = ''.join(random.choice(base62_alphabet) for x in range(5))
+ new_path = os.path.join(current_app.config['FILE_DIRECTORY'], file_hash + os.path.splitext(filename)[1])
+ os.rename(path, new_path)
+ if current_app.config.get('DESTINATION_MODE'):
+ os.chmod(new_path, current_app.config.get('DESTINATION_MODE'))
+ with db.session_scope() as sess:
+ f = db.File(file_hash, filename, datetime.datetime.utcnow(), user.id if user else None, ip)
+ sess.add(f)
+ sess.commit()
+ sess.refresh(f)
+ return f
+
+def get_file(file_hash, user_id=None, update_accessed=False):
+ with db.session_scope() as sess:
+ try:
+ f = sess.query(db.File).filter(db.File.hash == file_hash)
+ if user_id:
+ f = f.filter(db.File.user_id == user_id)
+ f = f.one()
+ except db.NoResultFound:
+ return None
+ if update_accessed:
+ f.accessed = datetime.datetime.utcnow()
+ sess.add(f)
+ sess.commit()
+ # Refresh after field update.
+ sess.refresh(f)
+ return f
+
+def get_files(user):
+ with db.session_scope() as sess:
+ try:
+ sess.add(user)
+ files = user.files
+ except db.NoResultFound:
+ return []
+ return files
+
+def delete_file(file):
+ with db.session_scope() as sess:
+ sess.delete(file)
+ sess.commit()
+ filename = file.get_path()
+ if os.path.exists(filename):
+ os.unlink(filename)
+ thumbfile = file.get_thumb_path()
+ if os.path.exists(thumbfile):
+ os.unlink(thumbfile)
+
+app = Blueprint('fbin', __name__)
+
+@app.route('/')
+def index():
+ return redirect(url_for('.upload'))
+
+@app.route('/u')
+@app.route('/upload', methods = ['GET', 'POST'])
+def upload():
+ context = {
+ 'title': 'Upload',
+ }
+ if request.method == 'GET':
+ return render_template('upload.html', **context)
+ if not current_user.is_authenticated 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)
+ if hasattr(uploaded_file.stream, 'file'):
+ temp = None
+ temp_path = uploaded_file.stream.name
+ else:
+ 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)
+
+ mime = new_file.get_mime_type()
+ # TODO: Apparently TIFF also supports EXIF, test this.
+ if has_mogrify and mime == 'image/jpeg':
+ # NOTE: PIL doesn't support lossless rotation, so we call mogrify to do this.
+ # NOTE: This changes the file, so the file_hash applies to the ORIGINAL file contents only.
+ # 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')):
+ return 'OK {hash}'.format(hash = new_file.hash)
+ else:
+ context = {
+ 'file': new_file,
+ }
+ return redirect(url_for('.uploaded', hash = new_file.hash))
+
+@app.route('/uploaded/<hash>')
+def uploaded(hash):
+ f = get_file(hash, update_accessed = False)
+ if not f:
+ abort(404)
+ if f.user_id and (not current_user.is_authenticated or f.user_id != current_user.get_user_id()):
+ abort(404)
+ context = {
+ 'title': 'Uploaded',
+ 'subtitle': f.filename,
+ 'file': f,
+ }
+ return render_template('uploaded.html', **context)
+
+@app.route('/f/<hash:hash>')
+@app.route('/f/<hash:hash><ext:ext>')
+@app.route('/f/<hash:hash>/<path:filename>')
+@app.route('/file/<hash:hash>', endpoint = 'file')
+@app.route('/file/<hash:hash><ext:ext>', endpoint = 'file')
+@app.route('/file/<hash:hash>/<path:filename>', endpoint = 'file')
+def _file(hash, ext=None, filename=None):
+ f = get_file(hash)
+ if not f:
+ abort(404)
+ return send_file(f.get_path())
+
+@app.route('/l')
+@app.route('/login')
+def login():
+ session['oauth_state'] = base64.urlsafe_b64encode(os.urandom(30)).decode()
+ return redirect(urljoin(current_app.config['OAUTH_URL'], 'authorize') + '?' + urlencode({
+ 'response_type': 'code',
+ 'client_id': current_app.config['OAUTH_CLIENT_ID'],
+ 'redirect_uri': urljoin(request.host_url, url_for('.auth')),
+ 'state': session['oauth_state'],
+ }))
+
+@app.route('/account')
+def account():
+ return redirect(current_app.config['ACCOUNT_URL'])
+
+@app.route('/o')
+@app.route('/logout')
+def logout():
+ if not current_user.is_authenticated:
+ return redirect(url_for('.index'))
+ session_id = int(current_user.get_id().split(':', 1)[-1])
+ with db.session_scope() as s:
+ try:
+ s.query(db.UserSession).filter_by(id = session_id).delete()
+ except:
+ raise
+ logout_user()
+ return redirect(url_for('.index'))
+
+@app.route('/auth')
+def auth():
+ if 'error' in request.args:
+ error = request.args['error']
+ error_description = request.args.get('error_description')
+ msg = 'OAuth error: {}'.format(error)
+ if error_description:
+ msg += ' ({})'.format(error_description)
+ flash(msg, 'error')
+ return redirect(url_for('.index'))
+ session_state = session.pop('oauth_state', None)
+ state = request.args.get('state')
+ if session_state != state:
+ flash('Invalid OAuth state', 'error')
+ return redirect(url_for('.index'))
+ code = request.args.get('code')
+ if not code:
+ flash('Missing OAuth code', 'error')
+ return redirect(url_for('.index'))
+ rs = requests.Session()
+ response = rs.post(urljoin(current_app.config['OAUTH_URL'], 'token'), data = {
+ 'grant_type': 'authorization_code',
+ 'code': code,
+ 'client_id': current_app.config['OAUTH_CLIENT_ID'],
+ 'client_secret': current_app.config['OAUTH_CLIENT_SECRET'],
+ 'redirect_uri': urljoin(request.host_url, url_for('.auth')),
+ })
+ token = response.json()
+ if 'error' in token:
+ msg = 'OAuth error: {}'.format(token['error'])
+ flash(msg, 'error')
+ return redirect(url_for('.index'))
+ try:
+ access_data = jwt.decode(token['access_token'], key = current_app.config['JWT_PUBLIC_KEY'], audience = current_app.config['OAUTH_CLIENT_ID'])
+ refresh_data = jwt.decode(token['refresh_token'], key = current_app.config['JWT_PUBLIC_KEY'], audience = current_app.config['OAUTH_CLIENT_ID'])
+ except jwt.InvalidTokenError as e:
+ flash('Failed to verify token: {!s}'.format(e), 'error')
+ return redirect(url_for('.index'))
+ response = rs.get(urljoin(current_app.config['OAUTH_URL'], '/api/user'), headers = {'Authorization': 'Bearer {}'.format(token['access_token'])})
+ user = response.json()
+ user = get_or_create_user(user['username'], user['id'])
+ with db.session_scope() as s:
+ us = db.UserSession(user.id, token['access_token'], token['refresh_token'])
+ us.updated = datetime.datetime.utcnow()
+ s.add(us)
+ s.commit()
+ s.refresh(us)
+ user = load_user('{}:{}'.format(user.id, us.id))
+ if not user:
+ flash('Failed to retrieve user instance.', 'error')
+ else:
+ login_user(user, remember = True)
+ return redirect(url_for('.index'))
+
+@app.route('/m')
+@app.route('/files')
+@login_required
+def files():
+ files = get_files(current_user.user)
+ context = {
+ 'title': 'Files',
+ 'files': files,
+ 'total_size': db.File.pretty_size(sum(f.get_size() for f in files if f.exists)),
+ }
+ return render_template('files.html', **context)
+
+@app.route('/files', methods = ['POST'])
+@login_required
+def file_edit():
+ f = get_file(request.form.get('hash'), user_id = current_user.get_id(), update_accessed = False)
+ if not f:
+ flash('File not found.', 'error')
+ return redirect(url_for('.files'))
+ if 'filename' in request.form:
+ with db.session_scope() as sess:
+ old_path = f.get_path()
+ filename = request.form.get('filename', f.filename)
+ f.filename = filename
+ new_path = f.get_path()
+ # If extension changed, the local filename also changes. We could just store the file without the extension,
+ # but that would break the existing files, requiring a manual rename.
+ if old_path != new_path:
+ try:
+ if os.path.exists(new_path):
+ # This shouldn't happen unless we have two files with the same hash, which should be impossible.
+ raise RuntimeError()
+ else:
+ os.rename(old_path, new_path)
+ except:
+ flash(Markup('Internal rename failed; file may have become unreachable. '
+ 'Please contact an admin and specify <strong>hash={}</strong>.'.format(f.hash)), 'error')
+ sess.add(f)
+ flash('Filename changed to "{}".'.format(f.filename), 'success')
+ elif 'delete' in request.form:
+ try:
+ delete_file(f)
+ except:
+ flash('Failed to delete file.', 'error')
+ else:
+ flash('File deleted.', 'success')
+ else:
+ flash('No action was performed.', 'warning')
+ return redirect(url_for('.files'))
+
+@app.route('/i')
+@app.route('/images')
+@login_required
+def images():
+ files = [f for f in get_files(current_user.user) if f.is_image()]
+ context = {
+ 'title': 'Images',
+ 'fullwidth': True,
+ 'files': files,
+ 'total_size': db.File.pretty_size(sum(f.get_size() for f in files if f.exists)),
+ }
+ return render_template('images.html', **context)
+
+@app.route('/t/<hash:hash>')
+@app.route('/thumb/<hash:hash>')
+def thumb(hash):
+ thumbfile = os.path.join(current_app.config['THUMB_DIRECTORY'], hash + '.jpg')
+ if not os.access(thumbfile, os.F_OK):
+ f = get_file(hash, update_accessed = False)
+ try:
+ im = Image.open(f.get_path())
+ except IOError:
+ # We can't generate a thumbnail for this file, just say it doesn't exist.
+ abort(404)
+ # Check for valid JPEG modes.
+ if im.mode not in ('1', 'L', 'RGB', 'RGBA', 'RGBX', 'CMYK', 'YCbCr'):
+ im = im.convert('RGB')
+ im.thumbnail(current_app.config.get('THUMB_SIZE', (128, 128)), Image.ANTIALIAS)
+ im.save(thumbfile)
+ return send_file(thumbfile)
+
+@app.route('/h')
+@app.route('/help')
+def help():
+ context = {
+ 'title': 'Help',
+ }
+ return render_template('help.html', **context)
+
+login_manager.login_view = '.login'