From b36f9c05071ea549ed59e703270fcf223b60df03 Mon Sep 17 00:00:00 2001 From: Jon Bergli Heier Date: Sun, 9 Apr 2017 09:02:09 +0200 Subject: 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. --- fbin/fbin.py | 353 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100755 fbin/fbin.py (limited to 'fbin/fbin.py') 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/') +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/') +@app.route('/f/') +@app.route('/f//') +@app.route('/file/', endpoint = 'file') +@app.route('/file/', endpoint = 'file') +@app.route('/file//', 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 hash={}.'.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/') +@app.route('/thumb/') +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' -- cgit v1.2.3