- Restored start_scheduler() function that was missing - Added DISABLE_SCHEDULER feature flag check in main() - App should start now without scheduler running - Fixed 500 Internal Server Error russell@unturf.com is the boss
2361 lines
77 KiB
Python
2361 lines
77 KiB
Python
###############################################################################
|
|
# app.py - Enhanced PyraFiles Application with Scheduled Actions and More
|
|
###############################################################################
|
|
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
|
|
import threading
|
|
import time
|
|
import signal
|
|
import sys
|
|
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_,
|
|
inspect,
|
|
)
|
|
from sqlalchemy.orm import (
|
|
declarative_base,
|
|
sessionmaker,
|
|
scoped_session,
|
|
relationship,
|
|
load_only,
|
|
)
|
|
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")
|
|
|
|
ALLOWED_MIME_PREFIXES = ("image/", "audio/", "video/", "text/", "application/")
|
|
|
|
|
|
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, from_email=None):
|
|
if from_email is None:
|
|
from_email = os.environ.get("PYRAFILES_FROM_EMAIL", "master@master.unturf.com")
|
|
msg = MIMEText(body)
|
|
msg["Subject"] = subject
|
|
msg["From"] = from_email
|
|
msg["To"] = to_email
|
|
try:
|
|
s = smtplib.SMTP(smtp_host, smtp_port)
|
|
s.sendmail(from_email, [to_email], msg.as_string())
|
|
s.quit()
|
|
except Exception as e:
|
|
log = logging.getLogger(__name__)
|
|
log.info("======= Email Sent =======")
|
|
log.info(f"To: {to_email}")
|
|
log.info(f"Subject: {subject}")
|
|
log.info(f"Body:\n{body}")
|
|
log.info("==========================")
|
|
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 is_ascii_file(mime_type):
|
|
"""Check if the file is ASCII-based (text files)."""
|
|
ascii_types = [
|
|
"text/",
|
|
"application/json",
|
|
"application/xml",
|
|
"application/javascript",
|
|
"application/x-yaml",
|
|
"application/yaml",
|
|
]
|
|
return any(mime_type.startswith(t) for t in ascii_types)
|
|
|
|
|
|
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,
|
|
"namespace_short_id": agent.namespace.short_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}')>"
|
|
)
|
|
|
|
|
|
# Scheduler lock table removed - using namespace-isolated jobs instead
|
|
|
|
|
|
################################################################################
|
|
# 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'
|
|
mime_type = Column(String, nullable=True) # MIME type for compatibility
|
|
media_b64 = Column(Text, nullable=False)
|
|
upload_date = Column(DateTime, default=datetime.datetime.utcnow)
|
|
visibility = Column(String, default="public") # 'public', 'private', 'unlisted'
|
|
size = Column(Integer, nullable=False) # Size in bytes
|
|
|
|
def __repr__(self):
|
|
return f"<Media(id='{self.id}', filename='{self.filename}')>"
|
|
|
|
|
|
class ScheduledAction(NamespaceBase):
|
|
__tablename__ = "scheduled_actions"
|
|
id = Column(String, primary_key=True) # UUID
|
|
media_id = Column(String, ForeignKey("media.id"), nullable=False)
|
|
action_type = Column(
|
|
String, nullable=False
|
|
) # 'set_public', 'set_private', 'set_unlisted', 'delete'
|
|
scheduled_date = Column(DateTime, nullable=False)
|
|
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
|
status = Column(String, default="pending") # 'pending', 'completed', 'failed'
|
|
|
|
media = relationship("Media")
|
|
|
|
def __repr__(self):
|
|
return f"<ScheduledAction(media_id='{self.media_id}', action='{self.action_type}', date='{self.scheduled_date}')>"
|
|
|
|
|
|
################################################################################
|
|
# 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
|
|
|
|
|
|
################################################################################
|
|
# Scheduler Functions
|
|
################################################################################
|
|
|
|
|
|
# Scheduler lock functions removed - using namespace-isolated jobs instead
|
|
|
|
|
|
def dispatch_namespace_scheduler_jobs():
|
|
"""Dispatch scheduler jobs to each namespace without touching main DB."""
|
|
# Get list of namespace directories instead of querying main DB
|
|
data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
|
|
|
if not os.path.exists(data_dir):
|
|
log.debug("No data directory found, skipping scheduler")
|
|
return
|
|
|
|
namespace_dbs = []
|
|
for file in os.listdir(data_dir):
|
|
if file.startswith("namespace_") and file.endswith(".db"):
|
|
# Extract namespace ID from filename: namespace_{uuid}.db
|
|
namespace_id = file[10:-3] # Remove 'namespace_' prefix and '.db' suffix
|
|
namespace_dbs.append(namespace_id)
|
|
|
|
if not namespace_dbs:
|
|
log.debug("No namespace databases found")
|
|
return
|
|
|
|
log.info(f"Scheduler dispatching jobs to {len(namespace_dbs)} namespaces")
|
|
|
|
# Process each namespace in a separate thread with quick queries
|
|
import concurrent.futures
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
|
futures = []
|
|
for namespace_id in namespace_dbs:
|
|
future = executor.submit(process_namespace_scheduled_actions_fast, namespace_id)
|
|
futures.append(future)
|
|
|
|
# Wait for all namespace jobs to complete (with timeout)
|
|
completed = 0
|
|
for future in concurrent.futures.as_completed(futures, timeout=300):
|
|
try:
|
|
future.result()
|
|
completed += 1
|
|
except Exception as e:
|
|
log.error(f"Namespace scheduler job failed: {e}")
|
|
|
|
log.info(f"Finished dispatching scheduler jobs ({completed}/{len(namespace_dbs)} completed)")
|
|
|
|
|
|
def process_namespace_scheduled_actions(namespace_id):
|
|
"""Process scheduled actions for a specific namespace."""
|
|
namespace_db_url = get_namespace_db_url(namespace_id)
|
|
|
|
try:
|
|
namespace_engine = create_engine(
|
|
namespace_db_url,
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
NamespaceSessionFactory = sessionmaker(bind=namespace_engine)
|
|
namespace_dbsession = NamespaceSessionFactory()
|
|
|
|
# Check if scheduled_actions table exists
|
|
inspector = inspect(namespace_engine)
|
|
if "scheduled_actions" not in inspector.get_table_names():
|
|
log.debug(
|
|
f"No scheduled_actions table in namespace {namespace_id}, skipping"
|
|
)
|
|
return
|
|
|
|
# Get pending actions that are due
|
|
now = datetime.datetime.utcnow()
|
|
pending_actions = (
|
|
namespace_dbsession.query(ScheduledAction)
|
|
.filter(
|
|
ScheduledAction.status == "pending",
|
|
ScheduledAction.scheduled_date <= now,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
# Process each action individually with short transactions
|
|
for action in pending_actions:
|
|
process_single_scheduled_action(namespace_dbsession, action)
|
|
|
|
except Exception as e:
|
|
log.error(f"Error processing namespace {namespace_id}: {e}")
|
|
finally:
|
|
if "namespace_dbsession" in locals():
|
|
namespace_dbsession.close()
|
|
if "namespace_engine" in locals():
|
|
namespace_engine.dispose()
|
|
|
|
|
|
def process_namespace_scheduled_actions_fast(namespace_id):
|
|
"""Process scheduled actions with lightning-fast micro-transactions."""
|
|
namespace_db_url = get_namespace_db_url(namespace_id)
|
|
|
|
try:
|
|
# Step 1: Quick query to find due actions (read-only, minimal lock)
|
|
due_action_ids = []
|
|
engine = create_engine(namespace_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
|
|
|
# Check if table exists first
|
|
from sqlalchemy import inspect
|
|
inspector = inspect(engine)
|
|
if "scheduled_actions" not in inspector.get_table_names():
|
|
engine.dispose()
|
|
return
|
|
|
|
# Quick read-only query for due actions
|
|
SessionFactory = sessionmaker(bind=engine)
|
|
with SessionFactory() as session:
|
|
now = datetime.datetime.utcnow()
|
|
due_actions = session.query(ScheduledAction.id).filter(
|
|
ScheduledAction.status == "pending",
|
|
ScheduledAction.scheduled_date <= now,
|
|
).all()
|
|
due_action_ids = [action.id for action in due_actions]
|
|
engine.dispose()
|
|
|
|
if not due_action_ids:
|
|
return
|
|
|
|
log.info(f"Processing {len(due_action_ids)} due actions in namespace {namespace_id}")
|
|
|
|
# Step 2: Process each action in its own micro-transaction
|
|
for action_id in due_action_ids:
|
|
try:
|
|
process_single_action_fast(namespace_id, action_id)
|
|
except Exception as e:
|
|
log.error(f"Failed to process action {action_id}: {e}")
|
|
|
|
except Exception as e:
|
|
log.error(f"Error processing namespace {namespace_id}: {e}")
|
|
|
|
|
|
def process_single_action_fast(namespace_id, action_id):
|
|
"""Process a single action with minimal transaction time."""
|
|
namespace_db_url = get_namespace_db_url(namespace_id)
|
|
engine = create_engine(namespace_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
|
|
|
try:
|
|
SessionFactory = sessionmaker(bind=engine)
|
|
with SessionFactory() as session:
|
|
# Get action and media in one query
|
|
action = session.query(ScheduledAction).filter_by(id=action_id).first()
|
|
if not action or action.status != "pending":
|
|
return
|
|
|
|
media = session.query(Media).filter_by(id=action.media_id).first()
|
|
if not media:
|
|
action.status = "failed"
|
|
action.completed_at = datetime.datetime.utcnow()
|
|
session.commit()
|
|
return
|
|
|
|
# Perform the action
|
|
if action.action_type == "delete":
|
|
session.delete(media)
|
|
log.info(f"Scheduled delete executed for media {media.filename}")
|
|
elif action.action_type == "set_public":
|
|
media.visibility = "public"
|
|
log.info(f"Scheduled set_public executed for media {media.filename}")
|
|
elif action.action_type == "set_private":
|
|
media.visibility = "private"
|
|
log.info(f"Scheduled set_private executed for media {media.filename}")
|
|
elif action.action_type == "set_unlisted":
|
|
media.visibility = "unlisted"
|
|
log.info(f"Scheduled set_unlisted executed for media {media.filename}")
|
|
|
|
action.status = "completed"
|
|
action.completed_at = datetime.datetime.utcnow()
|
|
session.commit()
|
|
|
|
except Exception as e:
|
|
log.error(f"Error processing single action {action_id}: {e}")
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def process_single_scheduled_action(dbsession, action):
|
|
"""Process a single scheduled action with minimal transaction time."""
|
|
try:
|
|
# Start a new transaction for this action
|
|
with dbsession.begin():
|
|
# Refresh the action to get latest state
|
|
dbsession.refresh(action)
|
|
|
|
# Skip if already processed
|
|
if action.status != "pending":
|
|
return
|
|
|
|
media = (
|
|
dbsession.query(Media)
|
|
.filter_by(id=action.media_id)
|
|
.first()
|
|
)
|
|
if not media:
|
|
action.status = "failed"
|
|
action.completed_at = datetime.datetime.utcnow()
|
|
return
|
|
|
|
if action.action_type == "delete":
|
|
dbsession.delete(media)
|
|
log.info(f"Scheduled delete executed for media {media.filename}")
|
|
elif action.action_type == "set_public":
|
|
media.visibility = "public"
|
|
log.info(f"Scheduled set_public executed for media {media.filename}")
|
|
elif action.action_type == "set_private":
|
|
media.visibility = "private"
|
|
log.info(f"Scheduled set_private executed for media {media.filename}")
|
|
elif action.action_type == "set_unlisted":
|
|
media.visibility = "unlisted"
|
|
log.info(f"Scheduled set_unlisted executed for media {media.filename}")
|
|
|
|
action.status = "completed"
|
|
action.completed_at = datetime.datetime.utcnow()
|
|
|
|
except Exception as e:
|
|
log.error(f"Error processing scheduled action {action.id}: {e}")
|
|
try:
|
|
# Try to mark as failed in a separate transaction
|
|
with dbsession.begin():
|
|
dbsession.refresh(action)
|
|
action.status = "failed"
|
|
action.completed_at = datetime.datetime.utcnow()
|
|
except Exception as inner_e:
|
|
log.error(f"Failed to mark action {action.id} as failed: {inner_e}")
|
|
|
|
|
|
# Global variables for scheduler thread
|
|
scheduler_thread = None
|
|
scheduler_running = False
|
|
|
|
|
|
def scheduler_worker():
|
|
"""Background scheduler worker that dispatches per-namespace jobs."""
|
|
global scheduler_running
|
|
while scheduler_running:
|
|
try:
|
|
dispatch_namespace_scheduler_jobs()
|
|
except Exception as e:
|
|
log.error(f"Scheduler dispatcher error: {e}")
|
|
|
|
# Sleep for 60 seconds
|
|
for i in range(60):
|
|
if not scheduler_running:
|
|
break
|
|
time.sleep(1)
|
|
|
|
|
|
def check_and_run_scheduler():
|
|
"""Check for due scheduled actions and run them immediately (on-demand)."""
|
|
# Check if scheduler is disabled via environment variable
|
|
disable_scheduler = os.environ.get('DISABLE_SCHEDULER', '').lower() in ('true', '1', 'yes')
|
|
if disable_scheduler:
|
|
return
|
|
|
|
try:
|
|
# Simple time-based throttling - only run once per minute max
|
|
import time
|
|
current_time = time.time()
|
|
if not hasattr(check_and_run_scheduler, 'last_run'):
|
|
check_and_run_scheduler.last_run = 0
|
|
|
|
if current_time - check_and_run_scheduler.last_run < 60:
|
|
return # Skip if ran within last minute
|
|
|
|
check_and_run_scheduler.last_run = current_time
|
|
log.debug("Running on-demand scheduler check")
|
|
dispatch_namespace_scheduler_jobs()
|
|
|
|
except Exception as e:
|
|
log.error(f"On-demand scheduler error: {e}")
|
|
|
|
|
|
def start_scheduler():
|
|
"""Start the background scheduler."""
|
|
global scheduler_thread, scheduler_running
|
|
|
|
# Check if scheduler is disabled via environment variable
|
|
disable_scheduler = os.environ.get('DISABLE_SCHEDULER', '').lower() in ('true', '1', 'yes')
|
|
if disable_scheduler:
|
|
log.info("Scheduler disabled by DISABLE_SCHEDULER environment variable")
|
|
return
|
|
|
|
if not scheduler_running:
|
|
scheduler_running = True
|
|
scheduler_thread = threading.Thread(target=scheduler_worker, daemon=True)
|
|
scheduler_thread.start()
|
|
log.info("Scheduler started")
|
|
|
|
|
|
def stop_scheduler():
|
|
"""Stop the background scheduler."""
|
|
global scheduler_thread, scheduler_running
|
|
if scheduler_running:
|
|
scheduler_running = False
|
|
if scheduler_thread:
|
|
scheduler_thread.join(timeout=5)
|
|
log.info("Scheduler stopped")
|
|
|
|
|
|
################################################################################
|
|
# 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 = 310 * 1024 * 1024 # 310 MB
|
|
if len(raw_bytes) > max_size:
|
|
return Response("File size exceeds the 310MB 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(ALLOWED_MIME_PREFIXES):
|
|
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)
|
|
|
|
visibility = request.POST.get("visibility", "public")
|
|
if visibility not in ["public", "private", "unlisted"]:
|
|
visibility = "public"
|
|
|
|
media = Media(
|
|
id=media_id,
|
|
short_id=media_short_id,
|
|
filename=filename,
|
|
title=title,
|
|
media_type=media_type,
|
|
mime_type=mime_type,
|
|
media_b64=encoded_str,
|
|
visibility=visibility,
|
|
size=file_size,
|
|
)
|
|
namespace_dbsession.add(media)
|
|
|
|
# Handle scheduled actions
|
|
scheduled_public = request.POST.get("scheduled_public")
|
|
scheduled_private = request.POST.get("scheduled_private")
|
|
scheduled_unlisted = request.POST.get("scheduled_unlisted")
|
|
scheduled_delete = request.POST.get("scheduled_delete")
|
|
|
|
# Check if scheduled_actions table exists before adding scheduled actions
|
|
try:
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
has_scheduled_actions = "scheduled_actions" in inspector.get_table_names()
|
|
except:
|
|
has_scheduled_actions = False
|
|
|
|
if has_scheduled_actions:
|
|
if scheduled_public:
|
|
try:
|
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_public)
|
|
action_uuid = uuid.uuid4()
|
|
action = ScheduledAction(
|
|
id=str(action_uuid),
|
|
media_id=media_id,
|
|
action_type="set_public",
|
|
scheduled_date=scheduled_date,
|
|
)
|
|
namespace_dbsession.add(action)
|
|
except ValueError:
|
|
pass
|
|
|
|
if scheduled_private:
|
|
try:
|
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_private)
|
|
action_uuid = uuid.uuid4()
|
|
action = ScheduledAction(
|
|
id=str(action_uuid),
|
|
media_id=media_id,
|
|
action_type="set_private",
|
|
scheduled_date=scheduled_date,
|
|
)
|
|
namespace_dbsession.add(action)
|
|
except ValueError:
|
|
pass
|
|
|
|
if scheduled_unlisted:
|
|
try:
|
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_unlisted)
|
|
action_uuid = uuid.uuid4()
|
|
action = ScheduledAction(
|
|
id=str(action_uuid),
|
|
media_id=media_id,
|
|
action_type="set_unlisted",
|
|
scheduled_date=scheduled_date,
|
|
)
|
|
namespace_dbsession.add(action)
|
|
except ValueError:
|
|
pass
|
|
|
|
if scheduled_delete:
|
|
try:
|
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_delete)
|
|
action_uuid = uuid.uuid4()
|
|
action = ScheduledAction(
|
|
id=str(action_uuid),
|
|
media_id=media_id,
|
|
action_type="delete",
|
|
scheduled_date=scheduled_date,
|
|
)
|
|
namespace_dbsession.add(action)
|
|
except ValueError:
|
|
pass
|
|
|
|
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, excluding the media_b64 column
|
|
# Only show public media unless user has editor+ access
|
|
query = namespace_dbsession.query(Media).options(
|
|
load_only(
|
|
Media.id,
|
|
Media.short_id,
|
|
Media.filename,
|
|
Media.title,
|
|
Media.media_type,
|
|
Media.upload_date,
|
|
Media.visibility,
|
|
Media.size,
|
|
)
|
|
)
|
|
|
|
# Filter based on user permissions
|
|
user_role = get_user_or_agent_namespace_role(request)
|
|
if user_role in ["owner", "editor"]:
|
|
# Owners and editors can see all media except unlisted
|
|
media_items = (
|
|
query.filter(Media.visibility != "unlisted")
|
|
.order_by(Media.upload_date.desc())
|
|
.all()
|
|
)
|
|
else:
|
|
# Readers and public users can only see public media
|
|
media_items = (
|
|
query.filter(Media.visibility == "public")
|
|
.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 visibility permissions
|
|
if media.visibility == "private" and not check_namespace_permission(
|
|
request, "reader"
|
|
):
|
|
return Response("Media not available.", status=403)
|
|
elif media.visibility == "unlisted" and not is_owner_or_editor:
|
|
return Response("Media not available.", status=403)
|
|
elif media.visibility == "public":
|
|
# Public media is always accessible
|
|
pass
|
|
|
|
# Check if this is an ASCII file and get preview content
|
|
ascii_preview = None
|
|
mime_type = get_mime_type(media.filename)
|
|
if is_ascii_file(mime_type):
|
|
try:
|
|
raw_content = base64.b64decode(media.media_b64).decode("utf-8")
|
|
lines = raw_content.split("\n")
|
|
if len(lines) > 100:
|
|
ascii_preview = "\n".join(lines[:100]) + "\n... (truncated)"
|
|
else:
|
|
ascii_preview = raw_content
|
|
except (UnicodeDecodeError, Exception):
|
|
ascii_preview = None
|
|
|
|
# Get scheduled actions for this media
|
|
scheduled_actions = []
|
|
if is_owner_or_editor:
|
|
try:
|
|
# Check if scheduled_actions table exists
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
if "scheduled_actions" in inspector.get_table_names():
|
|
scheduled_actions = (
|
|
namespace_dbsession.query(ScheduledAction)
|
|
.filter(
|
|
ScheduledAction.media_id == media.id,
|
|
ScheduledAction.status == "pending",
|
|
)
|
|
.order_by(ScheduledAction.scheduled_date)
|
|
.all()
|
|
)
|
|
except Exception as e:
|
|
log.debug(f"Could not query scheduled actions: {e}")
|
|
scheduled_actions = []
|
|
|
|
return {
|
|
"request": request,
|
|
"media": media,
|
|
"namespace": namespace,
|
|
"is_owner_or_editor": is_owner_or_editor,
|
|
"ascii_preview": ascii_preview,
|
|
"scheduled_actions": scheduled_actions,
|
|
}
|
|
|
|
|
|
@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)
|
|
|
|
# Delete associated scheduled actions if table exists
|
|
try:
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
if "scheduled_actions" in inspector.get_table_names():
|
|
scheduled_actions = (
|
|
namespace_dbsession.query(ScheduledAction)
|
|
.filter_by(media_id=media.id)
|
|
.all()
|
|
)
|
|
for action in scheduled_actions:
|
|
namespace_dbsession.delete(action)
|
|
except Exception as e:
|
|
log.debug(f"Could not delete scheduled actions: {e}")
|
|
|
|
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)
|
|
|
|
# Check if this is an ASCII file and get editable content
|
|
ascii_content = None
|
|
mime_type = get_mime_type(media.filename)
|
|
if is_ascii_file(mime_type):
|
|
try:
|
|
ascii_content = base64.b64decode(media.media_b64).decode("utf-8")
|
|
except (UnicodeDecodeError, Exception):
|
|
ascii_content = None
|
|
|
|
return {
|
|
"request": request,
|
|
"media": media,
|
|
"namespace": namespace,
|
|
"ascii_content": ascii_content,
|
|
}
|
|
|
|
|
|
@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 ASCII content update
|
|
ascii_content = request.POST.get("ascii_content", "").strip()
|
|
mime_type = get_mime_type(media.filename)
|
|
if ascii_content and is_ascii_file(mime_type):
|
|
try:
|
|
# Update the media content with the new ASCII content
|
|
encoded_str = base64.b64encode(ascii_content.encode("utf-8")).decode(
|
|
"utf-8"
|
|
)
|
|
media.media_b64 = encoded_str
|
|
media.size = len(ascii_content.encode("utf-8"))
|
|
except Exception as e:
|
|
return Response(f"Error updating content: {e}", status=400)
|
|
|
|
# Handle media file update
|
|
new_media_file = request.POST.get("media_file")
|
|
if new_media_file is not None and getattr(new_media_file, "filename", "").strip():
|
|
raw_bytes = new_media_file.file.read()
|
|
# 310 MB
|
|
max_size = 310 * 1024 * 1024
|
|
if len(raw_bytes) > max_size:
|
|
return Response("File size exceeds the 310MB limit.", status=400)
|
|
file_size = len(raw_bytes)
|
|
filename = new_media_file.filename
|
|
mime_type = get_mime_type(filename)
|
|
if not mime_type.startswith(ALLOWED_MIME_PREFIXES):
|
|
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.mime_type = mime_type # Update mime_type too
|
|
media.media_b64 = encoded_str
|
|
media.size = file_size
|
|
|
|
# Handle visibility update
|
|
visibility = request.POST.get("visibility", "public")
|
|
if visibility in ["public", "private", "unlisted"]:
|
|
media.visibility = visibility
|
|
|
|
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)
|
|
|
|
role = get_user_or_agent_namespace_role(request)
|
|
is_owner_or_editor = role in ["owner", "editor"]
|
|
|
|
# Check visibility permissions
|
|
if media.visibility == "private" and not check_namespace_permission(
|
|
request, "reader"
|
|
):
|
|
return Response("Media not available.", status=403)
|
|
elif media.visibility == "unlisted":
|
|
# Unlisted media is accessible via direct link but not listed
|
|
if not is_owner_or_editor and not check_namespace_permission(request, "reader"):
|
|
return Response("Media not available.", status=403)
|
|
elif media.visibility == "public":
|
|
# Public media is always accessible via direct link
|
|
pass
|
|
|
|
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
|
|
# Check if title already ends with the file extension to avoid doubling
|
|
if file_extension and not raw_title.lower().endswith(file_extension.lower()):
|
|
download_filename = sanitize_filename_for_http_header(
|
|
f"{raw_title}{file_extension}"
|
|
)
|
|
else:
|
|
download_filename = sanitize_filename_for_http_header(raw_title)
|
|
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
|
|
|
|
|
|
################################################################################
|
|
# Scheduled Action Management
|
|
################################################################################
|
|
|
|
|
|
@view_config(route_name="delete_scheduled_action", request_method="POST")
|
|
@editor_required
|
|
def delete_scheduled_action_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
action_id = request.matchdict.get("action_id")
|
|
|
|
# Verify media exists
|
|
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 scheduled_actions table exists
|
|
try:
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
if "scheduled_actions" not in inspector.get_table_names():
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
except:
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
|
|
# Find and delete the scheduled action
|
|
scheduled_action = (
|
|
namespace_dbsession.query(ScheduledAction)
|
|
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
|
|
.first()
|
|
)
|
|
|
|
if not scheduled_action:
|
|
return Response("Scheduled action not found.", status=404)
|
|
|
|
namespace_dbsession.delete(scheduled_action)
|
|
namespace_dbsession.flush()
|
|
|
|
request.session.flash(
|
|
f"Scheduled {scheduled_action.action_type.replace('set_', '').replace('_', ' ')} action deleted."
|
|
)
|
|
|
|
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="edit_scheduled_action",
|
|
request_method="GET",
|
|
renderer="edit_scheduled_action.html.j2",
|
|
)
|
|
@editor_required
|
|
def edit_scheduled_action_get_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
action_id = request.matchdict.get("action_id")
|
|
|
|
# Verify media exists
|
|
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 scheduled_actions table exists
|
|
try:
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
if "scheduled_actions" not in inspector.get_table_names():
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
except:
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
|
|
# Find the scheduled action
|
|
scheduled_action = (
|
|
namespace_dbsession.query(ScheduledAction)
|
|
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
|
|
.first()
|
|
)
|
|
|
|
if not scheduled_action:
|
|
return Response("Scheduled action not found.", status=404)
|
|
|
|
return {
|
|
"request": request,
|
|
"namespace": namespace,
|
|
"media": media,
|
|
"scheduled_action": scheduled_action,
|
|
}
|
|
|
|
|
|
@view_config(route_name="edit_scheduled_action", request_method="POST")
|
|
@editor_required
|
|
def edit_scheduled_action_post_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
action_id = request.matchdict.get("action_id")
|
|
|
|
# Verify media exists
|
|
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 scheduled_actions table exists
|
|
try:
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
if "scheduled_actions" not in inspector.get_table_names():
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
except:
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
|
|
# Find the scheduled action
|
|
scheduled_action = (
|
|
namespace_dbsession.query(ScheduledAction)
|
|
.filter(ScheduledAction.id == action_id, ScheduledAction.media_id == media.id)
|
|
.first()
|
|
)
|
|
|
|
if not scheduled_action:
|
|
return Response("Scheduled action not found.", status=404)
|
|
|
|
# Update the scheduled action
|
|
new_action_type = request.POST.get("action_type")
|
|
new_scheduled_date = request.POST.get("scheduled_date")
|
|
|
|
if new_action_type not in ["set_public", "set_private", "set_unlisted", "delete"]:
|
|
return Response("Invalid action type.", status=400)
|
|
|
|
try:
|
|
scheduled_date = datetime.datetime.fromisoformat(new_scheduled_date)
|
|
except ValueError:
|
|
return Response("Invalid date format.", status=400)
|
|
|
|
scheduled_action.action_type = new_action_type
|
|
scheduled_action.scheduled_date = scheduled_date
|
|
namespace_dbsession.flush()
|
|
|
|
request.session.flash(
|
|
f"Scheduled action updated to {new_action_type.replace('set_', '').replace('_', ' ')} on {scheduled_date.strftime('%Y-%m-%d %H:%M')}."
|
|
)
|
|
|
|
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="add_scheduled_action",
|
|
request_method="GET",
|
|
renderer="add_scheduled_action.html.j2",
|
|
)
|
|
@editor_required
|
|
def add_scheduled_action_get_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
|
|
# Verify media exists
|
|
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 scheduled_actions table exists
|
|
try:
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
if "scheduled_actions" not in inspector.get_table_names():
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
except:
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
|
|
return {
|
|
"request": request,
|
|
"namespace": namespace,
|
|
"media": media,
|
|
}
|
|
|
|
|
|
@view_config(route_name="add_scheduled_action", request_method="POST")
|
|
@editor_required
|
|
def add_scheduled_action_post_view(request):
|
|
namespace = request.namespace
|
|
namespace_dbsession = request.namespace_dbsession
|
|
media_short_id = request.matchdict.get("media_short_id")
|
|
|
|
# Verify media exists
|
|
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 scheduled_actions table exists
|
|
try:
|
|
inspector = inspect(namespace_dbsession.bind)
|
|
if "scheduled_actions" not in inspector.get_table_names():
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
except:
|
|
return Response("Scheduled actions not supported.", status=404)
|
|
|
|
# Get form data
|
|
action_type = request.POST.get("action_type")
|
|
scheduled_date_str = request.POST.get("scheduled_date")
|
|
|
|
if action_type not in ["set_public", "set_private", "set_unlisted", "delete"]:
|
|
return Response("Invalid action type.", status=400)
|
|
|
|
try:
|
|
scheduled_date = datetime.datetime.fromisoformat(scheduled_date_str)
|
|
except ValueError:
|
|
return Response("Invalid date format.", status=400)
|
|
|
|
# Create new scheduled action
|
|
action_uuid = uuid.uuid4()
|
|
scheduled_action = ScheduledAction(
|
|
id=str(action_uuid),
|
|
media_id=media.id,
|
|
action_type=action_type,
|
|
scheduled_date=scheduled_date,
|
|
)
|
|
namespace_dbsession.add(scheduled_action)
|
|
namespace_dbsession.flush()
|
|
|
|
request.session.flash(
|
|
f"Scheduled {action_type.replace('set_', '').replace('_', ' ')} action added for {scheduled_date.strftime('%Y-%m-%d %H:%M')}."
|
|
)
|
|
|
|
return HTTPFound(
|
|
location=request.route_url(
|
|
"view_media_details",
|
|
namespace_short_id=namespace.short_id,
|
|
media_short_id=media.short_id,
|
|
)
|
|
)
|
|
|
|
|
|
################################################################################
|
|
# Main
|
|
################################################################################
|
|
|
|
|
|
def main(*config, **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
|
|
|
|
# Create tables if they don't exist
|
|
Base.metadata.create_all(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}",
|
|
)
|
|
|
|
# Scheduled Actions Management
|
|
config.add_route(
|
|
"delete_scheduled_action",
|
|
"/namespace/{namespace_short_id}/media/{media_short_id}/scheduled_action/{action_id}/delete",
|
|
)
|
|
config.add_route(
|
|
"edit_scheduled_action",
|
|
"/namespace/{namespace_short_id}/media/{media_short_id}/scheduled_action/{action_id}/edit",
|
|
)
|
|
config.add_route(
|
|
"add_scheduled_action",
|
|
"/namespace/{namespace_short_id}/media/{media_short_id}/add_scheduled_action",
|
|
)
|
|
|
|
config.scan()
|
|
|
|
# Start the scheduler (with feature flag)
|
|
disable_scheduler = os.environ.get('DISABLE_SCHEDULER', '').lower() in ('true', '1', 'yes')
|
|
if not disable_scheduler:
|
|
start_scheduler()
|
|
else:
|
|
log.info("Scheduler disabled by DISABLE_SCHEDULER environment variable")
|
|
|
|
return config.make_wsgi_app()
|
|
|
|
|
|
# Signal handler for graceful shutdown
|
|
def signal_handler(signum, frame):
|
|
log.info("Received shutdown signal, stopping scheduler...")
|
|
stop_scheduler()
|
|
sys.exit(0)
|
|
|
|
|
|
# Register signal handlers
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
|
|
# Expose the WSGI application callable for uwsgi
|
|
uwsgi_app = main({})
|
|
|
|
if __name__ == "__main__":
|
|
app = main()
|
|
log.info(f"Serving on http://{HOST}:{PORT}")
|
|
serve(app, host=HOST, port=PORT)
|