############################################################################### # app.py - Full pyrafiles Application with Unicode-safe Content-Disposition ############################################################################### import os import base64 import datetime import random import string import bcrypt import re import uuid import hashlib import smtplib import mimetypes import json import logging import unicodedata from email.mime.text import MIMEText from pyramid.config import Configurator from pyramid.view import view_config from pyramid.response import Response from pyramid.httpexceptions import HTTPFound, HTTPForbidden from pyramid.session import SignedCookieSessionFactory from sqlalchemy import ( create_engine, Column, String, DateTime, Boolean, Integer, Index, ) from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import scoped_session from sqlalchemy.pool import StaticPool from waitress import serve from pyramid.renderers import render_to_response from pyramid.events import subscriber from pyramid_jinja2 import IJinja2Environment import transaction # Import transaction management from zope.sqlalchemy import register # Import zope.sqlalchemy ################################################################################ # Set up logging ################################################################################ log = logging.getLogger(__name__) ################################################################################ # Environment Variables and Defaults ################################################################################ # If PYRAFILES_SECRET is missing, generate a random 64-character secret. pyrafiles_secret = os.environ.get("PYRAFILES_SECRET", "") if not pyrafiles_secret: pyrafiles_secret = "".join( random.choices(string.ascii_letters + string.digits, k=64) ) log.info(f"Generated random PYRAFILES_SECRET: {pyrafiles_secret}") # Database URL can be overridden by environment variable APP_DIR = os.path.dirname(os.path.abspath(__file__)) default_main_db_url = f"sqlite:///{os.path.join(APP_DIR, 'main.db')}" DB_URL = os.environ.get("PYRAFILES_DB_URL", default_main_db_url) # Host and port for the application HOST = os.environ.get("PYRAFILES_HOST", "0.0.0.0") PORT = int(os.environ.get("PYRAFILES_PORT", "6544")) # SMTP host/port smtp_host = os.environ.get("PYRAFILES_SMTP_HOST", "localhost") smtp_port = int(os.environ.get("PYRAFILES_SMTP_PORT", "25")) ################################################################################ # Helper Functions ################################################################################ def slugify(text): text = text.lower() text = re.sub(r"\s+", "-", text) text = re.sub(r"[^\w\-]", "", text) return text def get_gravatar_url(email, size=100): email = email.strip().lower() hash_code = hashlib.md5(email.encode("utf-8")).hexdigest() return f"https://www.gravatar.com/avatar/{hash_code}?s={size}&d=identicon" def send_email(to_email, subject, body): log.debug("======= Email Sent =======") log.debug(f"To: {to_email}") log.debug(f"Subject: {subject}") log.debug(f"Body:\n{body}") log.debug("==========================") msg = MIMEText(body) msg["Subject"] = subject msg["From"] = "noreply@example.com" msg["To"] = to_email try: s = smtplib.SMTP(smtp_host, smtp_port) s.sendmail("noreply@example.com", [to_email], msg.as_string()) s.quit() except Exception as e: log.error(f"Error sending email: {e}") def admin_required(view_func): def wrapper(request): user = request.user if not user or not user.is_admin: return HTTPForbidden("You must be an admin to access this page.") return view_func(request) return wrapper def get_current_user(request): """Return the current user (authenticated or guest) from session.""" user_id = request.session.get("user_id") s = request.dbsession if user_id: # Try to get the user from the database user = s.query(User).filter_by(id=user_id).first() if user: return user else: # User ID in session does not exist in the database; remove it del request.session["user_id"] # No valid user in session; create a guest user # Generate a unique guest username while True: suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8)) guest_username = f"Guest-{suffix}" existing_user = s.query(User).filter_by(username=guest_username).first() if not existing_user: break # Unique username found # Generate UUID and short ID user_uuid = uuid.uuid4() new_user_id = str(user_uuid) short_id = uuid_to_short_id(user_uuid) # Create a new guest user guest_user = User( id=new_user_id, short_id=short_id, email=None, # Guests don't have an email username=guest_username, is_verified=False, ) s.add(guest_user) s.flush() # Use flush instead of commit in pyramid_tm # Store the user ID in the session request.session["user_id"] = guest_user.id return guest_user def get_mime_type(filename): # Guess the MIME type based on the file extension mime_type, _ = mimetypes.guess_type(filename) if not mime_type: mime_type = "application/octet-stream" return mime_type def uuid_to_short_id(u): """Encode UUID to a URL-safe base64 string without padding.""" return base64.urlsafe_b64encode(u.bytes).decode("ascii").rstrip("=") def short_id_to_uuid(sid): """Decode the short ID back to UUID, trying different padding lengths.""" for padding_length in range(6): try: padded = sid + ("=" * padding_length) bytes_data = base64.urlsafe_b64decode(padded) if len(bytes_data) == 16: # UUID is 16 bytes return uuid.UUID(bytes=bytes_data) except Exception: continue log.error( f"Failed to convert short_id {sid} to UUID after trying all padding lengths" ) return None def get_user_db_url(user_id): """Return the database URL for the user's SQLite database, in the same directory.""" db_file = os.path.join(APP_DIR, f"user_{user_id}.db") return f"sqlite:///{db_file}" def filesizeformat(value): """Returns the human-readable file size.""" for unit in ["bytes", "KB", "MB", "GB", "TB"]: if value < 1024.0: return f"{value:.2f} {unit}" value /= 1024.0 return f"{value:.2f} PB" def sanitize_filename_for_http_header(filename): """ Ensure that the filename is safe for Waitress (Latin-1 headers). Converts to ASCII, replacing or removing characters that won't encode. """ normalized = unicodedata.normalize("NFKD", filename) ascii_bytes = normalized.encode("ascii", "ignore") # drop non-ASCII safe = ascii_bytes.decode("ascii") # Replace any remaining bad chars with underscores # e.g. keep alphanumerics, dots, underscores, hyphens, etc. safe = re.sub(r"[^A-Za-z0-9._-]+", "_", safe) return safe or "download" ################################################################################ # Database Setup ################################################################################ log.debug(f"Using database URL: {DB_URL}") # For debugging Base = declarative_base() class User(Base): __tablename__ = "users" id = Column(String, primary_key=True) # UUID short_id = Column(String, unique=True, nullable=False) email = Column(String, unique=True, nullable=True) # Guests don't have an email. username = Column(String, unique=True, nullable=False) # user-chosen handle code_hash = Column(String, nullable=True) # bcrypt hash of code code_expires = Column(DateTime, nullable=True) # time limit for code is_verified = Column(Boolean, default=False) enable_gravatar = Column(Boolean, default=False) # Gravatar support is_admin = Column(Boolean, default=False) # Admin flag __table_args__ = ( Index("ix_users_id", "id"), Index("ix_users_short_id", "short_id"), ) def __repr__(self): return f"" class Media(Base): __tablename__ = "media" id = Column(String, primary_key=True) # UUID short_id = Column(String, unique=True, nullable=False) user_id = Column(String, nullable=False) # Owner's UUID filename = Column(String, nullable=False) title = Column(String, nullable=True) # Optional title media_type = Column(String, nullable=False) # 'image', 'audio', 'video' media_b64 = Column(String, nullable=False) upload_date = Column(DateTime, default=datetime.datetime.utcnow) is_public = Column(Boolean, default=True) size = Column(Integer, nullable=False) # Size in bytes __table_args__ = ( Index("ix_media_id", "id"), Index("ix_media_short_id", "short_id"), ) ################################################################################ # Jinja2 Environment and Custom Filters ################################################################################ @subscriber(IJinja2Environment) def add_jinja2_filters(event): env = event.environment env.filters["filesizeformat"] = filesizeformat ################################################################################ # Request Methods ################################################################################ def add_user_dbsession(request): """Adds user_dbsession to request for verified users.""" if request.user and request.user.is_verified: user_dbsession = get_user_dbsession_by_user_id(request.user.id, request) return user_dbsession else: return None # Guests do not have user_dbsession def get_user_dbsession_by_user_id(user_id, request): """Helper function to get a user_dbsession for a given user_id.""" user_db_url = get_user_db_url(user_id) db_file = os.path.join(APP_DIR, f"user_{user_id}.db") if not os.path.exists(db_file): return None # User database does not exist user_engine = create_engine( user_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool ) UserSessionFactory = sessionmaker(bind=user_engine) user_dbsession = scoped_session(UserSessionFactory) register(user_dbsession) # Register with zope.sqlalchemy # Attach cleanup callbacks def cleanup(_request): user_dbsession.remove() user_engine.dispose() request.add_finished_callback(cleanup) return user_dbsession ################################################################################ # Routes ################################################################################ @view_config(route_name="home", renderer="home.html.j2") def home_view(request): return { "request": request, } ################################################################################ # Authentication Views ################################################################################ @view_config(route_name="login", request_method="GET", renderer="login.html.j2") def login_get_view(request): return {"request": request} @view_config(route_name="login", request_method="POST") def login_post_view(request): email = request.POST.get("email", "").strip().lower() if not email: return Response("Email required.", status=400) session = request.dbsession user = session.query(User).filter_by(email=email).first() if not user: # Generate UUID and short ID user_uuid = uuid.uuid4() user_id = str(user_uuid) short_id = uuid_to_short_id(user_uuid) # Create new user user = User( id=user_id, short_id=short_id, email=email, username=email.split("@")[0], is_verified=False, ) session.add(user) session.flush() # Generate 6-digit code code_str = f"{random.randint(0,999999):06d}" code_hash = bcrypt.hashpw(code_str.encode("utf-8"), bcrypt.gensalt()).decode( "utf-8" ) user.code_hash = code_hash user.code_expires = datetime.datetime.now() + datetime.timedelta(minutes=15) user.is_verified = False session.flush() # Send code via email email_body = f"Your verification code is: {code_str}" send_email(user.email, "Your Verification Code", email_body) # Store the email in the session for verification request.session["login_email"] = email return HTTPFound(location=request.route_url("verify")) @view_config(route_name="verify", request_method="GET", renderer="verify.html.j2") def verify_get_view(request): return {"request": request} @view_config(route_name="verify", request_method="POST") def verify_post_view(request): code_entered = request.POST.get("code", "").strip() if not code_entered or len(code_entered) != 6: return Response("Invalid code.", status=400) email = request.session.get("login_email") if not email: return Response( "No email found in session. Please start the login process again.", status=400, ) s = request.dbsession user = ( s.query(User) .filter( User.email == email, User.code_expires > datetime.datetime.now(), User.code_hash != None, ) .first() ) if not user: return Response("Code not found or expired.", status=400) if not bcrypt.checkpw(code_entered.encode("utf-8"), user.code_hash.encode("utf-8")): return Response("Invalid code.", status=400) user.is_verified = True user.code_hash = None user.code_expires = None s.flush() # Remove the email from the session del request.session["login_email"] request.session["user_id"] = user.id # Create user database upon verification user_db_url = get_user_db_url(user.id) user_engine = create_engine( user_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool ) Base.metadata.create_all(user_engine) # Create tables in user's database user_engine.dispose() return HTTPFound(location=request.route_url("home")) @view_config(route_name="logout") def logout_view(request): request.session.invalidate() return HTTPFound(location=request.route_url("home")) ################################################################################ # Profile and Media Upload ################################################################################ @view_config(route_name="profile", request_method="GET", renderer="profile.html.j2") def profile_get_view(request): user = request.user if not user: return Response("You must be logged in to access your profile.", status=403) gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else "" # Initialize stats total_uploads = 0 total_size = 0 user_dbsession = request.user_dbsession if user_dbsession: media_items = user_dbsession.query(Media).all() total_uploads = len(media_items) total_size = sum(media.size for media in media_items) return { "request": request, "user": user, "gravatar_url": gravatar_url, "total_uploads": total_uploads, "total_size": total_size, } @view_config(route_name="profile", request_method="POST") def profile_post_view(request): if not request.user: return Response("You must be logged in to update your profile.", status=403) if not request.user.is_verified: return Response( "This account is in guest mode, log in to update your profile.", status=403 ) s = request.dbsession enable_gravatar = request.POST.get("enable_gravatar") == "on" request.user.enable_gravatar = enable_gravatar # Handle username update if provided new_username = request.POST.get("new_username", "").strip() if new_username: # Check if the new username is already taken existing = s.query(User).filter(User.username == new_username).first() if existing and existing.id != request.user.id: return Response("Username is already in use.", status=400) request.user.username = new_username s.flush() return HTTPFound(location=request.route_url("profile")) @view_config(route_name="download_database") def download_database_view(request): user = request.user if not user or not user.is_verified: return Response("You must be logged in to download your database.", status=403) db_file = os.path.join(APP_DIR, f"user_{user.id}.db") if not os.path.exists(db_file): return Response("Database file not found.", status=404) with open(db_file, "rb") as f: data = f.read() # Safely build a filename download_filename = sanitize_filename_for_http_header(f"user_{user.id}.db") response = Response(body=data, content_type="application/octet-stream") response.headers[ "Content-Disposition" ] = f'attachment; filename="{download_filename}"' return response ################################################################################ # User Record Export (Users) and Import (Admins) ################################################################################ @view_config(route_name="export_user_record") def export_user_record_view(request): user = request.user if not user or not user.is_verified: return Response("You must be logged in to export your user record.", status=403) # Create a JSON representation of the user record user_record = { "id": user.id, "short_id": user.short_id, "email": user.email, "username": user.username, "enable_gravatar": user.enable_gravatar, "is_admin": user.is_admin, } # Convert to JSON string user_json = json.dumps(user_record).encode("utf-8") response = Response(body=user_json, content_type="application/json") # Plain ASCII filename is safe response.headers["Content-Disposition"] = 'attachment; filename="user_record.json"' return response @view_config( route_name="import_user_record", request_method="GET", renderer="import_user_record.html.j2", ) @admin_required def import_user_record_get_view(request): return {"request": request} @view_config(route_name="import_user_record", request_method="POST") @admin_required def import_user_record_post_view(request): user_record_file = request.POST.get("user_record_file") if ( user_record_file is None or not getattr(user_record_file, "filename", "").strip() ): return Response("No user record file uploaded.", status=400) # Read and parse the JSON data try: user_record_data = user_record_file.file.read() user_record = json.loads(user_record_data) except Exception as e: return Response(f"Invalid user record file: {e}", status=400) s = request.dbsession # Check if user already exists existing_user = s.query(User).filter_by(id=user_record["id"]).first() if existing_user: return Response("User already exists in the database.", status=400) # Create new user with provided data user = User( id=user_record["id"], short_id=user_record["short_id"], email=user_record["email"], username=user_record["username"], enable_gravatar=user_record.get("enable_gravatar", False), is_admin=user_record.get("is_admin", False), is_verified=True, # Assume verified ) s.add(user) s.flush() # Do not create user database upon import request.session.flash(f"User {user.username} imported successfully.") return HTTPFound(location=request.route_url("home")) ################################################################################ # Media Upload, Listing, and Management ################################################################################ @view_config( route_name="upload_media", request_method="GET", renderer="upload_media.html.j2" ) def upload_media_get_view(request): user = request.user if not user or not user.is_verified: return Response("You must be logged in to upload media.", status=403) return {"request": request} @view_config(route_name="upload_media", request_method="POST") def upload_media_post_view(request): user = request.user if not user or not user.is_verified: return Response("You must be logged in to upload media.", status=403) user_dbsession = request.user_dbsession # Assume this exists for verified users media_file = request.POST.get("media_file") if media_file is None or not getattr(media_file, "filename", "").strip(): return Response("No file uploaded.", status=400) raw_bytes = media_file.file.read() max_size = 30 * 1024 * 1024 # 30 MB if len(raw_bytes) > max_size: return Response("File size exceeds the 30MB limit.", status=400) file_size = len(raw_bytes) # Determine media type based on MIME type filename = media_file.filename mime_type = get_mime_type(filename) if not mime_type.startswith(("image/", "audio/", "video/")): return Response("Unsupported media type.", status=400) media_type = mime_type.split("/")[0] # Get title from form title = request.POST.get("title", "").strip() # If user did not enter a title, default to the original filename if not title: title = filename # Encode content to base64 encoded_str = base64.b64encode(raw_bytes).decode("utf-8") # Generate UUID and short ID for the media media_uuid = uuid.uuid4() media_id = str(media_uuid) media_short_id = uuid_to_short_id(media_uuid) is_public = request.POST.get("is_public") == "on" media = Media( id=media_id, short_id=media_short_id, user_id=user.id, filename=filename, title=title, # now has a fallback of filename media_type=media_type, media_b64=encoded_str, is_public=is_public, size=file_size, ) user_dbsession.add(media) user_dbsession.flush() return HTTPFound( location=request.route_url( "view_media_details", user_short_id=user.short_id, media_short_id=media.short_id, ) ) @view_config(route_name="list_media", renderer="list_media.html.j2") def list_media_view(request): # Aggregate public media from all verified users s = request.dbsession users = s.query(User).filter(User.is_verified == True).all() media_list = [] for user in users: user_dbsession = get_user_dbsession_by_user_id(user.id, request) if not user_dbsession: continue media_items = user_dbsession.query(Media).filter(Media.is_public == True).all() for m in media_items: media_list.append( { "media": m, "username": user.username, "user_short_id": user.short_id, } ) # Sort media by upload date (recent first) media_list.sort(key=lambda x: x["media"].upload_date, reverse=True) return { "request": request, "media_list": media_list, } @view_config(route_name="user_media", renderer="user_media.html.j2") def user_media_view(request): user_short_id = request.matchdict.get("user_short_id") log.debug(f"Looking up user with short_id: {user_short_id}") try: user_uuid = short_id_to_uuid(user_short_id) if not user_uuid: log.error(f"Could not convert short_id {user_short_id} to UUID") return Response("User not found.", status=404) s = request.dbsession user = s.query(User).filter_by(id=str(user_uuid)).first() log.debug(f"User found: {user}") if not user: return Response("User not found.", status=404) viewer = request.user is_owner = viewer and viewer.id == user.id user_dbsession = get_user_dbsession_by_user_id(user.id, request) if not user_dbsession: return Response("User has no uploads.", status=404) if is_owner: media_items = user_dbsession.query(Media).all() else: media_items = ( user_dbsession.query(Media).filter(Media.is_public == True).all() ) media_items.sort(key=lambda m: m.upload_date, reverse=True) return { "request": request, "media_items": media_items, "user": user, "is_owner": is_owner, } except Exception as e: log.exception(f"Error processing user_short_id {user_short_id}: {e}") return Response("Error processing request.", status=500) @view_config(route_name="view_media_details", renderer="view_media_details.html.j2") def view_media_details_view(request): media_short_id = request.matchdict.get("media_short_id") user_short_id = request.matchdict.get("user_short_id") if not user_short_id or not media_short_id: return Response("Invalid request.", status=400) s = request.dbsession user = s.query(User).filter_by(short_id=user_short_id).first() if not user: return Response("User not found.", status=404) user_id = user.id user_dbsession = get_user_dbsession_by_user_id(user_id, request) if not user_dbsession: return Response("User database not found.", status=404) media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) viewer = request.user is_owner = viewer and viewer.id == user_id if not media.is_public and not is_owner: return Response("Media not available.", status=403) return { "request": request, "media": media, "username": user.username, "user_short_id": user.short_id, "is_owner": is_owner, } @view_config(route_name="delete_media", request_method="POST") def delete_media_view(request): media_short_id = request.matchdict.get("media_short_id") user_short_id = request.matchdict.get("user_short_id") viewer = request.user if not viewer or not viewer.is_verified: return Response("You must be logged in.", status=403) if viewer.short_id != user_short_id: return Response("You are not authorized to delete this media.", status=403) user_dbsession = request.user_dbsession media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) user_dbsession.delete(media) user_dbsession.flush() return HTTPFound( location=request.route_url("user_media", user_short_id=viewer.short_id) ) @view_config( route_name="edit_media", request_method="GET", renderer="edit_media.html.j2" ) def edit_media_get_view(request): media_short_id = request.matchdict.get("media_short_id") user_short_id = request.matchdict.get("user_short_id") viewer = request.user if not viewer or not viewer.is_verified: return Response("You must be logged in.", status=403) if viewer.short_id != user_short_id: return Response("You are not authorized to edit this media.", status=403) user_dbsession = request.user_dbsession media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) return { "request": request, "media": media, } @view_config(route_name="edit_media", request_method="POST") def edit_media_post_view(request): media_short_id = request.matchdict.get("media_short_id") user_short_id = request.matchdict.get("user_short_id") viewer = request.user if not viewer or not viewer.is_verified: return Response("You must be logged in.", status=403) if viewer.short_id != user_short_id: return Response("You are not authorized to edit this media.", status=403) user_dbsession = request.user_dbsession media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) # Handle title update new_title = request.POST.get("title", "").strip() media.title = new_title # Handle media file update new_media_file = request.POST.get("media_file") if new_media_file and getattr(new_media_file, "filename", "").strip(): raw_bytes = new_media_file.file.read() max_size = 30 * 1024 * 1024 if len(raw_bytes) > max_size: return Response("File size exceeds the 30MB limit.", status=400) file_size = len(raw_bytes) filename = new_media_file.filename mime_type = get_mime_type(filename) if not mime_type.startswith(("image/", "audio/", "video/")): return Response("Unsupported media type.", status=400) media_type = mime_type.split("/")[0] encoded_str = base64.b64encode(raw_bytes).decode("utf-8") media.filename = filename media.media_type = media_type media.media_b64 = encoded_str media.size = file_size # Handle public/private update is_public = request.POST.get("is_public") == "on" media.is_public = is_public user_dbsession.flush() return HTTPFound( location=request.route_url( "view_media_details", user_short_id=viewer.short_id, media_short_id=media.short_id, ) ) ################################################################################ # Media Viewing and Downloading ################################################################################ @view_config(route_name="view_media") def view_media_view(request): media_short_id = request.matchdict.get("media_short_id") user_short_id = request.matchdict.get("user_short_id") if not user_short_id or not media_short_id: return Response("Invalid request.", status=400) s = request.dbsession user = s.query(User).filter_by(short_id=user_short_id).first() if not user: return Response("User not found.", status=404) user_id = user.id user_dbsession = get_user_dbsession_by_user_id(user_id, request) if not user_dbsession: return Response("User database not found.", status=404) media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) viewer = request.user is_owner = viewer and viewer.id == user_id if not media.is_public and not is_owner: return Response("Media not available.", status=403) media_data = base64.b64decode(media.media_b64) mime_type = get_mime_type(media.filename) # Build a safe filename (avoid Unicode issues in the header) if media.title: file_extension = os.path.splitext(media.filename)[1] raw_title = media.title download_filename = sanitize_filename_for_http_header( f"{raw_title}{file_extension}" ) else: download_filename = sanitize_filename_for_http_header(media.filename) # Check if user wants attachment or inline download = request.GET.get("download", "false").lower() == "true" content_disposition = "attachment" if download else "inline" response = Response(body=media_data, content_type=mime_type) response.headers.update( { "Access-Control-Allow-Origin": "*", "Content-Disposition": f'{content_disposition}; filename="{download_filename}"', } ) return response ################################################################################ # Main ################################################################################ def main(global_config=None, **settings): # Configure logging logging.basicConfig(level=logging.DEBUG) # Set up the session factory session_factory = SignedCookieSessionFactory( secret=pyrafiles_secret, hashalg="sha512", timeout=31104000, # Approx. one year in seconds max_age=31104000, # Set Max-Age attribute on cookie reissue_time=15552000, # Approx. six months samesite=None, # Allows cross-site requests if needed httponly=True, # Helps mitigate XSS attacks secure=False, # Set to True if using HTTPS ) if not settings: settings = {} settings["sqlalchemy.url"] = DB_URL config = Configurator(settings=settings, session_factory=session_factory) config.include("pyramid_jinja2") config.include("pyramid_tm") # Include pyramid_tm for transaction management # Add .html.j2 extension for Jinja2 templates config.add_jinja2_renderer(".j2") config.add_jinja2_search_path("templates", name=".j2") # The Jinja2 filters are added via the event subscriber above # Set up SQLAlchemy engine = create_engine( DB_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool ) session_factory_ = sessionmaker(bind=engine) Base.metadata.bind = engine DBSession = scoped_session(session_factory_) register(DBSession) # Register with zope.sqlalchemy # Add user to all requests config.add_request_method(callable=get_current_user, name="user", reify=True) # Provide dbsession to requests def dbsession(request): return DBSession config.add_request_method(dbsession, "dbsession", reify=True) # Add user_dbsession to requests for verified users config.add_request_method(add_user_dbsession, "user_dbsession", reify=True) # Routes config.add_route("home", "/") # Auth config.add_route("login", "/auth/login") config.add_route("verify", "/auth/verify") config.add_route("logout", "/auth/logout") config.add_route("profile", "/auth/profile") # Export and Import User Record (Admin only for import) config.add_route("export_user_record", "/auth/export_user_record") config.add_route("import_user_record", "/admin/import_user_record") # Media config.add_route("upload_media", "/media/upload") config.add_route("list_media", "/media/list") config.add_route("user_media", "/media/user/{user_short_id}") config.add_route( "view_media_details", "/media/{user_short_id}/{media_short_id}/details" ) config.add_route("edit_media", "/media/{user_short_id}/{media_short_id}/edit") config.add_route("delete_media", "/media/{user_short_id}/{media_short_id}/delete") config.add_route("view_media", "/media/{user_short_id}/{media_short_id}") # Database Download config.add_route("download_database", "/auth/download_db") config.scan() return config.make_wsgi_app() if __name__ == "__main__": app = main() log.info(f"Serving on http://{HOST}:{PORT}") serve(app, host=HOST, port=PORT)