1078 lines
35 KiB
Python
1078 lines
35 KiB
Python
###############################################################################
|
|
# 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"<User(username='{self.username}', email='{self.email}')>"
|
|
|
|
|
|
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")
|
|
|
|
# Add settings for pyramid_openapi3
|
|
config.add_settings(
|
|
{
|
|
"pyramid_openapi3.spec": os.path.join(APP_DIR, "openapi.yaml"),
|
|
"pyramid_openapi3.enable_request_validation": True,
|
|
"pyramid_openapi3.enable_response_validation": False,
|
|
"pyramid_openapi3.route": "/openapi.yaml",
|
|
"pyramid_openapi3.ui_route": "/docs/",
|
|
}
|
|
)
|
|
|
|
# Include pyramid_openapi3
|
|
config.include("pyramid_openapi3")
|
|
|
|
# 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)
|