1526 lines
47 KiB
Python
1526 lines
47 KiB
Python
###############################################################################
|
|
# app.py - PyraFiles Application with Proper Agent and User Separation
|
|
###############################################################################
|
|
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, HTTPNotFound
|
|
from pyramid.session import SignedCookieSessionFactory
|
|
from sqlalchemy import (
|
|
create_engine,
|
|
Column,
|
|
String,
|
|
DateTime,
|
|
Boolean,
|
|
Integer,
|
|
Index,
|
|
ForeignKey,
|
|
Text,
|
|
or_,
|
|
)
|
|
from sqlalchemy.orm import declarative_base
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.orm import scoped_session, relationship
|
|
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
|
|
|
|
import jwt # Import PyJWT library
|
|
from jwt import PyJWTError
|
|
|
|
################################################################################
|
|
# Set up logging
|
|
################################################################################
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
log = logging.getLogger(__name__)
|
|
|
|
################################################################################
|
|
# Environment Variables and Defaults
|
|
################################################################################
|
|
|
|
# Application directory
|
|
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# Data directory
|
|
DATA_DIR = os.path.join(APP_DIR, "data")
|
|
|
|
# Ensure DATA_DIR exists
|
|
if not os.path.exists(DATA_DIR):
|
|
os.makedirs(DATA_DIR)
|
|
|
|
# Paths to the secret files
|
|
APP_SECRET_FILE = os.path.join(DATA_DIR, "pyrafiles_secret.txt")
|
|
JWT_SECRET_FILE = os.path.join(DATA_DIR, "jwt_secret.txt")
|
|
|
|
|
|
def get_or_create_secret(env_var_name, secret_file_path):
|
|
"""
|
|
Retrieves the secret from an environment variable, or loads it from the
|
|
specified file. If neither is available, generates a new secret, saves
|
|
it to the file, and returns it.
|
|
"""
|
|
# Check environment variable
|
|
secret = os.environ.get(env_var_name, "")
|
|
if secret:
|
|
log.info(f"Using {env_var_name} from environment variable.")
|
|
return secret
|
|
|
|
# Check if the secret file exists
|
|
if os.path.exists(secret_file_path):
|
|
with open(secret_file_path, "r") as f:
|
|
secret = f.read().strip()
|
|
if secret:
|
|
log.info(f"Loaded {env_var_name} from {secret_file_path}")
|
|
return secret
|
|
else:
|
|
log.warning(f"{secret_file_path} is empty. Generating new secret.")
|
|
else:
|
|
log.info(f"{secret_file_path} does not exist. Generating new secret.")
|
|
|
|
# Generate a new secret
|
|
secret = "".join(random.choices(string.ascii_letters + string.digits, k=64))
|
|
# Save the secret to the file
|
|
with open(secret_file_path, "w") as f:
|
|
f.write(secret)
|
|
log.info(f"Generated and saved new {env_var_name} to {secret_file_path}")
|
|
|
|
return secret
|
|
|
|
|
|
# Retrieve or generate the secrets
|
|
app_secret = get_or_create_secret("PYRAFILES_SECRET", APP_SECRET_FILE)
|
|
JWT_SECRET = get_or_create_secret("PYRAFILES_JWT_SECRET", JWT_SECRET_FILE)
|
|
JWT_ALGORITHM = "HS256"
|
|
|
|
# Database URL can be overridden by environment variable
|
|
default_main_db_url = f"sqlite:///{os.path.join(DATA_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.info("======= Email Sent =======")
|
|
log.info(f"To: {to_email}")
|
|
log.info(f"Subject: {subject}")
|
|
log.info(f"Body:\n{body}")
|
|
log.info("==========================")
|
|
|
|
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 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 paddings")
|
|
return None
|
|
|
|
|
|
def get_namespace_db_url(namespace_id):
|
|
"""Return the database URL for the namespace's SQLite database."""
|
|
db_file = os.path.join(DATA_DIR, f"namespace_{namespace_id}.db")
|
|
return f"sqlite:///{db_file}"
|
|
|
|
|
|
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"
|
|
|
|
|
|
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 generate_jwt_token(agent):
|
|
"""Generate a JWT for the given agent without an expiration time."""
|
|
payload = {
|
|
"agent_id": agent.id,
|
|
"agent_name": agent.name,
|
|
"namespace_id": agent.namespace_id,
|
|
"role": agent.role,
|
|
"token_version": agent.token_version,
|
|
"iat": datetime.datetime.utcnow(),
|
|
}
|
|
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
return token
|
|
|
|
|
|
def verify_jwt_token(token):
|
|
"""Verify the JWT and return the payload if valid."""
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
JWT_SECRET,
|
|
algorithms=[JWT_ALGORITHM],
|
|
options={"verify_exp": False}, # Disable expiration verification
|
|
)
|
|
return payload
|
|
except PyJWTError:
|
|
return None
|
|
|
|
|
|
################################################################################
|
|
# Database Setup
|
|
################################################################################
|
|
|
|
log.debug(f"Using database URL: {DB_URL}") # For debugging
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
# Association class for Namespace <-> User (with roles)
|
|
class NamespaceUserAssociation(Base):
|
|
__tablename__ = "namespace_user_association"
|
|
namespace_id = Column(String, ForeignKey("namespaces.id"), primary_key=True)
|
|
user_id = Column(String, ForeignKey("users.id"), primary_key=True)
|
|
role = Column(String, nullable=False) # 'owner', 'editor', 'reader'
|
|
|
|
namespace = relationship("Namespace", back_populates="user_associations")
|
|
user = relationship("User", back_populates="namespace_associations")
|
|
|
|
|
|
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)
|
|
username = Column(String, unique=True, nullable=False)
|
|
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
|
|
|
|
# Namespaces the user is associated with
|
|
namespace_associations = relationship(
|
|
"NamespaceUserAssociation", back_populates="user"
|
|
)
|
|
namespaces = relationship(
|
|
"Namespace",
|
|
secondary="namespace_user_association",
|
|
back_populates="users",
|
|
)
|
|
|
|
# Attribute to hold dbsession in permission checks
|
|
dbsession = None
|
|
|
|
def __repr__(self):
|
|
return f"<User(username='{self.username}', email='{self.email}')>"
|
|
|
|
|
|
class Namespace(Base):
|
|
__tablename__ = "namespaces"
|
|
id = Column(String, primary_key=True) # UUID
|
|
short_id = Column(String, unique=True, nullable=False)
|
|
name = Column(String, unique=True, nullable=False)
|
|
is_public = Column(Boolean, default=False)
|
|
|
|
# Users associated with the namespace
|
|
user_associations = relationship(
|
|
"NamespaceUserAssociation", back_populates="namespace"
|
|
)
|
|
users = relationship(
|
|
"User",
|
|
secondary="namespace_user_association",
|
|
back_populates="namespaces",
|
|
)
|
|
|
|
# Agents associated with the namespace
|
|
agents = relationship("Agent", back_populates="namespace")
|
|
|
|
def __repr__(self):
|
|
return f"<Namespace(name='{self.name}', is_public={self.is_public})>"
|
|
|
|
|
|
class Agent(Base):
|
|
__tablename__ = "agents"
|
|
id = Column(String, primary_key=True) # UUID
|
|
name = Column(String, nullable=False)
|
|
namespace_id = Column(String, ForeignKey("namespaces.id"))
|
|
role = Column(String, nullable=False) # 'owner', 'editor', 'reader'
|
|
token_version = Column(Integer, default=0)
|
|
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
|
status = Column(String, default="active") # 'active' or 'revoked'
|
|
|
|
namespace = relationship("Namespace", back_populates="agents")
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f"<Agent(name='{self.name}', namespace_id='{self.namespace_id}', "
|
|
f"status='{self.status}', role='{self.role}')>"
|
|
)
|
|
|
|
|
|
################################################################################
|
|
# Namespace Database Models
|
|
################################################################################
|
|
|
|
NamespaceBase = declarative_base()
|
|
|
|
|
|
class Media(NamespaceBase):
|
|
__tablename__ = "media"
|
|
id = Column(String, primary_key=True) # UUID
|
|
short_id = Column(String, unique=True, nullable=False)
|
|
filename = Column(String, nullable=False)
|
|
title = Column(String, nullable=True) # Optional title
|
|
media_type = Column(String, nullable=False) # 'image', 'audio', 'video'
|
|
media_b64 = Column(Text, nullable=False)
|
|
upload_date = Column(DateTime, default=datetime.datetime.utcnow)
|
|
is_public = Column(Boolean, default=True)
|
|
size = Column(Integer, nullable=False) # Size in bytes
|
|
|
|
def __repr__(self):
|
|
return f"<Media(id='{self.id}', filename='{self.filename}')>"
|
|
|
|
|
|
################################################################################
|
|
# Jinja2 Environment and Custom Filters
|
|
################################################################################
|
|
|
|
|
|
@subscriber(IJinja2Environment)
|
|
def add_jinja2_filters(event):
|
|
env = event.environment
|
|
env.filters["filesizeformat"] = filesizeformat
|
|
|
|
|
|
################################################################################
|
|
# Request Methods
|
|
################################################################################
|
|
|
|
|
|
def get_current_user(request):
|
|
"""
|
|
Return the current user from session (for users).
|
|
"""
|
|
s = request.dbsession
|
|
user_id = request.session.get("user_id")
|
|
|
|
if user_id:
|
|
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; return None
|
|
return None
|
|
|
|
|
|
def get_current_agent(request):
|
|
"""
|
|
Return the current agent based on the JWT token.
|
|
"""
|
|
s = request.dbsession
|
|
auth_header = request.headers.get("Authorization")
|
|
|
|
if auth_header and auth_header.startswith("Bearer "):
|
|
token = auth_header[len("Bearer ") :].strip()
|
|
payload = verify_jwt_token(token)
|
|
if payload:
|
|
agent_id = payload.get("agent_id")
|
|
if not agent_id:
|
|
return None
|
|
agent = s.query(Agent).filter_by(id=agent_id).first()
|
|
if agent and agent.status == "active":
|
|
# Check token version
|
|
if payload.get("token_version") != agent.token_version:
|
|
return None # Token has been revoked
|
|
request.jwt_payload = payload
|
|
return agent
|
|
return None
|
|
|
|
|
|
def get_namespace(request):
|
|
"""Get the namespace from the route parameter 'namespace_short_id'."""
|
|
namespace_short_id = request.matchdict.get("namespace_short_id")
|
|
if not namespace_short_id:
|
|
return None
|
|
s = request.dbsession
|
|
namespace = s.query(Namespace).filter_by(short_id=namespace_short_id).first()
|
|
return namespace
|
|
|
|
|
|
def get_user_or_agent_namespace_role(request):
|
|
"""
|
|
Retrieve the role of the current user or agent in the namespace.
|
|
"""
|
|
namespace = request.namespace
|
|
if not namespace:
|
|
return None
|
|
|
|
# First, check if an agent is authenticated
|
|
agent = request.agent
|
|
if agent and agent.namespace_id == namespace.id:
|
|
return agent.role
|
|
|
|
# Next, check if a user is authenticated via session
|
|
user = request.user
|
|
if user:
|
|
s = request.dbsession
|
|
association = (
|
|
s.query(NamespaceUserAssociation)
|
|
.filter(
|
|
NamespaceUserAssociation.user_id == user.id,
|
|
NamespaceUserAssociation.namespace_id == namespace.id,
|
|
)
|
|
.first()
|
|
)
|
|
if association:
|
|
return association.role
|
|
|
|
# No role found
|
|
return None
|
|
|
|
|
|
def get_namespace_dbsession(request):
|
|
"""Adds namespace_dbsession to request if namespace is set."""
|
|
namespace = request.namespace
|
|
if namespace:
|
|
namespace_dbsession = get_namespace_dbsession_by_namespace_id(
|
|
namespace.id, request
|
|
)
|
|
return namespace_dbsession
|
|
else:
|
|
return None # No namespace selected
|
|
|
|
|
|
def get_namespace_dbsession_by_namespace_id(namespace_id, request):
|
|
"""Helper function to get a namespace_dbsession for a given namespace_id."""
|
|
namespace_db_url = get_namespace_db_url(namespace_id)
|
|
db_file = os.path.join(DATA_DIR, f"namespace_{namespace_id}.db")
|
|
if not os.path.exists(db_file):
|
|
# Create the namespace database if it doesn't exist
|
|
engine = create_engine(
|
|
namespace_db_url,
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
NamespaceBase.metadata.create_all(engine)
|
|
engine.dispose()
|
|
|
|
namespace_engine = create_engine(
|
|
namespace_db_url,
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
NamespaceSessionFactory = sessionmaker(bind=namespace_engine)
|
|
namespace_dbsession = scoped_session(NamespaceSessionFactory)
|
|
register(namespace_dbsession) # Register with zope.sqlalchemy
|
|
|
|
# Attach cleanup callbacks
|
|
def cleanup(_request):
|
|
namespace_dbsession.remove()
|
|
namespace_engine.dispose()
|
|
|
|
request.add_finished_callback(cleanup)
|
|
return namespace_dbsession
|
|
|
|
|
|
def check_namespace_permission(request, required_role):
|
|
"""
|
|
Check if the user or agent has the required role in the namespace.
|
|
Supports both session-based user authentication and JWT-based agent authentication.
|
|
"""
|
|
namespace = request.namespace
|
|
if not namespace:
|
|
return False
|
|
|
|
# Get role from user or agent
|
|
role = get_user_or_agent_namespace_role(request)
|
|
|
|
if role:
|
|
roles_hierarchy = {"owner": 3, "editor": 2, "reader": 1}
|
|
return roles_hierarchy.get(role, 0) >= roles_hierarchy.get(required_role, 0)
|
|
|
|
# If no role, check if the namespace is public and required_role is 'reader'
|
|
if namespace.is_public and required_role == "reader":
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def owner_required(view_func):
|
|
def wrapper(request):
|
|
if check_namespace_permission(request, "owner"):
|
|
return view_func(request)
|
|
else:
|
|
return HTTPForbidden("You must be an owner to access this page.")
|
|
|
|
return wrapper
|
|
|
|
|
|
def editor_required(view_func):
|
|
def wrapper(request):
|
|
if check_namespace_permission(request, "editor"):
|
|
return view_func(request)
|
|
else:
|
|
return HTTPForbidden("You must be an editor to access this page.")
|
|
|
|
return wrapper
|
|
|
|
|
|
def reader_required(view_func):
|
|
def wrapper(request):
|
|
if check_namespace_permission(request, "reader"):
|
|
return view_func(request)
|
|
else:
|
|
return HTTPForbidden("You do not have access to this namespace.")
|
|
|
|
return wrapper
|
|
|
|
|
|
################################################################################
|
|
# Routes and Views
|
|
################################################################################
|
|
|
|
|
|
@view_config(route_name="home", renderer="home.html.j2")
|
|
def home_view(request):
|
|
s = request.dbsession
|
|
# Get public namespaces
|
|
public_namespaces = s.query(Namespace).filter(Namespace.is_public == True).all()
|
|
|
|
user_namespaces = []
|
|
if request.user and request.user.is_verified:
|
|
# Get namespaces the user has access to along with their roles
|
|
user_namespaces = []
|
|
for association in request.user.namespace_associations:
|
|
ns = association.namespace
|
|
user_namespaces.append(
|
|
{
|
|
"name": ns.name,
|
|
"short_id": ns.short_id,
|
|
"role": association.role,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"request": request,
|
|
"public_namespaces": public_namespaces,
|
|
"user_namespaces": user_namespaces,
|
|
}
|
|
|
|
|
|
################################################################################
|
|
# 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
|
|
|
|
return HTTPFound(location=request.route_url("home"))
|
|
|
|
|
|
@view_config(route_name="logout", request_method="POST", require_csrf=True)
|
|
def logout_view(request):
|
|
request.session.invalidate()
|
|
return HTTPFound(location=request.route_url("home"))
|
|
|
|
|
|
################################################################################
|
|
# Profile and Namespace Management
|
|
################################################################################
|
|
|
|
|
|
@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 ""
|
|
|
|
# Get namespaces where the user is an owner
|
|
owner_namespaces = []
|
|
for association in user.namespace_associations:
|
|
if association.role == "owner":
|
|
owner_namespaces.append(association.namespace)
|
|
|
|
return {
|
|
"request": request,
|
|
"user": user,
|
|
"gravatar_url": gravatar_url,
|
|
"owner_namespaces": owner_namespaces,
|
|
}
|
|
|
|
|
|
@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="create_namespace",
|
|
request_method="GET",
|
|
renderer="create_namespace.html.j2",
|
|
)
|
|
def create_namespace_get_view(request):
|
|
if not request.user or not request.user.is_verified:
|
|
return Response("You must be logged in to create a namespace.", status=403)
|
|
return {"request": request}
|
|
|
|
|
|
@view_config(route_name="create_namespace", request_method="POST")
|
|
def create_namespace_post_view(request):
|
|
user = request.user
|
|
if not user or not user.is_verified:
|
|
return Response("You must be logged in to create a namespace.", status=403)
|
|
|
|
name = request.POST.get("name", "").strip()
|
|
if not name:
|
|
return Response("Namespace name is required.", status=400)
|
|
|
|
s = request.dbsession
|
|
existing_namespace = s.query(Namespace).filter(Namespace.name == name).first()
|
|
if existing_namespace:
|
|
return Response("Namespace name already exists.", status=400)
|
|
|
|
# Generate UUID and short ID for the namespace
|
|
namespace_uuid = uuid.uuid4()
|
|
namespace_id = str(namespace_uuid)
|
|
namespace_short_id = uuid_to_short_id(namespace_uuid)
|
|
|
|
is_public = request.POST.get("is_public") == "on"
|
|
|
|
namespace = Namespace(
|
|
id=namespace_id,
|
|
short_id=namespace_short_id,
|
|
name=name,
|
|
is_public=is_public,
|
|
)
|
|
s.add(namespace)
|
|
|
|
# Add the user as an owner
|
|
association = NamespaceUserAssociation(
|
|
namespace=namespace,
|
|
user=user,
|
|
role="owner",
|
|
)
|
|
s.add(association)
|
|
s.flush()
|
|
|
|
# Create namespace database
|
|
namespace_db_url = get_namespace_db_url(namespace.id)
|
|
engine = create_engine(
|
|
namespace_db_url,
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
NamespaceBase.metadata.create_all(engine)
|
|
engine.dispose()
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"manage_namespace", namespace_short_id=namespace.short_id
|
|
)
|
|
)
|
|
|
|
|
|
@view_config(route_name="manage_namespace", renderer="manage_namespace.html.j2")
|
|
@editor_required
|
|
def manage_namespace_view(request):
|
|
namespace = request.namespace
|
|
s = request.dbsession
|
|
|
|
# Get users and their roles in the namespace
|
|
associations = (
|
|
s.query(NamespaceUserAssociation)
|
|
.filter(NamespaceUserAssociation.namespace_id == namespace.id)
|
|
.all()
|
|
)
|
|
|
|
users = []
|
|
for association in associations:
|
|
user = association.user
|
|
users.append({"user": user, "role": association.role})
|
|
|
|
# Get agents associated with the namespace (only active agents)
|
|
agents = (
|
|
s.query(Agent)
|
|
.filter(Agent.namespace_id == namespace.id, Agent.status == "active")
|
|
.all()
|
|
)
|
|
|
|
return {
|
|
"request": request,
|
|
"namespace": namespace,
|
|
"users": users,
|
|
"agents": agents,
|
|
}
|
|
|
|
|
|
@view_config(route_name="change_member_role", request_method="POST")
|
|
@owner_required
|
|
def change_member_role_view(request):
|
|
s = request.dbsession
|
|
namespace = request.namespace
|
|
current_user = request.user
|
|
|
|
user_id = request.POST.get("user_id")
|
|
new_role = request.POST.get("role")
|
|
|
|
if not user_id or not new_role:
|
|
return Response("User ID and new role are required.", status=400)
|
|
|
|
if new_role not in ["owner", "editor", "reader"]:
|
|
return Response("Invalid role.", status=400)
|
|
|
|
# Prevent owners from changing their own role
|
|
if user_id == current_user.id:
|
|
return Response("Owners cannot change their own role.", status=400)
|
|
|
|
# Ensure the user is a member of the namespace
|
|
association = (
|
|
s.query(NamespaceUserAssociation)
|
|
.filter(
|
|
NamespaceUserAssociation.namespace_id == namespace.id,
|
|
NamespaceUserAssociation.user_id == user_id,
|
|
)
|
|
.first()
|
|
)
|
|
if not association:
|
|
return Response("User is not a member of this namespace.", status=400)
|
|
|
|
# Update the user's role
|
|
association.role = new_role
|
|
s.flush()
|
|
|
|
request.session.flash(f"User's role has been updated to {new_role}.")
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"manage_namespace", namespace_short_id=namespace.short_id
|
|
)
|
|
)
|
|
|
|
|
|
@view_config(route_name="update_namespace", request_method="POST")
|
|
@owner_required
|
|
def update_namespace_view(request):
|
|
namespace = request.namespace
|
|
s = request.dbsession
|
|
|
|
# Update namespace properties
|
|
is_public = request.POST.get("is_public") == "on"
|
|
namespace.is_public = is_public
|
|
s.flush()
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"manage_namespace", namespace_short_id=namespace.short_id
|
|
)
|
|
)
|
|
|
|
|
|
@view_config(route_name="invite_user", request_method="POST")
|
|
@owner_required
|
|
def invite_user_view(request):
|
|
namespace = request.namespace
|
|
s = request.dbsession
|
|
|
|
email = request.POST.get("email", "").strip().lower()
|
|
role = request.POST.get("role", "").strip().lower()
|
|
if role not in ["owner", "editor", "reader"]:
|
|
return Response("Invalid role.", status=400)
|
|
|
|
# Find or create the user
|
|
user = s.query(User).filter(User.email == email).first()
|
|
if not user:
|
|
user_uuid = uuid.uuid4()
|
|
user_id = str(user_uuid)
|
|
short_id = uuid_to_short_id(user_uuid)
|
|
user = User(
|
|
id=user_id,
|
|
short_id=short_id,
|
|
email=email,
|
|
username=email.split("@")[0],
|
|
is_verified=False,
|
|
)
|
|
s.add(user)
|
|
s.flush()
|
|
|
|
# Check if the user already has an association with the namespace
|
|
existing_association = (
|
|
s.query(NamespaceUserAssociation)
|
|
.filter(
|
|
NamespaceUserAssociation.namespace_id == namespace.id,
|
|
NamespaceUserAssociation.user_id == user.id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if existing_association:
|
|
# Update the role if the user is already associated
|
|
existing_association.role = role
|
|
else:
|
|
# Create a new association
|
|
association = NamespaceUserAssociation(
|
|
namespace=namespace,
|
|
user=user,
|
|
role=role,
|
|
)
|
|
s.add(association)
|
|
|
|
s.flush()
|
|
|
|
request.session.flash(f"{user.email} was invited as {role} role.")
|
|
|
|
# Send invitation email
|
|
email_body = (
|
|
f"You have been invited as a {role} to namespace "
|
|
f"'{namespace.name}'. Please log in to access it."
|
|
)
|
|
send_email(user.email, "Namespace Invitation", email_body)
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"manage_namespace", namespace_short_id=namespace.short_id
|
|
)
|
|
)
|
|
|
|
|
|
@view_config(route_name="remove_user", request_method="POST")
|
|
@owner_required
|
|
def remove_user_view(request):
|
|
s = request.dbsession
|
|
namespace = request.namespace
|
|
current_user = request.user # The owner initiating the removal
|
|
|
|
# Get the user ID to remove from the POST data
|
|
user_id_to_remove = request.POST.get("user_id")
|
|
if not user_id_to_remove:
|
|
return Response("User ID is required.", status=400)
|
|
|
|
# Ensure that the user exists and is a member of the namespace
|
|
user_to_remove = s.query(User).filter(User.id == user_id_to_remove).first()
|
|
if not user_to_remove:
|
|
return Response("User not found.", status=404)
|
|
|
|
# Prevent owners from removing themselves
|
|
if user_to_remove.id == current_user.id:
|
|
return Response("Owners cannot remove themselves.", status=400)
|
|
|
|
# Check if the user to remove is a member of the namespace
|
|
association = (
|
|
s.query(NamespaceUserAssociation)
|
|
.filter(
|
|
NamespaceUserAssociation.namespace_id == namespace.id,
|
|
NamespaceUserAssociation.user_id == user_id_to_remove,
|
|
)
|
|
.first()
|
|
)
|
|
if not association:
|
|
return Response("User is not a member of this namespace.", status=400)
|
|
|
|
# Remove the association
|
|
s.delete(association)
|
|
s.flush()
|
|
|
|
# Provide a success message
|
|
request.session.flash(
|
|
f"User '{user_to_remove.username}' has been removed from the namespace."
|
|
)
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"manage_namespace", namespace_short_id=namespace.short_id
|
|
)
|
|
)
|
|
|
|
|
|
@view_config(
|
|
route_name="generate_agent_jwt",
|
|
request_method="POST",
|
|
renderer="display_agent_jwt.html.j2",
|
|
)
|
|
@owner_required
|
|
def generate_agent_jwt_view(request):
|
|
namespace = request.namespace
|
|
s = request.dbsession
|
|
|
|
agent_name = request.POST.get("agent_name", "").strip()
|
|
agent_role = request.POST.get("agent_role", "").strip().lower()
|
|
if not agent_name:
|
|
return Response("Agent name is required.", status=400)
|
|
if agent_role not in ["owner", "editor", "reader"]:
|
|
return Response("Invalid agent role.", status=400)
|
|
|
|
# Check if an agent with the same name exists in the namespace
|
|
agent = (
|
|
s.query(Agent)
|
|
.filter(
|
|
Agent.name == agent_name,
|
|
Agent.namespace_id == namespace.id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if agent:
|
|
# Agent exists, increment token_version and regenerate JWT
|
|
agent.token_version += 1
|
|
agent.status = "active" # Ensure the agent is active
|
|
agent.role = agent_role # Update the role
|
|
s.flush()
|
|
jwt_token = generate_jwt_token(agent)
|
|
message = (
|
|
f"A new JWT has been generated for existing agent '{agent.name}'. "
|
|
"Any previous tokens have been revoked."
|
|
)
|
|
else:
|
|
# Generate UUID for the new agent
|
|
agent_uuid = uuid.uuid4()
|
|
agent_id = str(agent_uuid)
|
|
|
|
# Create a new agent entry
|
|
agent = Agent(
|
|
id=agent_id,
|
|
name=agent_name,
|
|
namespace_id=namespace.id,
|
|
role=agent_role,
|
|
token_version=0,
|
|
status="active",
|
|
)
|
|
|
|
s.add(agent)
|
|
s.flush()
|
|
|
|
# Generate JWT for the agent including the namespace_id
|
|
jwt_token = generate_jwt_token(agent)
|
|
message = f"A new agent '{agent.name}' has been created."
|
|
|
|
return {
|
|
"request": request,
|
|
"namespace": namespace,
|
|
"agent_name": agent_name,
|
|
"jwt_token": jwt_token,
|
|
"message": message,
|
|
}
|
|
|
|
|
|
@view_config(route_name="revoke_agent", request_method="POST")
|
|
@owner_required
|
|
def revoke_agent_view(request):
|
|
namespace = request.namespace
|
|
s = request.dbsession
|
|
|
|
agent_id = request.POST.get("agent_id")
|
|
if not agent_id:
|
|
return Response("Agent ID is required.", status=400)
|
|
|
|
# Get the agent
|
|
agent = (
|
|
s.query(Agent)
|
|
.filter(
|
|
Agent.id == agent_id,
|
|
Agent.namespace_id == namespace.id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not agent:
|
|
return Response("Agent not found.", status=404)
|
|
|
|
# Set the agent's status to 'revoked' to hide it from the dashboard
|
|
agent.status = "revoked"
|
|
s.flush()
|
|
request.session.flash(f"Agent '{agent.name}' has been revoked.")
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"manage_namespace", namespace_short_id=namespace.short_id
|
|
)
|
|
)
|
|
|
|
|
|
################################################################################
|
|
# Media Upload, Listing, and Management
|
|
################################################################################
|
|
|
|
|
|
@view_config(
|
|
route_name="upload_media",
|
|
request_method="GET",
|
|
renderer="upload_media.html.j2",
|
|
)
|
|
@editor_required
|
|
def upload_media_get_view(request):
|
|
return {"request": request}
|
|
|
|
|
|
@view_config(route_name="upload_media", request_method="POST")
|
|
@editor_required
|
|
def upload_media_post_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
|
|
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,
|
|
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,
|
|
)
|
|
namespace_dbsession.add(media)
|
|
namespace_dbsession.flush()
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"view_media_details",
|
|
namespace_short_id=namespace.short_id,
|
|
media_short_id=media.short_id,
|
|
)
|
|
)
|
|
|
|
|
|
@view_config(route_name="list_media", renderer="list_media.html.j2")
|
|
def list_media_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
|
|
# Check access
|
|
if not check_namespace_permission(request, "reader"):
|
|
return HTTPForbidden("You do not have access to this namespace.")
|
|
|
|
# Get the media items
|
|
media_items = (
|
|
namespace_dbsession.query(Media).order_by(Media.upload_date.desc()).all()
|
|
)
|
|
|
|
return {
|
|
"request": request,
|
|
"namespace": namespace,
|
|
"media_items": media_items,
|
|
}
|
|
|
|
|
|
@view_config(route_name="view_media_details", renderer="view_media_details.html.j2")
|
|
def view_media_details_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
|
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
|
if not media:
|
|
return Response("Media not found.", status=404)
|
|
|
|
role = get_user_or_agent_namespace_role(request)
|
|
is_owner_or_editor = role in ["owner", "editor"]
|
|
|
|
# Check if media is public or user has access
|
|
if not media.is_public and not check_namespace_permission(request, "reader"):
|
|
return Response("Media not available.", status=403)
|
|
|
|
return {
|
|
"request": request,
|
|
"media": media,
|
|
"namespace": namespace,
|
|
"is_owner_or_editor": is_owner_or_editor,
|
|
}
|
|
|
|
|
|
@view_config(route_name="delete_media", request_method="POST")
|
|
@editor_required
|
|
def delete_media_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
|
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
|
if not media:
|
|
return Response("Media not found.", status=404)
|
|
|
|
namespace_dbsession.delete(media)
|
|
namespace_dbsession.flush()
|
|
|
|
return HTTPFound(
|
|
location=request.route_url("list_media", namespace_short_id=namespace.short_id)
|
|
)
|
|
|
|
|
|
@view_config(
|
|
route_name="edit_media", request_method="GET", renderer="edit_media.html.j2"
|
|
)
|
|
@editor_required
|
|
def edit_media_get_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
|
|
media = namespace_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,
|
|
"namespace": namespace,
|
|
}
|
|
|
|
|
|
@view_config(route_name="edit_media", request_method="POST")
|
|
@editor_required
|
|
def edit_media_post_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
|
|
media = namespace_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
|
|
|
|
namespace_dbsession.flush()
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"view_media_details",
|
|
namespace_short_id=namespace.short_id,
|
|
media_short_id=media.short_id,
|
|
)
|
|
)
|
|
|
|
|
|
################################################################################
|
|
# Media Viewing and Downloading
|
|
################################################################################
|
|
|
|
|
|
@view_config(route_name="view_media")
|
|
def view_media_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
|
|
media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first()
|
|
if not media:
|
|
return Response("Media not found.", status=404)
|
|
|
|
# Check if media is public or user has access
|
|
if not media.is_public and not check_namespace_permission(request, "reader"):
|
|
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.INFO)
|
|
|
|
# Set up the session factory
|
|
session_factory = SignedCookieSessionFactory(
|
|
secret=app_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_tm")
|
|
|
|
config.include("pyramid_openapi3")
|
|
config.pyramid_openapi3_spec('openapi.yaml', route='/openapi.yaml')
|
|
config.pyramid_openapi3_add_explorer(route='/docs')
|
|
|
|
# Add .html.j2 extension for Jinja2 templates
|
|
config.include("pyramid_jinja2")
|
|
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 with zope.sqlalchemy
|
|
register(DBSession)
|
|
|
|
# Provide dbsession to requests
|
|
def dbsession(request):
|
|
return DBSession
|
|
|
|
config.add_request_method(dbsession, "dbsession", reify=True)
|
|
|
|
# Add user, agent, namespace, user_namespace_role to requests
|
|
config.add_request_method(get_current_user, "user", reify=True)
|
|
config.add_request_method(get_current_agent, "agent", reify=True)
|
|
config.add_request_method(get_namespace, "namespace", reify=True)
|
|
config.add_request_method(
|
|
get_user_or_agent_namespace_role, "user_namespace_role", reify=True
|
|
)
|
|
|
|
# Add namespace_dbsession to requests if namespace is set
|
|
config.add_request_method(
|
|
get_namespace_dbsession, "namespace_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")
|
|
|
|
# Namespace Management
|
|
config.add_route("create_namespace", "/namespace/create")
|
|
config.add_route("manage_namespace", "/namespace/{namespace_short_id}/manage")
|
|
config.add_route("update_namespace", "/namespace/{namespace_short_id}/update")
|
|
config.add_route("invite_user", "/namespace/{namespace_short_id}/invite")
|
|
config.add_route("remove_user", "/namespace/{namespace_short_id}/remove_user")
|
|
config.add_route(
|
|
"change_member_role", "/namespace/{namespace_short_id}/change_member_role"
|
|
)
|
|
|
|
# Agent Management
|
|
config.add_route("revoke_agent", "/namespace/{namespace_short_id}/revoke_agent")
|
|
config.add_route(
|
|
"generate_agent_jwt", "/namespace/{namespace_short_id}/generate_agent_jwt"
|
|
)
|
|
|
|
# Media
|
|
config.add_route("upload_media", "/namespace/{namespace_short_id}/media/upload")
|
|
config.add_route("list_media", "/namespace/{namespace_short_id}/media/list")
|
|
config.add_route(
|
|
"view_media_details",
|
|
"/namespace/{namespace_short_id}/media/{media_short_id}/details",
|
|
)
|
|
config.add_route(
|
|
"edit_media",
|
|
"/namespace/{namespace_short_id}/media/{media_short_id}/edit",
|
|
)
|
|
config.add_route(
|
|
"delete_media",
|
|
"/namespace/{namespace_short_id}/media/{media_short_id}/delete",
|
|
)
|
|
config.add_route(
|
|
"view_media",
|
|
"/namespace/{namespace_short_id}/media/{media_short_id}",
|
|
)
|
|
|
|
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)
|