diff options
Diffstat (limited to 'fbin/fbin.py')
| -rwxr-xr-x | fbin/fbin.py | 353 | 
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' | 
