#!/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'