From 29afdd025cb25aacdb670229dae7dcb2d684dffa Mon Sep 17 00:00:00 2001 From: Trent Palmer Date: Sat, 30 Jan 2021 06:36:25 -0800 Subject: [PATCH] refactor with blueprints --- app/__init__.py | 59 ++-- app/auth/auth.py | 72 +++++ app/auth/dis_totp.py | 39 +++ app/{ => auth}/email.py | 8 +- app/auth/profile.py | 55 ++++ app/auth/register.py | 28 ++ app/auth/reset_password.py | 46 +++ app/{ => auth}/templates/change_password.html | 0 app/{ => auth}/templates/disable_2fa.html | 0 app/{ => auth}/templates/edit_profile.html | 6 +- .../email/reset_password_email_html.html | 0 .../email/reset_password_email_text.txt | 0 app/{ => auth}/templates/login.html | 5 +- app/{ => auth}/templates/qr.html | 0 app/{ => auth}/templates/register.html | 0 app/{ => auth}/templates/reset_password.html | 0 .../templates/reset_password_request.html | 0 .../templates/two_factor_input.html | 2 +- app/auth/totp.py | 56 ++++ app/forms.py | 4 - app/models.py | 7 +- app/photo_routes/delete_download.py | 68 ++++ app/photo_routes/photo_upload.py | 44 +++ .../photox.py} | 56 ++-- app/photo_routes/proutes.py | 48 +++ app/{ => photo_routes}/scripts/crop_photo.py | 0 app/photo_routes/scripts/get_exif_data.py | 95 ++++++ .../scripts/process_uploaded_photo.py | 0 .../templates/delete_photo.html | 2 +- app/{ => photo_routes}/templates/photo.html | 16 +- app/{ => photo_routes}/templates/upload.html | 0 app/routes.py | 295 ------------------ app/scripts/delete_photo.py | 31 -- app/scripts/get_exif_data.py | 94 ------ app/scripts/set_contributor_id_seq.py | 17 - app/scripts/totp_utils.py | 59 ---- app/templates/base.html | 10 +- app/templates/index.html | 2 +- examples/deploy_photo_app.bash.example | 73 +++-- examples/foo | 2 + photo_app.py | 4 +- 41 files changed, 696 insertions(+), 607 deletions(-) create mode 100644 app/auth/auth.py create mode 100644 app/auth/dis_totp.py rename app/{ => auth}/email.py (81%) create mode 100644 app/auth/profile.py create mode 100644 app/auth/register.py create mode 100644 app/auth/reset_password.py rename app/{ => auth}/templates/change_password.html (100%) rename app/{ => auth}/templates/disable_2fa.html (100%) rename app/{ => auth}/templates/edit_profile.html (88%) rename app/{ => auth}/templates/email/reset_password_email_html.html (100%) rename app/{ => auth}/templates/email/reset_password_email_text.txt (100%) rename app/{ => auth}/templates/login.html (83%) rename app/{ => auth}/templates/qr.html (100%) rename app/{ => auth}/templates/register.html (100%) rename app/{ => auth}/templates/reset_password.html (100%) rename app/{ => auth}/templates/reset_password_request.html (100%) rename app/{ => auth}/templates/two_factor_input.html (90%) create mode 100644 app/auth/totp.py create mode 100644 app/photo_routes/delete_download.py create mode 100644 app/photo_routes/photo_upload.py rename app/{scripts/get_photo_list.py => photo_routes/photox.py} (68%) create mode 100644 app/photo_routes/proutes.py rename app/{ => photo_routes}/scripts/crop_photo.py (100%) create mode 100644 app/photo_routes/scripts/get_exif_data.py rename app/{ => photo_routes}/scripts/process_uploaded_photo.py (100%) rename app/{ => photo_routes}/templates/delete_photo.html (84%) rename app/{ => photo_routes}/templates/photo.html (90%) rename app/{ => photo_routes}/templates/upload.html (100%) delete mode 100644 app/routes.py delete mode 100644 app/scripts/delete_photo.py delete mode 100644 app/scripts/get_exif_data.py delete mode 100644 app/scripts/set_contributor_id_seq.py delete mode 100644 app/scripts/totp_utils.py create mode 100644 examples/foo diff --git a/app/__init__.py b/app/__init__.py index a7d1977..b210db2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,28 +7,43 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_mail import Mail -app = Flask(__name__) -app.config.from_object(Config) -db = SQLAlchemy(app) -login = LoginManager(app) -mail = Mail(app) +db = SQLAlchemy() +login = LoginManager() +mail = Mail() -from app.sendxmpp_handler import SENDXMPPHandler -from app import routes -if app.debug: - print("flask app 'photo_app' is in debug mode") -else: - print("flask app 'photo_app' is not in debug mode") +def init_app(): -if app.config['LOGGING_XMPP_SERVER']: - sendxmpp_handler = SENDXMPPHandler( - logging_xmpp_server=(app.config['LOGGING_XMPP_SERVER']), - logging_xmpp_sender=(app.config['LOGGING_XMPP_SENDER']), - logging_xmpp_password=(app.config['LOGGING_XMPP_PASSWORD']), - logging_xmpp_recipient=(app.config['LOGGING_XMPP_RECIPIENT']), - logging_xmpp_command=(app.config['LOGGING_XMPP_COMMAND']), - logging_xmpp_use_tls=(app.config['LOGGING_XMPP_USE_TLS']), - ) - sendxmpp_handler.setLevel(logging.ERROR) - app.logger.addHandler(sendxmpp_handler) + app = Flask(__name__) + app.config.from_object(Config) + db.init_app(app) + login.init_app(app) + mail.init_app(app) + + from app.sendxmpp_handler import SENDXMPPHandler + + if app.config['LOGGING_XMPP_SERVER']: + sendxmpp_handler = SENDXMPPHandler( + logging_xmpp_server=(app.config['LOGGING_XMPP_SERVER']), + logging_xmpp_sender=(app.config['LOGGING_XMPP_SENDER']), + logging_xmpp_password=(app.config['LOGGING_XMPP_PASSWORD']), + logging_xmpp_recipient=(app.config['LOGGING_XMPP_RECIPIENT']), + logging_xmpp_command=(app.config['LOGGING_XMPP_COMMAND']), + logging_xmpp_use_tls=(app.config['LOGGING_XMPP_USE_TLS']), + ) + sendxmpp_handler.setLevel(logging.ERROR) + app.logger.addHandler(sendxmpp_handler) + from .auth import auth, totp, dis_totp, profile, register, reset_password + from .photo_routes import proutes, photox, delete_download, photo_upload + with app.app_context(): + app.register_blueprint(auth.auths) + app.register_blueprint(totp.totps) + app.register_blueprint(dis_totp.disabletotp) + app.register_blueprint(profile.prof) + app.register_blueprint(register.reg) + app.register_blueprint(reset_password.pwd) + app.register_blueprint(proutes.proute) + app.register_blueprint(photox.p_route) + app.register_blueprint(delete_download.d_d) + app.register_blueprint(photo_upload.pupload) + return app diff --git a/app/auth/auth.py b/app/auth/auth.py new file mode 100644 index 0000000..d98c550 --- /dev/null +++ b/app/auth/auth.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, redirect, url_for, session, flash, render_template +from flask_login import current_user, login_user, logout_user +from app.forms import LoginForm, GetTotp +from app.models import Contributor +from pyotp.totp import TOTP + +auths = Blueprint( + "auths", __name__, template_folder="templates" +) + + +@auths.route("/two-factor-input", methods=["GET", "POST"]) +def two_factor_input(): + if current_user.is_authenticated or 'id' not in session: + return redirect(url_for('proute.index')) + contributor = Contributor.query.get(session['id']) + if contributor is None: + return redirect(url_for('proute.index')) + form = GetTotp() + if form.validate_on_submit(): + if TOTP(contributor.totp_key).verify(int(form.totp_code.data), valid_window=5): + login_user(contributor, remember=session['remember_me']) + flash("Congratulations, you are now logged in!") + return redirect(url_for('proute.index')) + else: + flash("Oops, the pin was wrong") + form.totp_code.data = None + return render_template('two_factor_input.html', form=form, inst="Code was wrong, try again?") + return render_template('two_factor_input.html', form=form, inst="Enter Auth Code") + + +@auths.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for('proute.index')) + form = LoginForm() + if form.validate_on_submit(): + contributor_by_name = Contributor.query.filter_by(name=form.username.data).first() + contributor_by_email = Contributor.query.filter_by(email=form.email.data).first() + if contributor_by_name is not None and contributor_by_name.check_password(form.password.data): + if contributor_by_name.use_totp: + session['id'] = contributor_by_name.id + session['remember_me'] = form.remember_me.data + return redirect(url_for('auths.two_factor_input')) + else: + login_user(contributor_by_name, remember=form.remember_me.data) + flash("Congratulations, you are now logged in!") + return redirect(url_for('proute.index')) + elif contributor_by_email is not None and contributor_by_email.check_password(form.password.data): + if contributor_by_email.use_totp: + session['id'] = contributor_by_email.id + session['remember_me'] = form.remember_me.data + return redirect(url_for('auths.two_factor_input')) + else: + login_user(contributor_by_email, remember=form.remember_me.data) + flash("Congratulations, you are now logged in!") + return redirect(url_for('proute.index')) + else: + flash("Error Invalid Contributor (Username or Email) or Password") + return(redirect(url_for('auths.login'))) + return render_template('login.html', title='Sign In', form=form) + + +@auths.route("/logout") +def logout(): + is_authenticated = current_user.is_authenticated + logout_user() + if is_authenticated: + flash("Congratulations, you are now logged out!") + return redirect(url_for('proute.index')) diff --git a/app/auth/dis_totp.py b/app/auth/dis_totp.py new file mode 100644 index 0000000..51599ef --- /dev/null +++ b/app/auth/dis_totp.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, redirect, url_for, flash, render_template +from flask_login import current_user +from app.models import Contributor +from flask_wtf import FlaskForm +from wtforms import SubmitField +from .. import db + +disabletotp = Blueprint( + "disabletotp", __name__, template_folder="templates" +) + + +@disabletotp.route('/disable-totp', methods=['GET', 'POST']) +def disable_totp(): + if current_user.is_anonymous or not current_user.use_totp: + return(redirect(url_for('proute.index'))) + contributor = Contributor.query.get(current_user.id) + form = DisableTotp() + if form.validate_on_submit(): + if disable_2fa(contributor): + flash('2FA Now Disabled') + return(redirect(url_for('prof.edit_profile'))) + else: + flash('2FA Not Disabled') + return(redirect(url_for('prof.edit_profile'))) + return render_template('disable_2fa.html', form=form, title="Disable 2FA") + + +def disable_2fa(contributor): + contributor.use_totp = False + contributor.totp_key = None + db.session.commit() + return True + + +class DisableTotp(FlaskForm): + submit = SubmitField('Disable 2FA') diff --git a/app/email.py b/app/auth/email.py similarity index 81% rename from app/email.py rename to app/auth/email.py index 4fc09ce..7724c08 100644 --- a/app/email.py +++ b/app/auth/email.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 -from flask import render_template +from flask import render_template, current_app from flask_mail import Message -from app import mail, app +from .. import mail from threading import Thread def send_password_reset_email(contributor, external_url): token = contributor.get_reset_password_token() send_email('Photo App Reset Your Password', - sender=app.config['MAIL_ADMINS'][0], + sender=current_app.config['MAIL_ADMINS'][0], recipients=[contributor.email], text_body=render_template('email/reset_password_email_text.txt', contributor=contributor, token=token, external_url=external_url), @@ -26,4 +26,4 @@ def send_email(subject, sender, recipients, text_body, html_body): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body - Thread(target=send_async_email, args=(app, msg)).start() + Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start() diff --git a/app/auth/profile.py b/app/auth/profile.py new file mode 100644 index 0000000..9d0c2fa --- /dev/null +++ b/app/auth/profile.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, redirect, url_for, request, flash, render_template +from flask_login import current_user +from .. import db +from app.models import Contributor +from app.forms import EditProfile, ChangePassword + +prof = Blueprint( + "prof", __name__, template_folder="templates" +) + + +@prof.route("/change-password", methods=["GET", "POST"]) +def change_password(): + if not current_user.is_authenticated: + return(redirect(url_for('proute.index'))) + contributor = Contributor.query.get(current_user.id) + form = ChangePassword() + if form.validate_on_submit(): + if contributor.check_password(form.password.data): + contributor.set_password(form.new_password.data) + db.session.commit() + flash("Thanks for the update!") + return(redirect(url_for('proute.index'))) + else: + flash("Error Invalid Password") + return(redirect(url_for('prof.change_password'))) + return render_template('change_password.html', title='Change Password', form=form) + + +@prof.route("/edit-profile", methods=["GET", "POST"]) +def edit_profile(): + if current_user.is_anonymous: + return(redirect(url_for('proute.index'))) + contributor = Contributor.query.get(current_user.id) + form = EditProfile() + if request.method == 'GET': + form.username.data = contributor.name + form.email.data = contributor.email + if form.validate_on_submit(): + if contributor.check_password(form.password.data): + contributor.name = form.username.data + contributor.email = form.email.data + db.session.commit() + flash("Thanks for the update!") + return(redirect(url_for('proute.index'))) + else: + flash("Error Invalid Password") + return(redirect(url_for('prof.edit_profile'))) + return render_template( + 'edit_profile.html', + title='Edit Profile', form=form, + contributor_use_totp=contributor.use_totp + ) diff --git a/app/auth/register.py b/app/auth/register.py new file mode 100644 index 0000000..1ebd79d --- /dev/null +++ b/app/auth/register.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, redirect, url_for, render_template, flash +from flask_login import current_user +from app.forms import RegistrationForm +from app.models import Contributor +from .. import db + +reg = Blueprint( + "reg", __name__, template_folder="templates" +) + + +@reg.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for('proute.index')) + form = RegistrationForm() + if form.validate_on_submit(): + db.engine.execute("SELECT setval('contributor_id_seq', (SELECT MAX(id) FROM contributor))") + db.session.commit() + contributor = Contributor(name=form.username.data, num_photos=0, email=form.email.data) + contributor.set_password(form.password.data) + db.session.add(contributor) + db.session.commit() + flash("Congratulations, you are now a registered user!") + return redirect(url_for('auths.login')) + return render_template('register.html', title='Register', form=form) diff --git a/app/auth/reset_password.py b/app/auth/reset_password.py new file mode 100644 index 0000000..1564a59 --- /dev/null +++ b/app/auth/reset_password.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, redirect, url_for, flash, render_template, current_app +from flask_login import current_user +from app.models import Contributor +from app.forms import ResetPasswordForm, ResetPasswordRequestForm +from .email import send_password_reset_email +from .. import db + +pwd = Blueprint( + "pwd", __name__, template_folder="templates" +) + + +@pwd.route('/reset-password/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('proute.index')) + contributor = Contributor.verify_reset_password_token(token) + if not contributor: + return redirect(url_for('proute.index')) + form = ResetPasswordForm() + if form.validate_on_submit(): + contributor.set_password(form.password.data) + db.session.commit() + flash('Your password has been reset.') + return redirect(url_for('auths.login')) + return render_template('reset_password.html', title="New Password?", form=form) + + +@pwd.route('/reset-password-request', methods=['GET', 'POST']) +def reset_password_request(): + if current_user.is_authenticated: + return(redirect(url_for('proute.index'))) + else: + form = ResetPasswordRequestForm() + if form.validate_on_submit(): + contributor = Contributor.query.filter_by(email=form.email.data).first() + if contributor: + send_password_reset_email(contributor, current_app.config['EXTERNAL_URL']) + flash('Check your email for the instructions to reset your password') + return redirect(url_for('auths.login')) + else: + flash('Sorry, invalid email') + return redirect(url_for('auths.login')) + return render_template('reset_password_request.html', title='Reset Password', form=form) diff --git a/app/templates/change_password.html b/app/auth/templates/change_password.html similarity index 100% rename from app/templates/change_password.html rename to app/auth/templates/change_password.html diff --git a/app/templates/disable_2fa.html b/app/auth/templates/disable_2fa.html similarity index 100% rename from app/templates/disable_2fa.html rename to app/auth/templates/disable_2fa.html diff --git a/app/templates/edit_profile.html b/app/auth/templates/edit_profile.html similarity index 88% rename from app/templates/edit_profile.html rename to app/auth/templates/edit_profile.html index f7d9e61..2808103 100644 --- a/app/templates/edit_profile.html +++ b/app/auth/templates/edit_profile.html @@ -33,15 +33,15 @@ {% if current_user.is_authenticated %}
- +
{% if contributor_use_totp %}
- +
{% else %}
- +
{% endif %} {% endif %} diff --git a/app/templates/email/reset_password_email_html.html b/app/auth/templates/email/reset_password_email_html.html similarity index 100% rename from app/templates/email/reset_password_email_html.html rename to app/auth/templates/email/reset_password_email_html.html diff --git a/app/templates/email/reset_password_email_text.txt b/app/auth/templates/email/reset_password_email_text.txt similarity index 100% rename from app/templates/email/reset_password_email_text.txt rename to app/auth/templates/email/reset_password_email_text.txt diff --git a/app/templates/login.html b/app/auth/templates/login.html similarity index 83% rename from app/templates/login.html rename to app/auth/templates/login.html index 0dd55b2..e571aae 100644 --- a/app/templates/login.html +++ b/app/auth/templates/login.html @@ -30,10 +30,9 @@

{{ form.remember_me() }} {{ form.remember_me.label }}

{{ form.submit() }}

-

New User? Click to Register!

+

New User? Click to Register!

- Forgot Your Password? Click to Reset It + Forgot Your Password? Click to Reset It

{% endblock %} - diff --git a/app/templates/qr.html b/app/auth/templates/qr.html similarity index 100% rename from app/templates/qr.html rename to app/auth/templates/qr.html diff --git a/app/templates/register.html b/app/auth/templates/register.html similarity index 100% rename from app/templates/register.html rename to app/auth/templates/register.html diff --git a/app/templates/reset_password.html b/app/auth/templates/reset_password.html similarity index 100% rename from app/templates/reset_password.html rename to app/auth/templates/reset_password.html diff --git a/app/templates/reset_password_request.html b/app/auth/templates/reset_password_request.html similarity index 100% rename from app/templates/reset_password_request.html rename to app/auth/templates/reset_password_request.html diff --git a/app/templates/two_factor_input.html b/app/auth/templates/two_factor_input.html similarity index 90% rename from app/templates/two_factor_input.html rename to app/auth/templates/two_factor_input.html index bd651da..c34bdbb 100644 --- a/app/templates/two_factor_input.html +++ b/app/auth/templates/two_factor_input.html @@ -30,7 +30,7 @@ h3 {

{{ inst }}

-
+ {{ form.hidden_tag() }}

{{ form.totp_code.label }}
diff --git a/app/auth/totp.py b/app/auth/totp.py new file mode 100644 index 0000000..dceba0e --- /dev/null +++ b/app/auth/totp.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +import pyotp +import qrcode +import qrcode.image.svg +from io import BytesIO +from flask import Blueprint, redirect, url_for, flash, render_template +from flask_login import current_user +from app.models import Contributor +from app.forms import ConfirmTotp +from .. import db + +totps = Blueprint( + "totps", __name__, template_folder="templates" +) + + +@totps.route('/enable-totp', methods=['GET', 'POST']) +def enable_totp(): + if current_user.is_anonymous or current_user.use_totp: + return(redirect(url_for('proute.index'))) + contributor = Contributor.query.get(current_user.id) + form = ConfirmTotp() + qr = get_totp_qr(contributor) + if form.validate_on_submit(): + if contributor.use_totp: + flash('2FA Already Enabled') + return(redirect(url_for('prof.edit_profile'))) + if validate_totp(contributor, form.totp_code.data): + flash('2FA Now Enabled') + return(redirect(url_for('prof.edit_profile'))) + else: + flash("TOTP Code didn't validate, rescan and try again") + return(redirect(url_for('prof.edit_profile'))) + return render_template('qr.html', qr=qr, form=form, title="Aunthentication Code") + + +def get_totp_qr(contributor): + if contributor.totp_key is None: + contributor.totp_key = pyotp.random_base32() + db.session.commit() + + totp_uri = pyotp.totp.TOTP(contributor.totp_key).provisioning_uri(name=contributor.email, issuer_name='Photo App') + img = qrcode.make(totp_uri, image_factory=qrcode.image.svg.SvgPathImage) + f = BytesIO() + img.save(f) + return(f.getvalue().decode('utf-8')) + + +def validate_totp(contributor, totp_code): + if pyotp.TOTP(contributor.totp_key).verify(int(totp_code), valid_window=5): + contributor.use_totp = True + db.session.commit() + return True + else: + return False diff --git a/app/forms.py b/app/forms.py index 12a1eae..8f1c8b8 100644 --- a/app/forms.py +++ b/app/forms.py @@ -12,10 +12,6 @@ class ConfirmPhotoDelete(FlaskForm): submit = SubmitField('Delete') -class DisableTotp(FlaskForm): - submit = SubmitField('Disable 2FA') - - class GetTotp(FlaskForm): totp_code = StringField('6-Digit Code?', validators=[DataRequired(), Length(min=6, max=6, message="6 Digits")], render_kw={'autofocus': True}) submit = SubmitField('OK') diff --git a/app/models.py b/app/models.py index 9ec07ac..623602e 100644 --- a/app/models.py +++ b/app/models.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 from flask_login import UserMixin -from app import db, login, app +from flask import current_app +from . import db, login from werkzeug.security import generate_password_hash, check_password_hash from time import time import jwt @@ -63,12 +64,12 @@ class Contributor(UserMixin, db.Model): return ''.format(self.name) def get_reset_password_token(self, expires_in=1800): - return jwt.encode({'reset_password': self.id, 'exp': time() + expires_in}, app.config['SECRET_KEY'], algorithm='HS256').decode('utf-8') + return jwt.encode({'reset_password': self.id, 'exp': time() + expires_in}, current_app.config['SECRET_KEY'], algorithm='HS256').decode('utf-8') @staticmethod def verify_reset_password_token(token): try: - id = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])['reset_password'] + id = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])['reset_password'] except BaseException as error: print('An exception occurred: {}'.format(error)) return Contributor.query.get(id) diff --git a/app/photo_routes/delete_download.py b/app/photo_routes/delete_download.py new file mode 100644 index 0000000..8091ad2 --- /dev/null +++ b/app/photo_routes/delete_download.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, request, redirect, url_for, render_template, current_app, send_file +from flask_login import current_user +from app.models import Photo +from app.forms import ConfirmPhotoDelete +import psycopg2 +import os + +d_d = Blueprint( + "d_d", __name__, template_folder="templates" +) + + +@d_d.route('/download') +def download(): + if current_user.is_authenticated: + f = request.args['file'] + try: + return send_file('/var/lib/photo_app/photos/{}'.format(f), attachment_filename=f) + except Exception as e: + return str(e) + + +@d_d.route('/delete', methods=['GET', 'POST']) +def delete(): + photo = Photo.query.get(request.args['photo_id']) + if photo is None: + return(redirect(url_for('proute.index'))) + if not current_user.is_authenticated or photo.contributor_id != current_user.id: + return(redirect(url_for('proute.index'))) + form = ConfirmPhotoDelete() + if request.method == 'POST' and form.validate_on_submit(): + return(redirect(url_for('p_route.photo', photo_id=delete_photo(photo)))) + return(render_template( + 'delete_photo.html', + title="Delete Photo?", + photo=photo, + photo_url=current_app.config['PHOTO_URL'], + form=form + )) + + +def delete_photo(photo): + conn = psycopg2.connect( + dbname=current_app.config['DATABASE_NAME'], + user=current_app.config['DATABASE_USER'], + host=current_app.config['DATABASE_HOST'], + password=current_app.config['DATABASE_PASSWORD'] + ) + cur = conn.cursor() + cur.execute("SELECT count(id) FROM photo WHERE contributor_id=%s AND id>%s", (photo.contributor_id, photo.id)) + if cur.fetchone()[0] == 0: + cur.execute("SELECT id FROM photo WHERE contributor_id=%s ORDER BY id", (photo.contributor_id, )) + else: + cur.execute("SELECT id FROM photo WHERE contributor_id=%s AND id>%s ORDER BY id", (photo.contributor_id, photo.id)) + next_photo_id = cur.fetchone()[0] + os.chdir(current_app.config['PHOTO_SAVE_PATH']) + if os.path.exists('raw_' + photo.photo_name): + os.remove('raw_' + photo.photo_name) + if os.path.exists('1280_' + photo.photo_name): + os.remove('1280_' + photo.photo_name) + if os.path.exists('480_' + photo.photo_name): + os.remove('480_' + photo.photo_name) + cur.execute("DELETE FROM photo WHERE id=%s", (photo.id, )) + conn.commit() + conn.close() + return next_photo_id diff --git a/app/photo_routes/photo_upload.py b/app/photo_routes/photo_upload.py new file mode 100644 index 0000000..6dc6de0 --- /dev/null +++ b/app/photo_routes/photo_upload.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +from flask import current_app +from flask_login import current_user +from flask import Blueprint, render_template, redirect, url_for, flash, request, abort +from app.forms import UploadPhotoForm +from .scripts.process_uploaded_photo import process_uploaded_photo +from werkzeug.utils import secure_filename + +pupload = Blueprint( + "pupload", __name__, template_folder="templates" +) + + +@pupload.route('/photo-upload', methods=['GET', 'POST']) +def photo_upload(): + if not current_user.is_authenticated: + return(redirect(url_for('proute.index'))) + form = UploadPhotoForm() + if request.method == 'POST' and form.validate_on_submit(): + f = request.files['image'] + filename = secure_filename(f.filename) + if filename != '': + import os + file_ext = os.path.splitext(filename)[1] + if file_ext not in ['.jpg', '.png'] or file_ext != validate_image(f.stream): + abort(400) + f.save(os.path.join(current_app.config['PHOTO_SAVE_PATH'], 'raw_' + filename)) + photo_id = process_uploaded_photo(filename, current_user, current_app.config) + print(photo_id) + flash("Thanks for the new photo!") + return(redirect(url_for('p_route.photo', photo_id=photo_id))) + return(redirect(url_for('proute.index'))) + return render_template('upload.html', title="Photo Upload", form=form) + + +def validate_image(stream): + import imghdr + header = stream.read(512) + stream.seek(0) + format = imghdr.what(None, header) + if not format: + return None + return '.' + (format if format != 'jpeg' else 'jpg') diff --git a/app/scripts/get_photo_list.py b/app/photo_routes/photox.py similarity index 68% rename from app/scripts/get_photo_list.py rename to app/photo_routes/photox.py index c2cfeda..26377f4 100644 --- a/app/scripts/get_photo_list.py +++ b/app/photo_routes/photox.py @@ -1,16 +1,37 @@ #!/usr/bin/env python3 +from flask import Blueprint, redirect, url_for, current_app, render_template +from flask_login import current_user +from app.models import Photo import psycopg2 import psycopg2.extras -from shutil import disk_usage + +p_route = Blueprint( + "p_route", __name__, template_folder="templates" +) -def find_next_previous(photo, app_config): +@p_route.route('/photo/') +def photo(photo_id): + photo = Photo.query.get(photo_id) + if not current_user.is_authenticated or photo is None: + return(redirect(url_for('proute.index'))) + find_next_previous(photo) + calc_additional_data(photo) + return render_template( + 'photo.html', + title="Photo", + photo=photo, + photo_url=current_app.config['PHOTO_URL'] + ) + + +def find_next_previous(photo): conn = psycopg2.connect( - dbname=app_config['DATABASE_NAME'], - user=app_config['DATABASE_USER'], - host=app_config['DATABASE_HOST'], - password=app_config['DATABASE_PASSWORD'] + dbname=current_app.config['DATABASE_NAME'], + user=current_app.config['DATABASE_USER'], + host=current_app.config['DATABASE_HOST'], + password=current_app.config['DATABASE_PASSWORD'] ) cur = conn.cursor() cur.execute("SELECT count(id) FROM photo WHERE contributor_id=%s AND id > %s", (photo.contributor_id, photo.id)) @@ -53,26 +74,3 @@ def calc_additional_data(photo): photo.MapUrl = "https://www.google.com/maps/search/?api=1&query={}".format(photo.LatLong) else: photo.LatLong, photo.MapUrl = None, None - - -def get_photo_list(contributor_id, app_config): - conn = psycopg2.connect( - dbname=app_config['DATABASE_NAME'], - user=app_config['DATABASE_USER'], - host=app_config['DATABASE_HOST'], - password=app_config['DATABASE_PASSWORD'] - ) - cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute("SELECT photo_name,id FROM photo WHERE contributor_id=%s ORDER BY timestamp,\"DateTimeOriginal\" DESC", (contributor_id, )) - photos = cur.fetchall() - conn.close() - return photos - - -def get_disk_stats(): - disk_stats = disk_usage('/') - return("Used {}GB of {}GB, {}GB free".format( - round(disk_stats.used / 1073741824, 1), - round(disk_stats.total / 1073741824, 1), - round(disk_stats.free / 1073741824, 1) - )) diff --git a/app/photo_routes/proutes.py b/app/photo_routes/proutes.py new file mode 100644 index 0000000..6e9430c --- /dev/null +++ b/app/photo_routes/proutes.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, flash, render_template, current_app +from flask_login import current_user +from shutil import disk_usage +import psycopg2 + +proute = Blueprint( + "proute", __name__, template_folder="templates" +) + + +@proute.route("/") +@proute.route("/index") +def index(): + if current_user.is_authenticated: + photos = get_photo_list(current_user.id) + flash(get_disk_stats()) + return(render_template( + 'index.html', + title="Photos", + photos=photos, + photo_url=current_app.config['PHOTO_URL'] + )) + return render_template('index.html', title="Photos") + + +def get_photo_list(contributor_id): + conn = psycopg2.connect( + dbname=current_app.config['DATABASE_NAME'], + user=current_app.config['DATABASE_USER'], + host=current_app.config['DATABASE_HOST'], + password=current_app.config['DATABASE_PASSWORD'] + ) + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT photo_name,id FROM photo WHERE contributor_id=%s ORDER BY timestamp,\"DateTimeOriginal\" DESC", (contributor_id, )) + photos = cur.fetchall() + conn.close() + return photos + + +def get_disk_stats(): + disk_stats = disk_usage('/') + return("Used {}GB of {}GB, {}GB free".format( + round(disk_stats.used / 1073741824, 1), + round(disk_stats.total / 1073741824, 1), + round(disk_stats.free / 1073741824, 1) + )) diff --git a/app/scripts/crop_photo.py b/app/photo_routes/scripts/crop_photo.py similarity index 100% rename from app/scripts/crop_photo.py rename to app/photo_routes/scripts/crop_photo.py diff --git a/app/photo_routes/scripts/get_exif_data.py b/app/photo_routes/scripts/get_exif_data.py new file mode 100644 index 0000000..905b90d --- /dev/null +++ b/app/photo_routes/scripts/get_exif_data.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 + +from PIL import Image +from PIL.ExifTags import TAGS, GPSTAGS +import os +from datetime import datetime + + +def get_exif_data(photo): + exif_data = {} + get_file_sizes(photo, exif_data) + return(exif_data) + + +def get_exif(img_raw, exif_data): + if hasattr(img_raw, '_getexif'): + exifdata = img_raw._getexif() + if exifdata is not None: + date_format = "%Y:%m:%d %H:%M:%S" + for k, v in TAGS.items(): + if k in exifdata: + if v == "Make": + exif_data['Make'] = exifdata[k] + if v == "Model": + exif_data['Model'] = exifdata[k] + if v == "Software": + exif_data['Software'] = exifdata[k] + if v == "DateTime": + exif_data['DateTime'] = datetime.strptime(exifdata[k], date_format) + if v == "DateTimeOriginal": + exif_data['DateTimeOriginal'] = datetime.strptime(exifdata[k], date_format) + if v == "DateTimeDigitized": + exif_data['DateTimeDigitized'] = datetime.strptime(exifdata[k], date_format) + if v == "FNumber": + exif_data['fnumber'] = round(exifdata[k][0] / exifdata[k][1], 1) + if v == "DigitalZoomRatio": + exif_data['DigitalZoomRatio'] = round(exifdata[k][0] / exifdata[k][1], 2) + if v == "TimeZoneOffset": + exif_data['TimeZoneOffset'] = exifdata[k] + if v == "GPSInfo": + gpsinfo = {} + for h, i in GPSTAGS.items(): + if h in exifdata[k]: + if i == 'GPSAltitudeRef': + gpsinfo['GPSAltitudeRef'] = int.from_bytes(exifdata[k][h], "big") + if i == 'GPSAltitude': + gpsinfo['GPSAltitude'] = round(exifdata[k][h][0] / exifdata[k][h][1], 3) + if i == 'GPSLatitudeRef': + gpsinfo['GPSLatitudeRef'] = exifdata[k][h] + if i == 'GPSLatitude': + gpsinfo['GPSLatitude'] = calc_coordinate(exifdata[k][h]) + if i == 'GPSLongitudeRef': + gpsinfo['GPSLongitudeRef'] = exifdata[k][h] + if i == 'GPSLongitude': + gpsinfo['GPSLongitude'] = calc_coordinate(exifdata[k][h]) + update_gpsinfo(gpsinfo, exif_data) + + +def update_gpsinfo(gpsinfo, exif_data): + if 'GPSAltitudeRef' in gpsinfo and 'GPSLatitude' in gpsinfo: + exif_data['GPSAltitude'] = gpsinfo['GPSAltitude'] + exif_data['GPSAboveSeaLevel'] = False if gpsinfo['GPSAltitudeRef'] != 0 else True + if 'GPSLatitudeRef' in gpsinfo and 'GPSLatitude' in gpsinfo: + exif_data['GPSLatitude'] = gpsinfo['GPSLatitude'] if gpsinfo['GPSLatitudeRef'] != 'S' else 0 - gpsinfo['GPSLatitude'] + if 'GPSLongitudeRef' in gpsinfo and 'GPSLongitude' in gpsinfo: + exif_data['GPSLongitude'] = gpsinfo['GPSLongitude'] if gpsinfo['GPSLongitudeRef'] != 'W' else 0 - gpsinfo['GPSLongitude'] + + +def calc_coordinate(x): + degrees = x[0][0] / x[0][1] + minutes = x[1][0] / x[1][1] + seconds = x[2][0] / x[2][1] + return round(degrees + minutes / 60 + seconds / 3600, 5) + + +def get_dimensions_and_format(photo, exif_data): + img_raw = Image.open('raw_' + photo) + img_1280 = Image.open('1280_' + photo) + img_480 = Image.open('480_' + photo) + exif_data['AspectRatio'] = round(img_raw.width / img_raw.height, 5) + exif_data['photo_format'] = img_raw.format + exif_data['photo_width'] = img_raw.width + exif_data['photo_height'] = img_raw.height + exif_data['photo_1280_width'] = img_1280.width + exif_data['photo_1280_height'] = img_1280.height + exif_data['photo_480_width'] = img_480.width + exif_data['photo_480_height'] = img_480.height + get_exif(img_raw, exif_data) + + +def get_file_sizes(photo, exif_data): + exif_data['photo_raw_size'] = os.path.getsize('raw_' + photo) + exif_data['photo_1280_size'] = os.path.getsize('1280_' + photo) + exif_data['photo_480_size'] = os.path.getsize('480_' + photo) + get_dimensions_and_format(photo, exif_data) diff --git a/app/scripts/process_uploaded_photo.py b/app/photo_routes/scripts/process_uploaded_photo.py similarity index 100% rename from app/scripts/process_uploaded_photo.py rename to app/photo_routes/scripts/process_uploaded_photo.py diff --git a/app/templates/delete_photo.html b/app/photo_routes/templates/delete_photo.html similarity index 84% rename from app/templates/delete_photo.html rename to app/photo_routes/templates/delete_photo.html index 47b1c34..2c01b10 100644 --- a/app/templates/delete_photo.html +++ b/app/photo_routes/templates/delete_photo.html @@ -14,7 +14,7 @@ {% block content %}

- +

Delete Photo?

{{ {{ form.hidden_tag() }} diff --git a/app/templates/photo.html b/app/photo_routes/templates/photo.html similarity index 90% rename from app/templates/photo.html rename to app/photo_routes/templates/photo.html index 46ec2fb..41ad7c6 100644 --- a/app/templates/photo.html +++ b/app/photo_routes/templates/photo.html @@ -11,9 +11,9 @@ {% block morenavs %} - - - + + +