diff --git a/.gitignore b/.gitignore index 0801037..209a447 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ -# Remove all development files -gallery/user/logs/* -gallery/user/uploads/* -gallery/user/conf.yml -gallery/user/conf.json -gallery/static/theme/* +gallery/static/theme +gallery/static/.webassets-cache +gallery/static/gen .idea .vscode diff --git a/LICENSE b/LICENSE index 03aa068..6da480f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Michal +Copyright (c) 2023 Michal Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index fbd8d4f..7602331 100644 --- a/README.md +++ b/README.md @@ -22,28 +22,54 @@ ## Features -### Currently implemented -- Easy uploading and managing of a gallery of images -- Multi user support, helping you manage a whole group of photographers -- Custom CSS support + - [x] Easy uploading and managing of a gallery of images + - [x] Multi-user support, helping you manage a whole group of photographers + - [x] Image groups, helping you sort your favourite memories + - [x] Custom CSS support + - [ ] Password locked images/image groups, helping you share photos only to those who you want to + - [ ] Logging and automatic login attempt warnings and timeouts + - [ ] Searching through tags, file names and users -### Coming soon tm -- Image groups, helping you sort your favorite memories -- Password locked images/image groups, helping you share photos only to those who you want to -- Logging and automatic login attempt warnings and timeouts -- Searching through tags, file names, users (and metadata maybe, no promises) +And many more planned things! ## screenshots -Homescreen +Home-screen ![screenshot](.github/images/homepage.png) Image view ![screenshot](.github/images/imageview.png) ## Running -Currently only for reference + +You first need to install `python poetry`, it's best to follow their getting started guide you can find on the official website. + +Next we need to install the required packages for the gallery to function correctly, make sure you're in the directory of the project when you run this command: poetry install - poetry run python3 -m flask --app gallery --debug run --host 0.0.0.0 - poetry run python3 -m gunicorn -w 4 -b 0.0.0.0:5000 'gallery:create_app()' \ No newline at end of file + +By default, the app runs on port 5000, 4 workers on `gunicorn` ready for you to use it. You can find more information on this using the `-h` flag. But to run the gallery, use this command. + + poetry run python3 run.py + +Now follow the provided prompts to fill in the information for the Admin account, and you're ready to go! + +### Common issues +#### App failing to create a user config folder + +Try checking if you have `XDG_CONFIG_HOME` setup. If you don't, you can set that with this command: + + export XDG_CONFIG_HOME="$HOME/.config" + +## Final notes + +Thank you to everyone who helped me test the previous and current versions of the gallery, especially critters: + + - Carty + - Jeetix + - CRT + - mrHDash + - Verg + - FennecBitch + +Enjoy using OnlyLegs! \ No newline at end of file diff --git a/gallery/__init__.py b/gallery/__init__.py index 9bad318..103f76e 100644 --- a/gallery/__init__.py +++ b/gallery/__init__.py @@ -1,79 +1,48 @@ """ - ___ _ _ - / _ \ _ __ | |_ _| | ___ __ _ ___ -| | | | '_ \| | | | | | / _ \/ _` / __| -| |_| | | | | | |_| | |__| __/ (_| \__ \ - \___/|_| |_|_|\__, |_____\___|\__, |___/ - |___/ |___/ -Created by Fluffy Bean - Version 23.03.04 +Onlylegs Gallery +This is the main app file, it loads all the other files and sets up the app """ # Import system modules import os -import sys import logging # Flask from flask_compress import Compress +from flask_caching import Cache +from flask_assets import Environment, Bundle from flask import Flask, render_template # Configuration -from dotenv import load_dotenv import platformdirs -import yaml +from dotenv import load_dotenv +from yaml import FullLoader, safe_load -from . import theme_manager +# Utils +from gallery.utils import theme_manager USER_DIR = platformdirs.user_config_dir('onlylegs') -INSTANCE_PATH = os.path.join(USER_DIR, 'instance') - - -# Check if any of the required files are missing -if not os.path.exists(platformdirs.user_config_dir('onlylegs')): - from . import setup - setup.SetupApp() - -# Get environment variables -if os.path.exists(os.path.join(USER_DIR, '.env')): - load_dotenv(os.path.join(USER_DIR, '.env')) - print("Loaded environment variables") -else: - print("No environment variables found!") - sys.exit(1) - - -# Get config file -if os.path.exists(os.path.join(USER_DIR, 'conf.yml')): - with open(os.path.join(USER_DIR, 'conf.yml'), encoding='utf-8') as f: - conf = yaml.load(f, Loader=yaml.FullLoader) - print("Loaded gallery config") -else: - print("No config file found!") - sys.exit(1) - -# Setup the logging config -LOGS_PATH = os.path.join(platformdirs.user_config_dir('onlylegs'), 'logs') - -if not os.path.isdir(LOGS_PATH): - os.mkdir(LOGS_PATH) - -logging.getLogger('werkzeug').disabled = True -logging.basicConfig( - filename=os.path.join(LOGS_PATH, 'only.log'), - level=logging.INFO, - datefmt='%Y-%m-%d %H:%M:%S', - format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s', - encoding='utf-8') def create_app(test_config=None): """ Create and configure the main app """ - app = Flask(__name__,instance_path=INSTANCE_PATH) + app = Flask(__name__, instance_path=os.path.join(USER_DIR, 'instance')) + assets = Environment() + cache = Cache(config={'CACHE_TYPE': 'SimpleCache', 'CACHE_DEFAULT_TIMEOUT': 300}) compress = Compress() + # Get environment variables + load_dotenv(os.path.join(USER_DIR, '.env')) + print("Loaded environment variables") + + # Get config file + with open(os.path.join(USER_DIR, 'conf.yml'), encoding='utf-8') as file: + conf = safe_load(file) + print("Loaded gallery config") + # App configuration app.config.from_mapping( SECRET_KEY=os.environ.get('FLASK_SECRET'), @@ -94,56 +63,40 @@ def create_app(test_config=None): except OSError: pass - + # Load theme theme_manager.CompileTheme('default', app.root_path) + # Bundle JS files + js_scripts = Bundle('js/*.js', output='gen/packed.js') + assets.register('js_all', js_scripts) - @app.errorhandler(405) - def method_not_allowed(err): - error = '405' - msg = err.description - return render_template('error.html', error=error, msg=msg), 404 - - @app.errorhandler(404) - def page_not_found(err): - error = '404' - msg = err.description - return render_template('error.html', error=error, msg=msg), 404 - + # Error handlers @app.errorhandler(403) - def forbidden(err): - error = '403' - msg = err.description - return render_template('error.html', error=error, msg=msg), 403 - + @app.errorhandler(404) + @app.errorhandler(405) @app.errorhandler(410) - def gone(err): - error = '410' - msg = err.description - return render_template('error.html', error=error, msg=msg), 410 - @app.errorhandler(500) - def internal_server_error(err): - error = '500' + def error_page(err): + error = err.code msg = err.description - return render_template('error.html', error=error, msg=msg), 500 + return render_template('error.html', error=error, msg=msg), err.code # Load login, registration and logout manager - from . import auth + from gallery import auth app.register_blueprint(auth.blueprint) - # Load routes for home and images - from . import routing + # Load the different routes + from .routes import api, groups, routing, settings + app.register_blueprint(api.blueprint) + app.register_blueprint(groups.blueprint) app.register_blueprint(routing.blueprint) - app.add_url_rule('/', endpoint='index') - - # Load routes for settings - from . import settings app.register_blueprint(settings.blueprint) - # Load APIs - from . import api - app.register_blueprint(api.blueprint) + # Log to file that the app has started + logging.info('Gallery started successfully!') + # Initialize extensions and return app + assets.init_app(app) + cache.init_app(app) compress.init_app(app) return app diff --git a/gallery/api.py b/gallery/api.py deleted file mode 100644 index 04533d9..0000000 --- a/gallery/api.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Onlylegs - API endpoints -Used intermally by the frontend and possibly by other applications -""" -from uuid import uuid4 -import os -import io -import logging - -from flask import ( - Blueprint, current_app, send_from_directory, send_file, request, g, abort, flash, jsonify) -from werkzeug.utils import secure_filename - -from PIL import Image, ImageOps # ImageFilter -from sqlalchemy.orm import sessionmaker - -from gallery.auth import login_required - -from . import db # Import db to create a session -from . import metadata as mt - - -blueprint = Blueprint('api', __name__, url_prefix='/api') -db_session = sessionmaker(bind=db.engine) -db_session = db_session() - - -@blueprint.route('/uploads/', methods=['GET']) -def uploads(file): - """ - Returns a file from the uploads folder - w and h are the width and height of the image for resizing - f is whether to apply filters to the image, such as blurring NSFW images - """ - # Get args - width = request.args.get('w', default=0, type=int) # Width of image - height = request.args.get('h', default=0, type=int) # Height of image - filtered = request.args.get('f', default=False, type=bool) # Whether to apply filters - - # if no args are passed, return the raw file - if width == 0 and height == 0 and not filtered: - if not os.path.exists(os.path.join(current_app.config['UPLOAD_FOLDER'], - secure_filename(file))): - abort(404) - return send_from_directory(current_app.config['UPLOAD_FOLDER'], file ,as_attachment=True) - - # Of either width or height is 0, set it to the other value to keep aspect ratio - if width > 0 and height == 0: - height = width - elif width == 0 and height > 0: - width = height - - set_ext = current_app.config['ALLOWED_EXTENSIONS'] - buff = io.BytesIO() - - # Open image and set extension - try: - img = Image.open(os.path.join(current_app.config['UPLOAD_FOLDER'],file)) - except FileNotFoundError: - logging.error('File not found: %s, possibly broken upload', file) - abort(404) - except Exception as err: - logging.error('Error opening image: %s', err) - abort(500) - - img_ext = os.path.splitext(file)[-1].lower().replace('.', '') - img_ext = set_ext[img_ext] - # Get ICC profile as it alters colours when saving - img_icc = img.info.get("icc_profile") - - # Resize image and orientate correctly - img.thumbnail((width, height), Image.LANCZOS) - img = ImageOps.exif_transpose(img) - - # If has NSFW tag, blur image, etc. - if filtered: - #img = img.filter(ImageFilter.GaussianBlur(20)) - pass - - try: - img.save(buff, img_ext, icc_profile=img_icc) - except OSError: - # This usually happens when saving a JPEG with an ICC profile - # Convert to RGB and try again - img = img.convert('RGB') - img.save(buff, img_ext, icc_profile=img_icc) - except Exception as err: - logging.error('Could not resize image %s, error: %s', file, err) - abort(500) - - img.close() - - # Seek to beginning of buffer and return - buff.seek(0) - return send_file(buff, mimetype='image/' + img_ext) - - -@blueprint.route('/upload', methods=['POST']) -@login_required -def upload(): - """ - Uploads an image to the server and saves it to the database - """ - form_file = request.files['file'] - form = request.form - - if not form_file: - return abort(404) - - img_ext = os.path.splitext(form_file.filename)[-1].replace('.', '').lower() - img_name = f"GWAGWA_{str(uuid4())}.{img_ext}" - - if not img_ext in current_app.config['ALLOWED_EXTENSIONS'].keys(): - logging.info('File extension not allowed: %s', img_ext) - abort(403) - - if os.path.isdir(current_app.config['UPLOAD_FOLDER']) is False: - os.mkdir(current_app.config['UPLOAD_FOLDER']) - - # Save to database - try: - db_session.add(db.posts(img_name, form['description'], form['alt'], g.user.id)) - db_session.commit() - except Exception as err: - logging.error('Could not save to database: %s', err) - abort(500) - - # Save file - try: - form_file.save( - os.path.join(current_app.config['UPLOAD_FOLDER'], img_name)) - except Exception as err: - logging.error('Could not save file: %s', err) - abort(500) - - return 'Gwa Gwa' - - -@blueprint.route('/remove/', methods=['POST']) -@login_required -def remove(img_id): - """ - Deletes an image from the server and database - """ - img = db_session.query(db.posts).filter_by(id=img_id).first() - - if img is None: - abort(404) - if img.author_id != g.user.id: - abort(403) - - try: - os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'],img.file_name)) - except FileNotFoundError: - # File was already deleted or doesn't exist - logging.warning('File not found: %s, already deleted or never existed', img.file_name) - except Exception as err: - logging.error('Could not remove file: %s', err) - abort(500) - - try: - db_session.query(db.posts).filter_by(id=img_id).delete() - db_session.commit() - except Exception as err: - logging.error('Could not remove from database: %s', err) - abort(500) - - logging.info('Removed image (%s) %s', img_id, img.file_name) - flash(['Image was all in Le Head!', 1]) - return 'Gwa Gwa' - - -@blueprint.route('/metadata/', methods=['GET']) -def metadata(img_id): - """ - Yoinks metadata from an image - """ - img = db_session.query(db.posts).filter_by(id=img_id).first() - - if img is None: - abort(404) - - img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img.file_name) - exif = mt.Metadata(img_path).yoink() - - return jsonify(exif) - - -@blueprint.route('/logfile') -@login_required -def logfile(): - """ - Gets the log file and returns it as a JSON object - """ - filename = logging.getLoggerClass().root.handlers[0].baseFilename - log_dict = {} - i = 0 - - with open(filename, encoding='utf-8') as file: - for line in file: - line = line.split(' : ') - - event = line[0].strip().split(' ') - event_data = { - 'date': event[0], - 'time': event[1], - 'severity': event[2], - 'owner': event[3] - } - - message = line[1].strip() - try: - message_data = { - 'code': int(message[1:4]), - 'message': message[5:].strip() - } - except ValueError: - message_data = {'code': 0, 'message': message} - except Exception as err: - logging.error('Could not parse log file: %s', err) - abort(500) - - log_dict[i] = {'event': event_data, 'message': message_data} - - i += 1 # Line number, starts at 0 - - return jsonify(log_dict) diff --git a/gallery/auth.py b/gallery/auth.py index 3db96bf..0c7cfa4 100644 --- a/gallery/auth.py +++ b/gallery/auth.py @@ -1,10 +1,11 @@ """ -OnlyLegs - Authentification +OnlyLegs - Authentication User registration, login and logout and locking access to pages behind a login """ import re import uuid import logging +from datetime import datetime as dt import functools from flask import Blueprint, flash, g, redirect, request, session, url_for, abort, jsonify @@ -28,7 +29,7 @@ def login_required(view): @functools.wraps(view) def wrapped_view(**kwargs): if g.user is None or session.get('uuid') is None: - logging.error('Authentification failed') + logging.error('Authentication failed') session.clear() return redirect(url_for('gallery.index')) @@ -49,14 +50,14 @@ def load_logged_in_user(): g.user = None session.clear() else: - is_alive = db_session.query(db.sessions).filter_by(session_uuid=user_uuid).first() + is_alive = db_session.query(db.Sessions).filter_by(session_uuid=user_uuid).first() if is_alive is None: logging.info('Session expired') flash(['Session expired!', '3']) session.clear() else: - g.user = db_session.query(db.users).filter_by(id=user_id).first() + g.user = db_session.query(db.Users).filter_by(id=user_id).first() @blueprint.route('/register', methods=['POST']) @@ -74,7 +75,6 @@ def register(): email_regex = re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b') username_regex = re.compile(r'\b[A-Za-z0-9._%+-]+\b') - if not username or not username_regex.match(username): error.append('Username is invalid!') @@ -94,9 +94,12 @@ def register(): if error: return jsonify(error) - try: - db_session.add(db.users(username, email, generate_password_hash(password))) + register_user = db.Users(username=username, + email=email, + password=generate_password_hash(password), + created_at=dt.utcnow()) + db_session.add(register_user) db_session.commit() except exc.IntegrityError: return f'User {username} is already registered!' @@ -116,10 +119,9 @@ def login(): username = request.form['username'] password = request.form['password'] - user = db_session.query(db.users).filter_by(username=username).first() + user = db_session.query(db.Users).filter_by(username=username).first() error = [] - if user is None: logging.error('User %s does not exist. Login attempt from %s', username, request.remote_addr) @@ -132,17 +134,19 @@ def login(): if error: abort(403) - try: session.clear() session['user_id'] = user.id session['uuid'] = str(uuid.uuid4()) - db_session.add(db.sessions(user.id, - session.get('uuid'), - request.remote_addr, - request.user_agent.string, - 1)) + session_query = db.Sessions(user_id=user.id, + session_uuid=session.get('uuid'), + ip_address=request.remote_addr, + user_agent=request.user_agent.string, + active=True, + created_at=dt.utcnow()) + + db_session.add(session_query) db_session.commit() except Exception as err: logging.error('User %s could not be logged in: %s', username, err) @@ -160,4 +164,4 @@ def logout(): """ logging.info('User (%s) %s logged out', session.get('user_id'), g.user.username) session.clear() - return redirect(url_for('index')) + return redirect(url_for('gallery.index')) diff --git a/gallery/db.py b/gallery/db.py index 31a43c6..fc68d75 100644 --- a/gallery/db.py +++ b/gallery/db.py @@ -1,21 +1,25 @@ """ -OnlyLegs - Database -Database models and functions for SQLAlchemy +OnlyLegs - Database models and functions for SQLAlchemy """ import os -from datetime import datetime import platformdirs -from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, ForeignKey +from sqlalchemy import ( + create_engine, Column, Integer, String, Boolean, DateTime, ForeignKey, PickleType) from sqlalchemy.orm import declarative_base, relationship -path_to_db = os.path.join(platformdirs.user_config_dir('onlylegs'), 'gallery.sqlite') -engine = create_engine(f'sqlite:///{path_to_db}', echo=False) +USER_DIR = platformdirs.user_config_dir('onlylegs') +DB_PATH = os.path.join(USER_DIR, 'gallery.sqlite') + + +# engine = create_engine('postgresql://username:password@host:port/database_name', echo=False) +# engine = create_engine('mysql://username:password@host:port/database_name', echo=False) +engine = create_engine(f'sqlite:///{DB_PATH}', echo=False) base = declarative_base() -class users (base): # pylint: disable=too-few-public-methods, C0103 +class Users (base): # pylint: disable=too-few-public-methods, C0103 """ User table Joins with post, groups, session and log @@ -28,19 +32,13 @@ class users (base): # pylint: disable=too-few-public-methods, C0103 password = Column(String, nullable=False) created_at = Column(DateTime, nullable=False) - posts = relationship('posts') - groups = relationship('groups') - session = relationship('sessions') - log = relationship('logs') - - def __init__(self, username, email, password): - self.username = username - self.email = email - self.password = password - self.created_at = datetime.now() + posts = relationship('Posts', backref='users') + groups = relationship('Groups', backref='users') + session = relationship('Sessions', backref='users') + log = relationship('Logs', backref='users') -class posts (base): # pylint: disable=too-few-public-methods, C0103 +class Posts (base): # pylint: disable=too-few-public-methods, C0103 """ Post table Joins with group_junction @@ -48,23 +46,34 @@ class posts (base): # pylint: disable=too-few-public-methods, C0103 __tablename__ = 'posts' id = Column(Integer, primary_key=True) - file_name = Column(String, unique=True, nullable=False) - description = Column(String, nullable=False) - alt = Column(String, nullable=False) author_id = Column(Integer, ForeignKey('users.id')) created_at = Column(DateTime, nullable=False) - junction = relationship('group_junction') + file_name = Column(String, unique=True, nullable=False) + file_type = Column(String, nullable=False) - def __init__(self, file_name, description, alt, author_id): - self.file_name = file_name - self.description = description - self.alt = alt - self.author_id = author_id - self.created_at = datetime.now() + image_exif = Column(PickleType, nullable=False) + image_colours = Column(PickleType, nullable=False) + + post_description = Column(String, nullable=False) + post_alt = Column(String, nullable=False) + + junction = relationship('GroupJunction', backref='posts') -class groups (base): # pylint: disable=too-few-public-methods, C0103 +class Thumbnails (base): # pylint: disable=too-few-public-methods, C0103 + """ + Thumbnail table + """ + __tablename__ = 'thumbnails' + + id = Column(Integer, primary_key=True) + file_name = Column(String, unique=True, nullable=False) + file_ext = Column(String, nullable=False) + data = Column(PickleType, nullable=False) + + +class Groups (base): # pylint: disable=too-few-public-methods, C0103 """ Group table Joins with group_junction @@ -77,16 +86,10 @@ class groups (base): # pylint: disable=too-few-public-methods, C0103 author_id = Column(Integer, ForeignKey('users.id')) created_at = Column(DateTime, nullable=False) - junction = relationship('group_junction') - - def __init__(self, name, description, author_id): - self.name = name - self.description = description - self.author_id = author_id - self.created_at = datetime.now() + junction = relationship('GroupJunction', backref='groups') -class group_junction (base): # pylint: disable=too-few-public-methods, C0103 +class GroupJunction (base): # pylint: disable=too-few-public-methods, C0103 """ Junction table for posts and groups Joins with posts and groups @@ -94,15 +97,12 @@ class group_junction (base): # pylint: disable=too-few-public-methods, C0103 __tablename__ = 'group_junction' id = Column(Integer, primary_key=True) + date_added = Column(DateTime, nullable=False) group_id = Column(Integer, ForeignKey('groups.id')) post_id = Column(Integer, ForeignKey('posts.id')) - def __init__(self, group_id, post_id): - self.group_id = group_id - self.post_id = post_id - -class sessions (base): # pylint: disable=too-few-public-methods, C0103 +class Sessions (base): # pylint: disable=too-few-public-methods, C0103 """ Session table Joins with user @@ -117,16 +117,8 @@ class sessions (base): # pylint: disable=too-few-public-methods, C0103 active = Column(Boolean, nullable=False) created_at = Column(DateTime, nullable=False) - def __init__(self, user_id, session_uuid, ip_address, user_agent, active): # pylint: disable=too-many-arguments, C0103 - self.user_id = user_id - self.session_uuid = session_uuid - self.ip_address = ip_address - self.user_agent = user_agent - self.active = active - self.created_at = datetime.now() - -class logs (base): # pylint: disable=too-few-public-methods, C0103 +class Logs (base): # pylint: disable=too-few-public-methods, C0103 """ Log table Joins with user @@ -140,15 +132,8 @@ class logs (base): # pylint: disable=too-few-public-methods, C0103 msg = Column(String, nullable=False) created_at = Column(DateTime, nullable=False) - def __init__(self, user_id, ip_address, code, msg): - self.user_id = user_id - self.ip_address = ip_address - self.code = code - self.msg = msg - self.created_at = datetime.now() - -class bans (base): # pylint: disable=too-few-public-methods, C0103 +class Bans (base): # pylint: disable=too-few-public-methods, C0103 """ Bans table """ @@ -160,11 +145,8 @@ class bans (base): # pylint: disable=too-few-public-methods, C0103 msg = Column(String, nullable=False) created_at = Column(DateTime, nullable=False) - def __init__(self, ip_address, code, msg): - self.ip_address = ip_address - self.code = code - self.msg = msg - self.created_at = datetime.now() - -base.metadata.create_all(engine) +# check if database file exists, if not create it +if not os.path.isfile(DB_PATH): + base.metadata.create_all(engine) + print('Database created') diff --git a/gallery/routes/api.py b/gallery/routes/api.py new file mode 100644 index 0000000..000042c --- /dev/null +++ b/gallery/routes/api.py @@ -0,0 +1,305 @@ +""" +Onlylegs - API endpoints +Used internally by the frontend and possibly by other applications +""" +from uuid import uuid4 +import os +import pathlib +import io +import logging +from datetime import datetime as dt + +from flask import (Blueprint, send_from_directory, send_file, + abort, flash, jsonify, request, g, current_app) +from werkzeug.utils import secure_filename + +from colorthief import ColorThief +from PIL import Image, ImageOps, ImageFilter + +from sqlalchemy.orm import sessionmaker +from gallery.auth import login_required + +from gallery import db +from gallery.utils import metadata as mt + + +blueprint = Blueprint('api', __name__, url_prefix='/api') +db_session = sessionmaker(bind=db.engine) +db_session = db_session() + + +@blueprint.route('/file/', methods=['GET']) +def get_file(file_name): + """ + Returns a file from the uploads folder + r for resolution, 400x400 or thumb for thumbnail + f is whether to apply filters to the image, such as blurring NSFW images + b is whether to force blur the image, even if it's not NSFW + """ + # Get args + res = request.args.get('r', default=None, type=str) # Type of file (thumb, etc) + filtered = request.args.get('f', default=False, type=bool) # Whether to apply filters # pylint: disable=W0612 + blur = request.args.get('b', default=False, type=bool) # Whether to force blur + + file_name = secure_filename(file_name) # Sanitize file name + + # if no args are passed, return the raw file + if not request.args: + if not os.path.exists(os.path.join(current_app.config['UPLOAD_FOLDER'], file_name)): + abort(404) + + return send_from_directory(current_app.config['UPLOAD_FOLDER'], file_name) + + buff = io.BytesIO() + img = None # Image object to be set + + try: # Open image and set extension + img = Image.open(os.path.join(current_app.config['UPLOAD_FOLDER'], file_name)) + except FileNotFoundError: # FileNotFound is raised if the file doesn't exist + logging.error('File not found: %s', file_name) + abort(404) + except OSError as err: # OSError is raised if the file is broken or corrupted + logging.error('Possibly broken image %s, error: %s', file_name, err) + abort(500) + + img_ext = pathlib.Path(file_name).suffix.replace('.', '').lower() # Get file extension + img_ext = current_app.config['ALLOWED_EXTENSIONS'][img_ext] # Convert to MIME type + img_icc = img.info.get("icc_profile") # Get ICC profile + + img = ImageOps.exif_transpose(img) # Rotate image based on EXIF data + + # Todo: If type is thumb(nail), return from database instead of file system pylint: disable=W0511 + # as it's faster than generating a new thumbnail on every request + if res: + if res in ['thumb', 'thumbnail']: + width, height = 400, 400 + elif res in ['prev', 'preview']: + width, height = 1920, 1080 + else: + try: + width, height = res.split('x') + width = int(width) + height = int(height) + except ValueError: + abort(400) + + img.thumbnail((width, height), Image.LANCZOS) + + # Todo: If the image has a NSFW tag, blur image for example pylint: disable=W0511 + # if filtered: + # pass + + # If forced to blur, blur image + if blur: + img = img.filter(ImageFilter.GaussianBlur(20)) + + try: + img.save(buff, img_ext, icc_profile=img_icc) + except OSError: + # This usually happens when saving a JPEG with an ICC profile, + # so we convert to RGB and try again + img = img.convert('RGB') + img.save(buff, img_ext, icc_profile=img_icc) + except Exception as err: + logging.error('Could not resize image %s, error: %s', file_name, err) + abort(500) + + img.close() # Close image to free memory, learned the hard way + buff.seek(0) # Reset buffer to start + + return send_file(buff, mimetype='image/' + img_ext) + + +@blueprint.route('/upload', methods=['POST']) +@login_required +def upload(): + """ + Uploads an image to the server and saves it to the database + """ + form_file = request.files['file'] + form = request.form + + # If no image is uploaded, return 404 error + if not form_file: + return abort(404) + + # Get file extension, generate random name and set file path + img_ext = pathlib.Path(form_file.filename).suffix.replace('.', '').lower() + img_name = "GWAGWA_"+str(uuid4()) + img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img_name+'.'+img_ext) + + # Check if file extension is allowed + if img_ext not in current_app.config['ALLOWED_EXTENSIONS'].keys(): + logging.info('File extension not allowed: %s', img_ext) + abort(403) + + # Save file + try: + form_file.save(img_path) + except Exception as err: + logging.error('Could not save file: %s', err) + abort(500) + + img_exif = mt.Metadata(img_path).yoink() # Get EXIF data + img_colors = ColorThief(img_path).get_palette(color_count=3) # Get color palette + + # Save to database + try: + query = db.Posts(author_id=g.user.id, + created_at=dt.utcnow(), + file_name=img_name+'.'+img_ext, + file_type=img_ext, + image_exif=img_exif, + image_colours=img_colors, + post_description=form['description'], + post_alt=form['alt']) + + db_session.add(query) + db_session.commit() + except Exception as err: + logging.error('Could not save to database: %s', err) + abort(500) + + return 'Gwa Gwa' # Return something so the browser doesn't show an error + + +@blueprint.route('/delete/', methods=['POST']) +@login_required +def delete_image(image_id): + """ + Deletes an image from the server and database + """ + img = db_session.query(db.Posts).filter_by(id=image_id).first() + + if img is None: + abort(404) + if img.author_id != g.user.id: + abort(403) + + try: + os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'],img.file_name)) + except FileNotFoundError: + # File was already deleted or doesn't exist + logging.warning('File not found: %s, already deleted or never existed', img.file_name) + except Exception as err: + logging.error('Could not remove file: %s', err) + abort(500) + + try: + db_session.query(db.Posts).filter_by(id=image_id).delete() + + groups = db_session.query(db.GroupJunction).filter_by(post_id=image_id).all() + for group in groups: + db_session.delete(group) + + db_session.commit() + except Exception as err: + logging.error('Could not remove from database: %s', err) + abort(500) + + logging.info('Removed image (%s) %s', image_id, img.file_name) + flash(['Image was all in Le Head!', 1]) + return 'Gwa Gwa' + + +@blueprint.route('/group/create', methods=['POST']) +@login_required +def create_group(): + """ + Creates a group + """ + new_group = db.Groups(name=request.form['name'], + description=request.form['description'], + author_id=g.user.id, + created_at=dt.utcnow()) + + db_session.add(new_group) + db_session.commit() + + return ':3' + + +@blueprint.route('/group/modify', methods=['POST']) +@login_required +def modify_group(): + """ + Changes the images in a group + """ + group_id = request.form['group'] + image_id = request.form['image'] + + group = db_session.query(db.Groups).filter_by(id=group_id).first() + + if group is None: + abort(404) + elif group.author_id != g.user.id: + abort(403) + + if request.form['action'] == 'add': + if not db_session.query(db.GroupJunction)\ + .filter_by(group_id=group_id, post_id=image_id)\ + .first(): + db_session.add(db.GroupJunction(group_id=group_id, + post_id=image_id, + date_added=dt.utcnow())) + elif request.form['action'] == 'remove': + db_session.query(db.GroupJunction)\ + .filter_by(group_id=group_id, post_id=image_id)\ + .delete() + + db_session.commit() + + return ':3' + + +@blueprint.route('/metadata/', methods=['GET']) +def metadata(img_id): + """ + Yoinks metadata from an image + """ + img = db_session.query(db.Posts).filter_by(id=img_id).first() + + if not img: + abort(404) + + img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img.file_name) + exif = mt.Metadata(img_path).yoink() + + return jsonify(exif) + + +@blueprint.route('/logfile') +@login_required +def logfile(): + """ + Gets the log file and returns it as a JSON object + """ + log_dict = {} + + with open('only.log', encoding='utf-8', mode='r') as file: + for i, line in enumerate(file): + line = line.split(' : ') + + event = line[0].strip().split(' ') + event_data = { + 'date': event[0], + 'time': event[1], + 'severity': event[2], + 'owner': event[3] + } + + message = line[1].strip() + try: + message_data = { + 'code': int(message[1:4]), + 'message': message[5:].strip() + } + except ValueError: + message_data = {'code': 0, 'message': message} + except Exception as err: + logging.error('Could not parse log file: %s', err) + abort(500) + + log_dict[i] = {'event': event_data, 'message': message_data} + + return jsonify(log_dict) diff --git a/gallery/routes/groups.py b/gallery/routes/groups.py new file mode 100644 index 0000000..0c5f170 --- /dev/null +++ b/gallery/routes/groups.py @@ -0,0 +1,106 @@ +""" +Onlylegs - Image Groups +Why groups? Because I don't like calling these albums +sounds more limiting that it actually is in this gallery +""" +from flask import Blueprint, abort, render_template, url_for + +from sqlalchemy.orm import sessionmaker +from gallery import db + + +blueprint = Blueprint('group', __name__, url_prefix='/group') +db_session = sessionmaker(bind=db.engine) +db_session = db_session() + + +@blueprint.route('/', methods=['GET']) +def groups(): + """ + Group overview, shows all image groups + """ + group_list = db_session.query(db.Groups).all() + + for group_item in group_list: + thumbnail = db_session.query(db.GroupJunction.post_id)\ + .filter(db.GroupJunction.group_id == group_item.id)\ + .order_by(db.GroupJunction.date_added.desc())\ + .first() + + if thumbnail: + group_item.thumbnail = db_session.query(db.Posts.file_name, db.Posts.post_alt, + db.Posts.image_colours, db.Posts.id)\ + .filter(db.Posts.id == thumbnail[0])\ + .first() + + return render_template('groups/list.html', groups=group_list) + + +@blueprint.route('/') +def group(group_id): + """ + Group view, shows all images in a group + """ + group_item = db_session.query(db.Groups).filter(db.Groups.id == group_id).first() + + if group_item is None: + abort(404, 'Group not found! D:') + + group_item.author_username = db_session.query(db.Users.username)\ + .filter(db.Users.id == group_item.author_id)\ + .first()[0] + + group_images = db_session.query(db.GroupJunction.post_id)\ + .filter(db.GroupJunction.group_id == group_id)\ + .order_by(db.GroupJunction.date_added.desc())\ + .all() + + images = [] + for image in group_images: + image = db_session.query(db.Posts).filter(db.Posts.id == image[0]).first() + images.append(image) + + return render_template('groups/group.html', group=group_item, images=images) + + +@blueprint.route('//') +def group_post(group_id, image_id): + """ + Image view, shows the image and its metadata from a specific group + """ + img = db_session.query(db.Posts).filter(db.Posts.id == image_id).first() + + if img is None: + abort(404, 'Image not found') + + img.author_username = db_session.query(db.Users.username)\ + .filter(db.Users.id == img.author_id)\ + .first()[0] + + group_list = db_session.query(db.GroupJunction.group_id)\ + .filter(db.GroupJunction.post_id == image_id)\ + .all() + + img.group_list = [] + for group_item in group_list: + group_item = db_session.query(db.Groups).filter(db.Groups.id == group_item[0]).first() + img.group_list.append(group_item) + + next_url = db_session.query(db.GroupJunction.post_id)\ + .filter(db.GroupJunction.group_id == group_id)\ + .filter(db.GroupJunction.post_id > image_id)\ + .order_by(db.GroupJunction.date_added.asc())\ + .first() + + prev_url = db_session.query(db.GroupJunction.post_id)\ + .filter(db.GroupJunction.group_id == group_id)\ + .filter(db.GroupJunction.post_id < image_id)\ + .order_by(db.GroupJunction.date_added.desc())\ + .first() + + if next_url is not None: + next_url = url_for('group.group_post', group_id=group_id, image_id=next_url[0]) + if prev_url is not None: + prev_url = url_for('group.group_post', group_id=group_id, image_id=prev_url[0]) + + return render_template('image.html', image=img, next_url=next_url, prev_url=prev_url) diff --git a/gallery/routes/routing.py b/gallery/routes/routing.py new file mode 100644 index 0000000..4ae6a15 --- /dev/null +++ b/gallery/routes/routing.py @@ -0,0 +1,81 @@ +""" +Onlylegs Gallery - Routing +""" +from flask import Blueprint, render_template, url_for +from werkzeug.exceptions import abort + +from sqlalchemy.orm import sessionmaker +from gallery import db + + +blueprint = Blueprint('gallery', __name__) +db_session = sessionmaker(bind=db.engine) +db_session = db_session() + + +@blueprint.route('/') +def index(): + """ + Home page of the website, shows the feed of the latest images + """ + images = db_session.query(db.Posts.file_name, + db.Posts.post_alt, + db.Posts.image_colours, + db.Posts.created_at, + db.Posts.id).order_by(db.Posts.id.desc()).all() + + return render_template('index.html', images=images) + + +@blueprint.route('/image/') +def image(image_id): + """ + Image view, shows the image and its metadata + """ + img = db_session.query(db.Posts).filter(db.Posts.id == image_id).first() + + if not img: + abort(404, 'Image not found :<') + + img.author_username = db_session.query(db.Users.username)\ + .filter(db.Users.id == img.author_id).first()[0] + + groups = db_session.query(db.GroupJunction.group_id)\ + .filter(db.GroupJunction.post_id == image_id).all() + + img.groups = [] + for group in groups: + group = db_session.query(db.Groups).filter(db.Groups.id == group[0]).first() + img.groups.append(group) + + next_url = db_session.query(db.Posts.id)\ + .filter(db.Posts.id > image_id)\ + .order_by(db.Posts.id.asc())\ + .first() + prev_url = db_session.query(db.Posts.id)\ + .filter(db.Posts.id < image_id)\ + .order_by(db.Posts.id.desc())\ + .first() + + if next_url: + next_url = url_for('gallery.image', image_id=next_url[0]) + if prev_url: + prev_url = url_for('gallery.image', image_id=prev_url[0]) + + return render_template('image.html', image=img, next_url=next_url, prev_url=prev_url) + + +@blueprint.route('/profile') +def profile(): + """ + Profile overview, shows all profiles on the onlylegs gallery + """ + return render_template('profile.html', user_id='gwa gwa') + + +@blueprint.route('/profile/') +def profile_id(user_id): + """ + Shows user ofa given id, displays their uploads and other info + """ + return render_template('profile.html', user_id=user_id) diff --git a/gallery/settings.py b/gallery/routes/settings.py similarity index 99% rename from gallery/settings.py rename to gallery/routes/settings.py index a4cd845..04da5e4 100644 --- a/gallery/settings.py +++ b/gallery/routes/settings.py @@ -17,6 +17,7 @@ def general(): """ return render_template('settings/general.html') + @blueprint.route('/server') @login_required def server(): @@ -25,6 +26,7 @@ def server(): """ return render_template('settings/server.html') + @blueprint.route('/account') @login_required def account(): @@ -33,6 +35,7 @@ def account(): """ return render_template('settings/account.html') + @blueprint.route('/logs') @login_required def logs(): diff --git a/gallery/routing.py b/gallery/routing.py deleted file mode 100644 index 9389bcf..0000000 --- a/gallery/routing.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Onlylegs Gallery - Routing -""" -import os - -from flask import Blueprint, render_template, current_app -from werkzeug.exceptions import abort - -from sqlalchemy.orm import sessionmaker - -from . import db -from . import metadata as mt - - -blueprint = Blueprint('gallery', __name__) -db_session = sessionmaker(bind=db.engine) -db_session = db_session() - - -@blueprint.route('/') -def index(): - """ - Home page of the website, shows the feed of latest images - """ - images = db_session.query(db.posts).order_by(db.posts.id.desc()).all() - - return render_template('index.html', - images=images, - image_count=len(images), - name=current_app.config['WEBSITE']['name'], - motto=current_app.config['WEBSITE']['motto']) - -@blueprint.route('/image/') -def image(image_id): - """ - Image view, shows the image and its metadata - """ - img = db_session.query(db.posts).filter_by(id=image_id).first() - - if img is None: - abort(404) - - img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img.file_name) - exif = mt.Metadata(img_path).yoink() - - return render_template('image.html', image=img, exif=exif) - -@blueprint.route('/group') -def groups(): - """ - Group overview, shows all image groups - """ - return render_template('group.html', group_id='gwa gwa') - -@blueprint.route('/group/') -def group(group_id): - """ - Group view, shows all images in a group - """ - return render_template('group.html', group_id=group_id) - -@blueprint.route('/profile') -def profile(): - """ - Profile overview, shows all profiles on the onlylegs gallery - """ - return render_template('profile.html', user_id='gwa gwa') - -@blueprint.route('/profile/') -def profile_id(user_id): - """ - Shows user ofa given id, displays their uploads and other info - """ - return render_template('profile.html', user_id=user_id) diff --git a/gallery/setup.py b/gallery/setup.py deleted file mode 100644 index fea6547..0000000 --- a/gallery/setup.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -OnlyLegs - Setup -Runs when the app detects that there is no user directory -""" -import os -import sys -import platformdirs -import yaml - -USER_DIR = platformdirs.user_config_dir('onlylegs') - -class SetupApp: - """ - Setup the application on first run - """ - def __init__(self): - """ - Main setup function - """ - print("Running setup...") - - if not os.path.exists(USER_DIR): - self.make_dir() - if not os.path.exists(os.path.join(USER_DIR, '.env')): - self.make_env() - if not os.path.exists(os.path.join(USER_DIR, 'conf.yml')): - self.make_yaml() - - def make_dir(self): - """ - Create the user directory - """ - try: - os.makedirs(USER_DIR) - os.makedirs(os.path.join(USER_DIR, 'instance')) - - print("Created user directory at:", USER_DIR) - except Exception as err: - print("Error creating user directory:", err) - sys.exit(1) # exit with error code - - def make_env(self): - """ - Create the .env file with default values - """ - env_conf = { - 'FLASK_SECRETE': 'dev', - } - try: - with open(os.path.join(USER_DIR, '.env'), encoding='utf-8') as file: - for key, value in env_conf.items(): - file.write(f"{key}={value}\n") - print("Created environment variables") - except Exception as err: - print("Error creating environment variables:", err) - sys.exit(1) - - print("Generated default .env file. EDIT IT BEFORE RUNNING THE APP AGAIN!") - - def make_yaml(self): - """ - Create the YAML config file with default values - """ - yaml_conf = { - 'admin': { - 'name': 'Real Person', - 'username': 'User', - 'email': 'real-email@some.place' - }, - 'upload': { - 'allowed-extensions': { - 'jpg': 'jpeg', - 'jpeg': 'jpeg', - 'png': 'png', - 'webp': 'webp' - }, - 'max-size': 69, - 'rename': 'GWA_\{\{username\}\}_\{\{time\}\}' - }, - 'website': { - 'name': 'OnlyLegs', - 'motto': 'Gwa Gwa', - 'language': 'english' - }, - 'server': { - 'host': '0.0.0.0', - 'port': 5000 - }, - } - try: - with open(os.path.join(USER_DIR, 'conf.yml'), encoding='utf-8') as file: - yaml.dump(yaml_conf, file, default_flow_style=False) - print("Created default gallery config") - except Exception as err: - print("Error creating default gallery config:", err) - sys.exit(1) - - print("Generated default YAML config. EDIT IT BEFORE RUNNING THE APP AGAIN!") diff --git a/gallery/static/images/background.svg b/gallery/static/images/background.svg deleted file mode 100644 index ef824d3..0000000 --- a/gallery/static/images/background.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/gallery/static/images/bg.svg b/gallery/static/images/bg.svg new file mode 100644 index 0000000..b133190 --- /dev/null +++ b/gallery/static/images/bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gallery/static/images/leaves.jpg b/gallery/static/images/leaves.jpg deleted file mode 100644 index 5a8e723..0000000 Binary files a/gallery/static/images/leaves.jpg and /dev/null differ diff --git a/gallery/static/images/logo-black.svg b/gallery/static/images/logo-black.svg new file mode 100644 index 0000000..559ad4d --- /dev/null +++ b/gallery/static/images/logo-black.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + diff --git a/gallery/static/images/logo-white.svg b/gallery/static/images/logo-white.svg new file mode 100644 index 0000000..a50b3f3 --- /dev/null +++ b/gallery/static/images/logo-white.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + diff --git a/gallery/static/js/login.js b/gallery/static/js/login.js index d66c25a..f7cd4cb 100644 --- a/gallery/static/js/login.js +++ b/gallery/static/js/login.js @@ -1,108 +1,123 @@ +// Function to show login function showLogin() { popUpShow( - 'idk what to put here, just login please', + 'Login!', 'Need an account? Register!', - '', + '\ + ', '
\ - \ - \ + \ + \
' ); }; +// Function to login +function login(event) { + // AJAX takes control of subby form :3 + event.preventDefault(); + + let formUsername = document.querySelector("#username").value; + let formPassword = document.querySelector("#password").value; + + if (formUsername === "" || formPassword === "") { + addNotification("Please fill in all fields!!!!", 3); + return; + } + + // Make form + var formData = new FormData(); + formData.append("username", formUsername); + formData.append("password", formPassword); + + $.ajax({ + url: '/auth/login', + type: 'post', + data: formData, + contentType: false, + processData: false, + success: function (response) { + location.reload(); + }, + error: function (response) { + switch (response.status) { + case 500: + addNotification('Server exploded, F\'s in chat', 2); + break; + case 403: + addNotification('None but devils play past here... Wrong information', 2); + break; + default: + addNotification('Error logging in, blame someone', 2); + break; + } + } + }); +} +// Function to show register function showRegister() { popUpShow( 'Who are you?', 'Already have an account? Login!', - '', + '\ + ', '
\ - \ - \ - \ - \ + \ + \ + \ + \
' ); }; - -function login(event) { - // AJAX takes control of subby form - event.preventDefault(); - - if ($("#username").val() === "" || $("#password").val() === "") { - addNotification("Please fill in all fields", 3); - } else { - // Make form - var formData = new FormData(); - formData.append("username", $("#username").val()); - formData.append("password", $("#password").val()); - - $.ajax({ - url: '/auth/login', - type: 'post', - data: formData, - contentType: false, - processData: false, - success: function (response) { - location.reload(); - }, - error: function (response) { - switch (response.status) { - case 500: - addNotification('Server exploded, F\'s in chat', 2); - break; - case 403: - addNotification('None but devils play past here... Wrong information', 2); - break; - default: - addNotification('Error logging in, blame someone', 2); - break; - } - } - }); - } -} +// Function to register function register(obj) { // AJAX takes control of subby form event.preventDefault(); - if ($("#username").val() === "" || $("#email").val() === "" || $("#password").val() === "" || $("#password-repeat").val() === "") { - addNotification("Please fill in all fields", 3); - } else { - // Make form - var formData = new FormData(); - formData.append("username", $("#username").val()); - formData.append("email", $("#email").val()); - formData.append("password", $("#password").val()); - formData.append("password-repeat", $("#password-repeat").val()); + let formUsername = document.querySelector("#username").value; + let formEmail = document.querySelector("#email").value; + let formPassword = document.querySelector("#password").value; + let formPasswordRepeat = document.querySelector("#password-repeat").value; - $.ajax({ - url: '/auth/register', - type: 'post', - data: formData, - contentType: false, - processData: false, - success: function (response) { - if (response === "gwa gwa") { - addNotification('Registered successfully! Now please login to continue', 1); - showLogin(); - } else { - for (var i = 0; i < response.length; i++) { - addNotification(response[i], 2); - } - } - }, - error: function (response) { - switch (response.status) { - case 500: - addNotification('Server exploded, F\'s in chat', 2); - break; - case 403: - addNotification('None but devils play past here...', 2); - break; - default: - addNotification('Error logging in, blame someone', 2); - break; + if (formUsername === "" || formEmail === "" || formPassword === "" || formPasswordRepeat === "") { + addNotification("Please fill in all fields!!!!", 3); + return; + } + + // Make form + var formData = new FormData(); + formData.append("username", formUsername); + formData.append("email", formEmail); + formData.append("password", formPassword); + formData.append("password-repeat", formPasswordRepeat); + + $.ajax({ + url: '/auth/register', + type: 'post', + data: formData, + contentType: false, + processData: false, + success: function (response) { + if (response === "gwa gwa") { + addNotification('Registered successfully! Now please login to continue', 1); + showLogin(); + } else { + for (var i = 0; i < response.length; i++) { + addNotification(response[i], 2); } } - }); - } + }, + error: function (response) { + switch (response.status) { + case 500: + addNotification('Server exploded, F\'s in chat', 2); + break; + case 403: + addNotification('None but devils play past here...', 2); + break; + default: + addNotification('Error logging in, blame someone', 2); + break; + } + } + }); } \ No newline at end of file diff --git a/gallery/static/js/main.js b/gallery/static/js/main.js index 1844d14..4458cdd 100644 --- a/gallery/static/js/main.js +++ b/gallery/static/js/main.js @@ -1,168 +1,92 @@ -let navToggle = true; - -document.onscroll = function() { - try { - document.querySelector('.background-decoration').style.opacity = `${1 - window.scrollY / 621}`; - document.querySelector('.background-decoration').style.top = `-${window.scrollY / 5}px`; - } catch (e) { - console.log('No background decoration found'); - } - - try { - if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) { - document.querySelector('.banner').classList = 'banner banner-scrolled'; - } else { - document.querySelector('.banner').classList = 'banner'; - } - } - catch (e) { - console.log('No banner found'); - } - - if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) { - document.querySelector('.jumpUp').classList = 'jumpUp jumpUp--show'; +// fade in images +function imgFade(obj, time = 250) { + $(obj).animate({ opacity: 1 }, time); +} +// https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color +function colourContrast(bgColor, lightColor, darkColor, threshold = 0.179) { + // if color is in hex format then convert to rgb else parese rgb + if (bgColor.charAt(0) === '#') { + var color = (bgColor.charAt(0) === '#') ? bgColor.substring(1, 7) : bgColor; + var r = parseInt(color.substring(0, 2), 16); // hexToR + var g = parseInt(color.substring(2, 4), 16); // hexToG + var b = parseInt(color.substring(4, 6), 16); // hexToB } else { - document.querySelector('.jumpUp').classList = 'jumpUp'; + var color = bgColor.replace('rgb(', '').replace(')', '').split(','); + var r = color[0]; + var g = color[1]; + var b = color[2]; } -} - -document.querySelector('.jumpUp').onclick = function() { - document.body.scrollTop = 0; - document.documentElement.scrollTop = 0; -} - -function imgFade(obj) { - $(obj).animate({opacity: 1}, 250); -} - -var times = document.getElementsByClassName('time'); -for (var i = 0; i < times.length; i++) { - var time = times[i].innerHTML; - var date = new Date(time); - times[i].innerHTML = date.toLocaleString('en-GB'); -} - -function addNotification(text='Sample notification', type=4) { - var container = document.querySelector('.notifications'); - - // Create notification element - var div = document.createElement('div'); - div.classList.add('sniffle__notification'); - div.onclick = function() { - if (div.parentNode) { - div.classList.add('sniffle__notification--hide'); - - setTimeout(function() { - container.removeChild(div); - }, 500); + + var uicolors = [r / 255, g / 255, b / 255]; + var c = uicolors.map((col) => { + if (col <= 0.03928) { + return col / 12.92; } - }; - - // Create icon element and append to notification - var icon = document.createElement('span'); - icon.classList.add('sniffle__notification-icon'); - switch (type) { - case 1: - div.classList.add('sniffle__notification--success'); - icon.innerHTML = '\ - \ - '; - break; - case 2: - div.classList.add('sniffle__notification--error'); - icon.innerHTML = '\ - \ - '; - break; - case 3: - div.classList.add('sniffle__notification--warning'); - icon.innerHTML = '\ - \ - '; - break; - default: - div.classList.add('sniffle__notification--info'); - icon.innerHTML = '\ - \ - '; - break; - } - div.appendChild(icon); - - // Create text element and append to notification - var description = document.createElement('span'); - description.classList.add('sniffle__notification-text'); - description.innerHTML = text; - div.appendChild(description); - - // Create span to show time remaining - var timer = document.createElement('span'); - timer.classList.add('sniffle__notification-time'); - div.appendChild(timer); - - // Append notification to container - container.appendChild(div); - setTimeout(function() { - div.classList.add('sniffle__notification-show'); - }, 100); - - // Remove notification after 5 seconds - setTimeout(function() { - if (div.parentNode) { - div.classList.add('sniffle__notification--hide'); - - setTimeout(function() { - container.removeChild(div); - }, 500); - } - }, 5000); + return Math.pow((col + 0.055) / 1.055, 2.4); + }); + var L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2]); + return (L > threshold) ? darkColor : lightColor; } +// Lazy load images when they are in view +function loadOnView() { + let lazyLoad = document.querySelectorAll('#lazy-load'); -function popUpShow(title, body, actions, content) { - var popup = document.querySelector('.pop-up'); - var popupContent = document.querySelector('.pop-up-content'); - var popupActions = document.querySelector('.pop-up-controlls'); - - // Set tile and description - h3 = document.createElement('h3'); - h3.innerHTML = title; - p = document.createElement('p'); - p.innerHTML = body; - - popupContent.innerHTML = ''; - popupContent.appendChild(h3); - popupContent.appendChild(p); - - // Set content - if (content != '') { - popupContent.innerHTML += content; - } - - // Set buttons that will be displayed - popupActions.innerHTML = ''; - if (actions != '') { - popupActions.innerHTML += actions; - } - popupActions.innerHTML += ''; - - // Show popup - popup.classList.add('pop-up__active'); -} - -function popupDissmiss() { - var popup = document.querySelector('.pop-up'); - - popup.classList.add('pop-up__hide'); - - setTimeout(function() { - popup.classList = 'pop-up'; - }, 200); -} - -document.addEventListener('keydown', function(event) { - if (event.key === 'Escape') { - if (document.querySelector('.pop-up').classList.contains('pop-up__active')) { - popupDissmiss(); + for (let i = 0; i < lazyLoad.length; i++) { + let image = lazyLoad[i]; + if (image.getBoundingClientRect().top < window.innerHeight && image.getBoundingClientRect().bottom > 0) { + if (!image.src) { + image.src = `/api/file/${image.getAttribute('data-src')}?r=thumb` + } } } -}); \ No newline at end of file +} + +window.onload = function () { + loadOnView(); + + const darkColor = '#151515'; + const lightColor = '#E8E3E3'; + let contrastCheck = document.querySelectorAll('#contrast-check'); + for (let i = 0; i < contrastCheck.length; i++) { + console.log(contrastCheck[i].getAttribute('data-color')); + bgColor = contrastCheck[i].getAttribute('data-color'); + contrastCheck[i].style.color = colourContrast(bgColor, lightColor, darkColor); + } + + let times = document.querySelectorAll('.time'); + for (let i = 0; i < times.length; i++) { + // Remove milliseconds + const raw = times[i].innerHTML.split('.')[0]; + + // Parse YYYY-MM-DD HH:MM:SS to Date object + const time = raw.split(' ')[1] + const date = raw.split(' ')[0].split('-'); + + // Format to YYYY/MM/DD HH:MM:SS + let formatted = date[0] + '/' + date[1] + '/' + date[2] + ' ' + time + ' UTC'; + + // Convert to UTC Date object + let dateTime = new Date(formatted); + + // Convert to local time + times[i].innerHTML = dateTime.toLocaleDateString() + ' ' + dateTime.toLocaleTimeString(); + } +}; +window.onscroll = function () { + loadOnView(); + + // Jump to top button + let topOfPage = document.querySelector('.top-of-page'); + if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) { + topOfPage.classList.add('show'); + } else { + topOfPage.classList.remove('show'); + } + topOfPage.onclick = function () { + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + } +}; +window.onresize = function () { + loadOnView(); +}; \ No newline at end of file diff --git a/gallery/static/js/notifications.js b/gallery/static/js/notifications.js new file mode 100644 index 0000000..9d46f2e --- /dev/null +++ b/gallery/static/js/notifications.js @@ -0,0 +1,75 @@ +function addNotification(text='Sample notification', type=4) { + var container = document.querySelector('.notifications'); + + // Create notification element + var div = document.createElement('div'); + div.classList.add('sniffle__notification'); + div.onclick = function() { + if (div.parentNode) { + div.classList.add('sniffle__notification--hide'); + + setTimeout(function() { + container.removeChild(div); + }, 500); + } + }; + + // Create icon element and append to notification + var icon = document.createElement('span'); + icon.classList.add('sniffle__notification-icon'); + switch (type) { + case 1: + div.classList.add('sniffle__notification--success'); + icon.innerHTML = '\ + \ + '; + break; + case 2: + div.classList.add('sniffle__notification--error'); + icon.innerHTML = '\ + \ + '; + break; + case 3: + div.classList.add('sniffle__notification--warning'); + icon.innerHTML = '\ + \ + '; + break; + default: + div.classList.add('sniffle__notification--info'); + icon.innerHTML = '\ + \ + '; + break; + } + div.appendChild(icon); + + // Create text element and append to notification + var description = document.createElement('span'); + description.classList.add('sniffle__notification-text'); + description.innerHTML = text; + div.appendChild(description); + + // Create span to show time remaining + var timer = document.createElement('span'); + timer.classList.add('sniffle__notification-time'); + div.appendChild(timer); + + // Append notification to container + container.appendChild(div); + setTimeout(function() { + div.classList.add('sniffle__notification-show'); + }, 100); + + // Remove notification after 5 seconds + setTimeout(function() { + if (div.parentNode) { + div.classList.add('sniffle__notification--hide'); + + setTimeout(function() { + container.removeChild(div); + }, 500); + } + }, 5000); +} \ No newline at end of file diff --git a/gallery/static/js/popup.js b/gallery/static/js/popup.js new file mode 100644 index 0000000..c564aa7 --- /dev/null +++ b/gallery/static/js/popup.js @@ -0,0 +1,42 @@ +function popUpShow(title, body, actions, content) { + // Stop scrolling + document.querySelector("html").style.overflow = "hidden"; + + // Get popup elements + var popup = document.querySelector('.pop-up'); + var popupContent = document.querySelector('.pop-up-content'); + var popupActions = document.querySelector('.pop-up-controlls'); + + // Set popup content + popupContent.innerHTML = `

${title}

${body}

${content}`; + + // Set buttons that will be displayed + popupActions.innerHTML = actions; + + // Show popup + popup.style.display = 'block'; + setTimeout(function() { + popup.classList.add('active') + }, 10); +} + +function popupDissmiss() { + // un-Stop scrolling + document.querySelector("html").style.overflow = "auto"; + + var popup = document.querySelector('.pop-up'); + + popup.classList.remove('active'); + + setTimeout(function() { + popup.style.display = 'none'; + }, 200); +} + +document.addEventListener('keydown', function(event) { + if (event.key === 'Escape') { + if (document.querySelector('.pop-up').classList.contains('active')) { + popupDissmiss(); + } + } +}); \ No newline at end of file diff --git a/gallery/static/js/upload.js b/gallery/static/js/upload.js deleted file mode 100644 index c1b9b50..0000000 --- a/gallery/static/js/upload.js +++ /dev/null @@ -1,69 +0,0 @@ -function showUpload() { - popUpShow( - 'Upload funny stuff', - 'May the world see your stuff 👀', - '', - '
\ - \ - \ - \ - \ -
' - ); -}; -function uploadFile(){ - // AJAX takes control of subby form - event.preventDefault(); - - // Check for empty upload - if ($("#file").val() === "") { - addNotification("Please select a file to upload", 2); - } else { - // Make form - var formData = new FormData(); - formData.append("file", $("#file").prop("files")[0]); - formData.append("alt", $("#alt").val()); - formData.append("description", $("#description").val()); - formData.append("tags", $("#tags").val()); - formData.append("submit", $("#submit").val()); - - // Upload the information - $.ajax({ - url: '/api/upload', - type: 'post', - data: formData, - contentType: false, - processData: false, - success: function (response) { - addNotification("File uploaded successfully!", 1); - // popupDissmiss(); // Close popup - }, - error: function (response) { - switch (response.status) { - case 500: - addNotification('Server exploded, F\'s in chat', 2); - break; - case 400: - case 404: - addNotification('Error uploading. Blame yourself', 2); - break; - case 403: - addNotification('None but devils play past here...', 2); - break; - case 413: - addNotification('File too large!!!!!!', 3); - break; - default: - addNotification('Error uploading file, blame someone', 2); - break; - } - } - }); - - // Empty values - $("#file").val(""); - $("#alt").val(""); - $("#description").val(""); - $("#tags").val(""); - } -}; \ No newline at end of file diff --git a/gallery/static/js/uploadTab.js b/gallery/static/js/uploadTab.js new file mode 100644 index 0000000..7963adc --- /dev/null +++ b/gallery/static/js/uploadTab.js @@ -0,0 +1,128 @@ +// Function to upload images +function uploadFile() { + // AJAX takes control of subby form + event.preventDefault(); + + const jobList = document.querySelector(".upload-jobs"); + + // Check for empty upload + if ($("#file").val() === "") { + addNotification("Please select a file to upload", 2); + } else { + // Make form + let formData = new FormData(); + formData.append("file", $("#file").prop("files")[0]); + formData.append("alt", $("#alt").val()); + formData.append("description", $("#description").val()); + formData.append("tags", $("#tags").val()); + formData.append("submit", $("#submit").val()); + + // Upload the information + $.ajax({ + url: '/api/upload', + type: 'post', + data: formData, + contentType: false, + processData: false, + beforeSend: function () { + jobContainer = document.createElement("div"); + jobContainer.classList.add("job"); + + jobStatus = document.createElement("span"); + jobStatus.classList.add("job__status"); + jobStatus.innerHTML = "Uploading..."; + + jobProgress = document.createElement("span"); + jobProgress.classList.add("progress"); + + jobImg = document.createElement("img"); + jobImg.src = URL.createObjectURL($("#file").prop("files")[0]); + + jobImgFilter = document.createElement("span"); + jobImgFilter.classList.add("img-filter"); + + jobContainer.appendChild(jobStatus); + jobContainer.appendChild(jobProgress); + jobContainer.appendChild(jobImg); + jobContainer.appendChild(jobImgFilter); + jobList.appendChild(jobContainer); + }, + success: function (response) { + jobContainer.classList.add("success"); + jobStatus.innerHTML = "Uploaded!"; + if (!document.querySelector(".upload-panel").classList.contains("open")) { + addNotification("Image uploaded successfully", 1); + } + }, + error: function (response) { + jobContainer.classList.add("critical"); + switch (response.status) { + case 500: + jobStatus.innerHTML = "Server exploded, F's in chat"; + break; + case 400: + case 404: + jobStatus.innerHTML = "Error uploading. Blame yourself"; + break; + case 403: + jobStatus.innerHTML = "None but devils play past here..."; + break; + case 413: + jobStatus.innerHTML = "File too large!!!!!!"; + break; + default: + jobStatus.innerHTML = "Error uploading file, blame someone"; + break; + } + if (!document.querySelector(".upload-panel").classList.contains("open")) { + addNotification("Error uploading file", 2); + } + }, + }); + + // Empty values + $("#file").val(""); + $("#alt").val(""); + $("#description").val(""); + $("#tags").val(""); + } +}; + +// open upload tab +function openUploadTab() { + // Stop scrolling + document.querySelector("html").style.overflow = "hidden"; + document.querySelector(".content").tabIndex = "-1"; + + // Open upload tab + const uploadTab = document.querySelector(".upload-panel"); + uploadTab.style.display = "block"; + + setTimeout(function () { + uploadTab.classList.add("open"); + }, 10); +} + +// close upload tab +function closeUploadTab() { + // un-Stop scrolling + document.querySelector("html").style.overflow = "auto"; + document.querySelector(".content").tabIndex = ""; + + // Close upload tab + const uploadTab = document.querySelector(".upload-panel"); + uploadTab.classList.remove("open"); + + setTimeout(function () { + uploadTab.style.display = "none"; + }, 250); +} + +// toggle upload tab +function toggleUploadTab() { + if (document.querySelector(".upload-panel").classList.contains("open")) { + closeUploadTab(); + } else { + openUploadTab(); + } +} diff --git a/gallery/templates/error.html b/gallery/templates/error.html index 0d63473..72b570e 100644 --- a/gallery/templates/error.html +++ b/gallery/templates/error.html @@ -1,7 +1,8 @@ {% extends 'layout.html' %} -{% block wrapper_class %}error-wrapper{% endblock %} {% block content %} -

{{error}}

-

{{msg}}

+ +

{{error}}

+

{{msg}}

+
{% endblock %} \ No newline at end of file diff --git a/gallery/templates/group.html b/gallery/templates/group.html deleted file mode 100644 index c18f4df..0000000 --- a/gallery/templates/group.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'layout.html' %} - -{% block header %} -
- - -
-{% endblock %} - -{% block nav_groups %}navigation-item__selected{% endblock %} - -{% block content %} -

Image Group

-

{{group_id}}

-{% endblock %} \ No newline at end of file diff --git a/gallery/templates/groups/group.html b/gallery/templates/groups/group.html new file mode 100644 index 0000000..c34ed53 --- /dev/null +++ b/gallery/templates/groups/group.html @@ -0,0 +1,91 @@ +{% extends 'layout.html' %} + +{% block nav_groups %}navigation-item__selected{% endblock %} + +{% block content %} + + +
+ + + + +
+ + {% if images %} + + {% else %} +
+

No image!

+ {% if g.user %} +

You can get started by uploading an image!

+ {% else %} +

Login to start uploading images!

+ {% endif %} +
+ {% endif %} +{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/gallery/templates/groups/list.html b/gallery/templates/groups/list.html new file mode 100644 index 0000000..ebb059b --- /dev/null +++ b/gallery/templates/groups/list.html @@ -0,0 +1,59 @@ +{% extends 'layout.html' %} + +{% block nav_groups %}navigation-item__selected{% endblock %} + +{% block content %} + + +
+ + + +
+ + {% if groups %} + + {% else %} +
+

No image groups!

+ {% if g.user %} +

You can get started by creating a new image group!

+ {% else %} +

Login to get started!

+ {% endif %} +
+ {% endif %} +{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/gallery/templates/image.html b/gallery/templates/image.html index ddee033..7cf1a27 100644 --- a/gallery/templates/image.html +++ b/gallery/templates/image.html @@ -1,238 +1,253 @@ {% extends 'layout.html' %} -{% block header %} -
- - -
+{% block head %} + + {% endblock %} + {% block wrapper_class %}image-wrapper{% endblock %} {% block content %} +
+ {{ image.post_alt }} + +
+
- + {{ image.post_alt }}
-
- -
- -
-
- - - - - - - Download - -
- {% if g.user['id'] == image['author_id'] %} -
- - -
- {% endif %} - -
- -
- {% if image['alt'] != '' %} -
- - - - - -
- - - -

Alt

-
-
-

{{ image['alt'] }}

-
-
- {% endif %} - {% if image['description'] != '' %} -
- - - - - -
- - - -

Description

-
-
-

{{ image['description'] }}

-
-
- {% endif %} -
- - - - - -
- - - -

Info

-
-
- - - - - - - - - - - - - -
Image ID{{ image['id'] }}
Author{{ image['author_id'] }}
Upload date{{ image['created_at'] }}
-
-
- {% for tag in exif %} -
- - - - - - {% if tag == 'Photographer' %} -
- - - -

Photographer

-
- {% elif tag == 'Camera' %} -
- - - -

Camera

-
- {% elif tag == 'Software' %} -
- - - -

Software

-
- {% elif tag == 'File' %} -
- - - -

File

-
- {% else %} -
- - - -

{{tag}}

-
+
+
+ {{ image.post_alt }} - - {% for subtag in exif[tag] %} - - - {% if exif[tag][subtag]['formatted'] %} - {% if exif[tag][subtag]['type'] == 'date' %} - - {% else %} - - {% endif %} - {% elif exif[tag][subtag]['raw'] %} - - {% else %} - - {% endif %} - - {% endfor %} -
{{subtag}}{{exif[tag][subtag]['formatted']}}{{exif[tag][subtag]['formatted']}}{{exif[tag][subtag]['raw']}}Oops, an error
+ /> +
+ +
+ {% if next_url %} + + {% endif %} +
+ + + + + + Download + + +
- {% endfor %} + {% if image.author_id == g.user.id %} +
+ + +
+ {% endif %} + {% if prev_url %} + + {% endif %} +
+ +
+ {% if image.post_description %} +
+
+ +

Description

+ + + +
+
+

{{ image.post_description }}

+
+
+ {% endif %} +
+
+ +

Info

+ + + +
+
+
+ {% for col in image.image_colours %} + + {% endfor %} +
+ + + + + + + + + + + + + +
Image ID{{ image['id'] }}
Author{{ image.author_username }}
Upload date{{ image.created_at }}
+ {% if group and image.author_id == g.user.id %} +
+ {% for group in image.groups %} + + + {{ group['name'] }} + + {% endfor %} + + +
+ {% elif image.author_id == g.user.id %} +
+ +
+ {% endif %} +
+
+ {% for tag in image.image_exif %} +
+
+ {% if tag == 'Photographer' %} + +

Photographer

+ {% elif tag == 'Camera' %} + +

Camera

+ {% elif tag == 'Software' %} + +

Software

+ {% elif tag == 'File' %} + +

File

+ {% else %} + +

{{ tag }}

+ {% endif %} + + + +
+
+ + {% for subtag in image.image_exif[tag] %} + + + {% if image.image_exif[tag][subtag]['formatted'] %} + {% if image.image_exif[tag][subtag]['type'] == 'date' %} + + {% else %} + + {% endif %} + {% elif image.image_exif[tag][subtag]['raw'] %} + + {% else %} + + {% endif %} + + {% endfor %} +
{{ subtag }}{{ image.image_exif[tag][subtag]['formatted'] }}{{ image.image_exif[tag][subtag]['formatted'] }}{{ image.image_exif[tag][subtag]['raw'] }}Oops, an error
+
+
+ {% endfor %} +
{% endblock %} {% block script %} {% endblock %} \ No newline at end of file diff --git a/gallery/templates/index.html b/gallery/templates/index.html index 89bd7aa..3da78d4 100644 --- a/gallery/templates/index.html +++ b/gallery/templates/index.html @@ -1,73 +1,59 @@ {% extends 'layout.html' %} -{% block header %} - -{% endblock %} {% block nav_home %}navigation-item__selected{% endblock %} -{% block wrapper_class %}index-wrapper{% endblock %} {% block content %} -