#!/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(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 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: return error('No valid file or filename was provided.') 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, user, 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 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 = { '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(): user_id = int(current_user.get_id().split(':')[0]) f = get_file(request.form.get('hash'), user_id = user_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('/v') @app.route('/videos') @login_required def videos(): files = [f for f in get_files(current_user.user) if f.is_video()] context = { 'title': 'Videos', '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) if f.is_image(): 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', 'RGBX', 'CMYK', 'YCbCr'): im = im.convert('RGB') im.thumbnail(current_app.config.get('THUMB_SIZE', (128, 128)), Image.ANTIALIAS) im.save(thumbfile) elif f.is_video(): p = subprocess.run(['ffmpegthumbnailer', '-i', f.get_path(), '-o', thumbfile]) if p.returncode != 0: abort(404) else: abort(404) return send_file(thumbfile) @app.route('/h') @app.route('/help') def help(): return redirect(url_for('.api')) @app.route('/api') def api(): context = { 'title': 'API', 'subtitle': 'keys and usage', } 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'