From 5c226f2a2ed9dfed8c2f28b86a3456494be2b195 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 12 Jan 2025 22:54:11 +0000 Subject: [PATCH] JWT and complete RBAC --- .gitignore | 1 + README.rst | 285 +++--- app.py | 1262 ++++++++++++++++++-------- initialize_db.py | 29 +- openapi.yaml | 638 ++++++++++--- requirements.txt | 2 + templates/base.html.j2 | 60 +- templates/create_namespace.html.j2 | 18 + templates/display_agent_jwt.html.j2 | 15 + templates/edit_media.html.j2 | 23 +- templates/home.html.j2 | 36 +- templates/list_media.html.j2 | 33 +- templates/manage_namespace.html.j2 | 107 +++ templates/profile.html.j2 | 22 +- templates/upload_media.html.j2 | 8 +- templates/view_media_details.html.j2 | 47 +- 16 files changed, 1803 insertions(+), 783 deletions(-) create mode 100644 templates/create_namespace.html.j2 create mode 100644 templates/display_agent_jwt.html.j2 create mode 100644 templates/manage_namespace.html.j2 diff --git a/.gitignore b/.gitignore index 63ad4cf..d0a045b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ env/ __pycache__/ cookies.txt +data/ diff --git a/README.rst b/README.rst index cfec1e7..7d57101 100644 --- a/README.rst +++ b/README.rst @@ -1,18 +1,25 @@ =========================================== -pyrafiles - A Sophisticated Pyramid Project +PyraFiles - A Sophisticated Pyramid Project =========================================== -``pyrafiles`` is a **public domain** application built on the `Pyramid `_ framework. It demonstrates a flexible, multi-database design where each verified user maintains their own SQLite database for media uploads (images, audio, video). The main database (``main.db``) stores system- and user-level data, while each user’s personal DB file handles their specific uploads. +**PyraFiles** is a **public domain** application built on the `Pyramid `_ framework. It demonstrates a flexible, multi-database design where each namespace (group of users and agents) maintains its own SQLite database for media uploads (images, audio, video). The main database (``main.db``) stores system-level data, including user accounts, namespaces, agents, and roles. + +PyraFiles supports both human users and agents: + +- **Users** authenticate via OTP (One-Time Password) email verification codes and interact through the web interface. +- **Agents** authenticate via JWT tokens and can interact programmatically with the application. Key Features ============ -- **Passwordless Login** using email verification codes. -- **Guest Sessions** for unverified visitors. -- **Per-user SQLite** databases for media uploads (limit ~30MB each). -- **Public/Private** media visibility controls. -- **Admin Tools** (import user records, manage site settings). -- **Agent-friendly** architecture: easily scriptable endpoints for login, verification, media uploads, etc. +- **Passwordless Login** using email verification codes (OTP). +- **Namespaces** to organize media files and control access. +- **Role-Based Access Control** with roles: *owner*, *editor*, and *reader*. +- **JWT Authentication** for agents, enabling programmatic access. +- **Per-Namespace SQLite Databases** for media uploads. +- **Public/Private** namespace and media visibility controls. +- **Admin Tools** for namespace and agent management. +- **OpenAPI Documentation** available for API interactions. - **Configurable** via environment variables (including secret keys). Git Repository @@ -20,22 +27,25 @@ Git Repository The project is maintained at: -- `pyrafiles Git Repo `_ +- `PyraFiles Git Repo `_ Since this is public domain, you can adapt and redistribute it freely. - Configuration via Environment ============================= -`pyrafiles` fetches settings from environment variables with sensible defaults: +PyraFiles fetches settings from environment variables with sensible defaults: - ``PYRAFILES_SECRET`` The secret key for session signing. - If missing, ``pyrafiles`` automatically generates a **random 64-character** string at runtime & will log out all users out. + If missing, PyraFiles automatically generates a **random 64-character** string at runtime, which will log out all users. + +- ``PYRAFILES_JWT_SECRET`` + The secret key used for signing JWT tokens for agents. + If missing, PyraFiles automatically generates a **random 64-character** string at runtime, which will invalidate existing agent tokens. - ``PYRAFILES_DB_URL`` - Connection string for the main database. Default: ``sqlite:///main.db``. + Connection string for the main database. Default: ``sqlite:///data/main.db``. - ``PYRAFILES_HOST`` and ``PYRAFILES_PORT`` The host and port to serve on. Defaults: ``0.0.0.0`` (host), ``6544`` (port). @@ -45,7 +55,6 @@ Configuration via Environment - Any other environment variables you wish to incorporate can be accessed in the code. - Local Setup =========== @@ -60,7 +69,7 @@ Local Setup .. code-block:: bash - python -m venv venv + python3 -m venv venv source venv/bin/activate # Linux/Mac # or venv\Scripts\activate.bat # Windows @@ -71,136 +80,156 @@ Local Setup pip install -r requirements.txt -4. **Run** - - Create the database if this is the first time running the applicaiton. +4. **Initialize the Database** .. code-block:: bash python initialize_db.py - Otherwise: + This creates or updates the main database file (``data/main.db``) and ensures all tables are set up. + +5. **Run the Application** .. code-block:: bash - # Optionally set PYRAFILES_SECRET if you want a custom secret. + # Optionally set PYRAFILES_SECRET and PYRAFILES_JWT_SECRET if you want custom secrets. export PYRAFILES_SECRET="YOUR_OWN_LONG_RANDOM_STRING" - python main.py + export PYRAFILES_JWT_SECRET="YOUR_OWN_LONG_RANDOM_STRING" + python app.py - If ``PYRAFILES_SECRET`` is **not** set, the app automatically generates a 64-char secret at runtime & log out all users. + If ``PYRAFILES_SECRET`` and ``PYRAFILES_JWT_SECRET`` are **not** set, the app automatically generates 64-character secrets at runtime, which will log out all users and invalidate existing JWT tokens. -5. **Access** +6. **Access the Application** Point your browser to `http://localhost:6544` or `http://:` according to your environment variables. +OTP Authentication +================== -Example: Agent Workflow Script -============================== +PyraFiles uses a passwordless login system for users: -First of all, everything is documented as OpenAPI for agentic flows. +1. **Login with Email** -* http://localhost:6544/docs -* http://localhost:6544/openapi.yaml + - Users enter their email address on the login page. + - A 6-digit verification code is sent to the provided email address. -Below is a sample Bash script showing how an **agent** might: +2. **Verify with Code** -1. Start the session (to get a cookie). -2. Log in with an email. -3. Prompt the user for a verification code. -4. Complete the verification. -5. Upload a file. + - Users enter the 6-digit code on the verification page. + - Upon successful verification, the user is logged in. + +3. **Session Management** + + - User sessions are managed via signed cookies. + - Sessions persist across browser restarts unless the server secret changes. + +JWT Agent Authentication +======================== + +Agents authenticate with PyraFiles using JWT tokens, allowing programmatic interaction: + +1. **Generate Agent JWT** + + - **Owners** of a namespace can generate JWT tokens for agents. + - Agents are assigned a role: *owner*, *editor*, or *reader*. + - Each agent has a unique token, which includes their role and namespace ID. + +2. **Authenticate with JWT** + + - Agents include the JWT in the `Authorization` header as a Bearer token: + + .. code-block:: http + + Authorization: Bearer + + - The token is verified on each request, and the agent's role is used for access control. + +3. **Access Control** + + - **Owner** agents can manage the namespace, including inviting users and creating other agents. + - **Editor** agents can upload, edit, and delete media within the namespace. + - **Reader** agents can view media if they have appropriate permissions. + +4. **Token Revocation** + + - Owners can revoke agent tokens, which invalidates the JWT. + +Example: Agent Workflow with JWT +================================ + +Below is a sample Bash script showing how an **agent** might upload a media file using a JWT token. .. code-block:: bash #!/usr/bin/env bash # # Usage: - # MAX_COOKIE_AGE=1800 ./agent_upload.sh /path/to/somefile.jpg + # ./agent_upload_jwt.sh /path/to/mediafile.jpg # # Description: - # Upload a file to the pyrafiles server as an agent, making it public. - # - If cookies.txt is newer than MAX_COOKIE_AGE seconds (default 31536000, ~1 year), - # we skip re-login. - # - Otherwise, we prompt for OTP again. - # - # Notes: - # 1. If not set, MAX_COOKIE_AGE defaults to 31536000 (one year). - # 2. We assume the server runs on http://localhost:6544. Adjust BASE_URL if needed. - + # Upload a media file to the PyraFiles server as an agent using a JWT token. + BASE_URL="${BASE_URL:-http://localhost:6544}" - EMAIL="${EMAIL:-agent@example.com}" + JWT_TOKEN="${JWT_TOKEN:-your_agent_jwt_token}" + NAMESPACE_SHORT_ID="${NAMESPACE_SHORT_ID:-your_namespace_short_id}" MEDIA_FILE="$1" - # Default to 1 year in seconds if not provided: - MAX_COOKIE_AGE="${MAX_COOKIE_AGE:-31536000}" - + if [[ -z "$MEDIA_FILE" ]]; then - echo "Usage: MAX_COOKIE_AGE= $0 /path/to/mediafile" + echo "Usage: $0 /path/to/mediafile.jpg" exit 1 fi - - # Check if cookies.txt is 'fresh enough': - COOKIE_FRESH=0 - if [[ -f cookies.txt ]]; then - # 'stat -c %Y' returns the file's mod time on Linux; 'stat -f %m' on macOS/BSD - MOD_TIME=$(stat -c %Y cookies.txt 2>/dev/null || stat -f %m cookies.txt 2>/dev/null) - NOW=$(date +%s) - AGE=$((NOW - MOD_TIME)) - - if (( AGE < MAX_COOKIE_AGE )); then - COOKIE_FRESH=1 - fi + + if [[ -z "$JWT_TOKEN" ]]; then + echo "Error: JWT_TOKEN environment variable is not set." + exit 1 fi - - if [[ "$COOKIE_FRESH" -eq 1 ]]; then - echo "Reusing existing cookies (file age < $MAX_COOKIE_AGE seconds)." - echo "Skipping OTP prompt." - else - echo "Starting new session (cookie missing or stale)..." - curl -s -c cookies.txt -b cookies.txt "$BASE_URL/" >/dev/null - - echo "Logging in with email: $EMAIL" - curl -s -c cookies.txt -b cookies.txt \ - -X POST \ - -F "email=$EMAIL" \ - "$BASE_URL/auth/login" - - echo "" - echo "Check your console or email for the 6-digit verification code." - read -p "Enter the 6-digit code: " VERIFICATION_CODE - - echo "Verifying code..." - curl -s -c cookies.txt -b cookies.txt \ - -X POST \ - -F "code=$VERIFICATION_CODE" \ - "$BASE_URL/auth/verify" - - echo "Cookies refreshed." + + if [[ -z "$NAMESPACE_SHORT_ID" ]]; then + echo "Error: NAMESPACE_SHORT_ID environment variable is not set." + exit 1 fi - - echo "" - echo "Uploading media: $MEDIA_FILE (public)" - curl -s -c cookies.txt -b cookies.txt \ + + echo "Uploading media: $MEDIA_FILE" + curl -s \ -X POST \ + -H "Authorization: Bearer $JWT_TOKEN" \ -F "media_file=@$MEDIA_FILE" \ -F "is_public=on" \ - "$BASE_URL/media/upload" - + "$BASE_URL/namespace/$NAMESPACE_SHORT_ID/media/upload" + echo "" - echo "Upload complete. File is public." + echo "Upload complete." +OpenAPI Documentation +===================== +PyraFiles provides comprehensive OpenAPI documentation for all endpoints, making it easier to integrate agents and other services. -Dockerfile with Caddy + uWSGI -============================= +- **Swagger UI Documentation** + + Access the interactive API documentation at: + + - http://localhost:6544/docs + +- **OpenAPI Specification** + + Download the OpenAPI YAML file: + + - http://localhost:6544/openapi.yaml + +Docker Deployment +================= Below is an example Dockerfile that: -- Uses **uWSGI** to run ``pyrafiles``. +- Uses **uWSGI** to run PyraFiles. - Uses **Caddy** as a reverse proxy (and optionally HTTPS if configured). - Defines a **volume** for databases (so they are stored on the host). .. code-block:: dockerfile + # Dockerfile for PyraFiles + ########################################################### # Stage 1: Build the Python environment ########################################################### @@ -217,7 +246,6 @@ Below is an example Dockerfile that: ########################################################### FROM caddy:2-alpine - # Install Python, pip, and uWSGI from Alpine RUN apk add --no-cache python3 py3-pip uwsgi-python3 WORKDIR /app @@ -226,37 +254,25 @@ Below is an example Dockerfile that: COPY --from=builder /app /app # Copy Caddyfile for the reverse proxy - # (Assuming you have a Caddyfile in your repo root) COPY Caddyfile /etc/caddy/Caddyfile - # Copy uWSGI config if desired - # For example (assume you created uwsgi.ini in your repo): - # [uwsgi] - # module = main:app - # master = true - # processes = 4 - # socket = 127.0.0.1:8080 - # vacuum = true - # die-on-term = true + # Copy uWSGI configuration COPY uwsgi.ini /app/uwsgi.ini - # Environment variables (optional overrides). - # If PYRAFILES_SECRET is empty, the app itself generates a random 64-char secret & logs all users out. + # Set environment variables ENV PYRAFILES_SECRET="" + ENV PYRAFILES_JWT_SECRET="" ENV PYRAFILES_DB_URL="sqlite:///data/main.db" ENV PYRAFILES_HOST="0.0.0.0" ENV PYRAFILES_PORT="6544" # Expose HTTP and HTTPS - EXPOSE 80 - EXPOSE 443 + EXPOSE 80 443 - # Define volume so host can persist user DBs outside the container - # We'll store DB files in /data + # Define volume for data VOLUME ["/data"] - # Command starts uWSGI and then starts Caddy - # The app itself checks for PYRAFILES_SECRET and auto-generates one if missing. + # Start uWSGI and Caddy CMD ["/bin/sh", "-c", "\ uwsgi --ini /app/uwsgi.ini & \ caddy run --config /etc/caddy/Caddyfile \ @@ -264,45 +280,32 @@ Below is an example Dockerfile that: .. note:: - - We use ``/data`` as the volume. By default, the environment variable ``PYRAFILES_DB_URL`` is set to ``sqlite:///data/main.db``, so the main DB (and any user DB files) go inside ``/data``. - - For user DB files, your app can also interpret an environment variable (like ``PYRAFILES_DB_BASEPATH=/data``) if you want to make that path configurable in the code. - - Make sure to **mount** a volume at ``/data`` when you run the container, e.g.: - - .. code-block:: bash - - docker run -d \ - -p 80:80 -p 443:443 \ - -v /my/local/dbfolder:/data \ - --name pyrafiles \ - pyrafiles-image:latest - - - With that, any user database is stored in ``/my/local/dbfolder`` on the host. - + - The ``/data`` volume is used to persist database files. + - Ensure you mount a volume at ``/data`` when running the container. + - Set ``PYRAFILES_SECRET`` and ``PYRAFILES_JWT_SECRET`` to persistent values in a production environment. Tips ==== -- For **production**, you likely want to set up a real SMTP server or third-party service (e.g. Mailgun, ImprovMV) and configure it via environment variables (``PYRAFILES_SMTP_HOST``, etc.). -- Ensure you mount a volume for persistent SQLite files if you want to avoid data loss when containers are removed or replaced. -- Because the project is **public domain**, you can adapt it without restriction, removing features, adding custom logic, etc. - +- For **production**, set up a real SMTP server or use a third-party service (e.g., Mailgun) and configure it via environment variables (``PYRAFILES_SMTP_HOST``, etc.). +- **Persist Secrets**: In production, set ``PYRAFILES_SECRET`` and ``PYRAFILES_JWT_SECRET`` to persistent values to avoid invalidating sessions and JWT tokens. +- **Mount Data Volume**: Mount the ``/data`` directory to persist database files and avoid data loss. +- **OpenAPI Integration**: Use the provided OpenAPI specification to generate client code or integrate with other services. License and Public Domain ========================= -This project is in the public domain. You are free to use, adapt, and redistribute it without attribution or additional licensing. - -If you find `pyrafiles` helpful, feel free to contribute back or share your enhancements! +This project is in the public domain. You are free to use, adapt, and redistribute it without attribution or additional licensing. +If you find PyraFiles helpful, feel free to contribute back or share your enhancements! Support and Contact =================== -- Issues: Please open tickets at the `git.unturf.com project page `_. -- For general inquiries, you can reach out to the maintainers directly. +- **Issues**: Please open tickets at the `PyraFiles Git Repo `_. +- **Contributions**: If you register an account, let us know, and we will grant you developer access to contribute. +- **General Inquiries**: Reach out to the maintainers directly. -We hope `pyrafiles` helps you get up and running quickly with a flexible media-sharing and multi-DB infrastructure! - -If you register an account, let me know and I will bless you as an developer to contribute. +We hope PyraFiles helps you get up and running quickly with a flexible media-sharing and multi-database infrastructure! Enjoy and happy building! diff --git a/app.py b/app.py index 8991ce8..c417959 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ ############################################################################### -# app.py - Full pyrafiles Application with Unicode-safe Content-Disposition +# app.py - PyraFiles Application with Proper Agent and User Separation ############################################################################### import os import base64 @@ -20,7 +20,7 @@ from email.mime.text import MIMEText from pyramid.config import Configurator from pyramid.view import view_config from pyramid.response import Response -from pyramid.httpexceptions import HTTPFound, HTTPForbidden +from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound from pyramid.session import SignedCookieSessionFactory from sqlalchemy import ( create_engine, @@ -30,10 +30,13 @@ from sqlalchemy import ( Boolean, Integer, Index, + ForeignKey, + Text, + or_, ) from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker -from sqlalchemy.orm import scoped_session +from sqlalchemy.orm import scoped_session, relationship from sqlalchemy.pool import StaticPool from waitress import serve @@ -45,27 +48,76 @@ 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 ################################################################################ -# If PYRAFILES_SECRET is missing, generate a random 64-character secret. -pyrafiles_secret = os.environ.get("PYRAFILES_SECRET", "") -if not pyrafiles_secret: - pyrafiles_secret = "".join( - random.choices(string.ascii_letters + string.digits, k=64) - ) - log.info(f"Generated random PYRAFILES_SECRET: {pyrafiles_secret}") +# 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 -APP_DIR = os.path.dirname(os.path.abspath(__file__)) -default_main_db_url = f"sqlite:///{os.path.join(APP_DIR, 'main.db')}" +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 @@ -95,11 +147,11 @@ def get_gravatar_url(email, size=100): def send_email(to_email, subject, body): - log.debug("======= Email Sent =======") - log.debug(f"To: {to_email}") - log.debug(f"Subject: {subject}") - log.debug(f"Body:\n{body}") - log.debug("==========================") + 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 @@ -114,61 +166,6 @@ def send_email(to_email, subject, body): log.error(f"Error sending email: {e}") -def admin_required(view_func): - def wrapper(request): - user = request.user - if not user or not user.is_admin: - return HTTPForbidden("You must be an admin to access this page.") - return view_func(request) - - return wrapper - - -def get_current_user(request): - """Return the current user (authenticated or guest) from session.""" - user_id = request.session.get("user_id") - s = request.dbsession - - if user_id: - # Try to get the user from the database - user = s.query(User).filter_by(id=user_id).first() - if user: - return user - else: - # User ID in session does not exist in the database; remove it - del request.session["user_id"] - - # No valid user in session; create a guest user - # Generate a unique guest username - while True: - suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8)) - guest_username = f"Guest-{suffix}" - existing_user = s.query(User).filter_by(username=guest_username).first() - if not existing_user: - break # Unique username found - - # Generate UUID and short ID - user_uuid = uuid.uuid4() - new_user_id = str(user_uuid) - short_id = uuid_to_short_id(user_uuid) - - # Create a new guest user - guest_user = User( - id=new_user_id, - short_id=short_id, - email=None, # Guests don't have an email - username=guest_username, - is_verified=False, - ) - s.add(guest_user) - s.flush() # Use flush instead of commit in pyramid_tm - - # Store the user ID in the session - request.session["user_id"] = guest_user.id - - return guest_user - - def get_mime_type(filename): # Guess the MIME type based on the file extension mime_type, _ = mimetypes.guess_type(filename) @@ -193,27 +190,16 @@ def short_id_to_uuid(sid): except Exception: continue - log.error( - f"Failed to convert short_id {sid} to UUID after trying all padding lengths" - ) + log.error(f"Failed to convert short_id {sid} to UUID after trying all paddings") return None -def get_user_db_url(user_id): - """Return the database URL for the user's SQLite database, in the same directory.""" - db_file = os.path.join(APP_DIR, f"user_{user_id}.db") +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 filesizeformat(value): - """Returns the human-readable file size.""" - for unit in ["bytes", "KB", "MB", "GB", "TB"]: - if value < 1024.0: - return f"{value:.2f} {unit}" - value /= 1024.0 - return f"{value:.2f} PB" - - def sanitize_filename_for_http_header(filename): """ Ensure that the filename is safe for Waitress (Latin-1 headers). @@ -228,6 +214,43 @@ def sanitize_filename_for_http_header(filename): 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 ################################################################################ @@ -237,44 +260,110 @@ 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) # Guests don't have an email. - username = Column(String, unique=True, nullable=False) # user-chosen handle + 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 - __table_args__ = ( - Index("ix_users_id", "id"), - Index("ix_users_short_id", "short_id"), + # 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"" -class Media(Base): +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"" + + +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"" + ) + + +################################################################################ +# 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) - user_id = Column(String, nullable=False) # Owner's UUID filename = Column(String, nullable=False) title = Column(String, nullable=True) # Optional title media_type = Column(String, nullable=False) # 'image', 'audio', 'video' - media_b64 = Column(String, nullable=False) + 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 - __table_args__ = ( - Index("ix_media_id", "id"), - Index("ix_media_short_id", "short_id"), - ) + def __repr__(self): + return f"" ################################################################################ @@ -293,46 +382,217 @@ def add_jinja2_filters(event): ################################################################################ -def add_user_dbsession(request): - """Adds user_dbsession to request for verified users.""" - if request.user and request.user.is_verified: - user_dbsession = get_user_dbsession_by_user_id(request.user.id, request) - return user_dbsession +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 # Guests do not have user_dbsession + return None # No namespace selected -def get_user_dbsession_by_user_id(user_id, request): - """Helper function to get a user_dbsession for a given user_id.""" - user_db_url = get_user_db_url(user_id) - db_file = os.path.join(APP_DIR, f"user_{user_id}.db") +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): - return None # User database does not exist - user_engine = create_engine( - user_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool + # 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, ) - UserSessionFactory = sessionmaker(bind=user_engine) - user_dbsession = scoped_session(UserSessionFactory) - register(user_dbsession) # Register with zope.sqlalchemy + NamespaceSessionFactory = sessionmaker(bind=namespace_engine) + namespace_dbsession = scoped_session(NamespaceSessionFactory) + register(namespace_dbsession) # Register with zope.sqlalchemy # Attach cleanup callbacks def cleanup(_request): - user_dbsession.remove() - user_engine.dispose() + namespace_dbsession.remove() + namespace_engine.dispose() request.add_finished_callback(cleanup) - return user_dbsession + 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 +# 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, } @@ -436,25 +696,17 @@ def verify_post_view(request): request.session["user_id"] = user.id - # Create user database upon verification - user_db_url = get_user_db_url(user.id) - user_engine = create_engine( - user_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool - ) - Base.metadata.create_all(user_engine) # Create tables in user's database - user_engine.dispose() - return HTTPFound(location=request.route_url("home")) -@view_config(route_name="logout") +@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 Media Upload +# Profile and Namespace Management ################################################################################ @@ -465,22 +717,17 @@ def profile_get_view(request): return Response("You must be logged in to access your profile.", status=403) gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else "" - # Initialize stats - total_uploads = 0 - total_size = 0 - - user_dbsession = request.user_dbsession - if user_dbsession: - media_items = user_dbsession.query(Media).all() - total_uploads = len(media_items) - total_size = sum(media.size for media in media_items) + # 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, - "total_uploads": total_uploads, - "total_size": total_size, + "owner_namespaces": owner_namespaces, } @@ -509,108 +756,387 @@ def profile_post_view(request): return HTTPFound(location=request.route_url("profile")) -@view_config(route_name="download_database") -def download_database_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to download your database.", status=403) - db_file = os.path.join(APP_DIR, f"user_{user.id}.db") - if not os.path.exists(db_file): - return Response("Database file not found.", status=404) - with open(db_file, "rb") as f: - data = f.read() - - # Safely build a filename - download_filename = sanitize_filename_for_http_header(f"user_{user.id}.db") - - response = Response(body=data, content_type="application/octet-stream") - response.headers[ - "Content-Disposition" - ] = f'attachment; filename="{download_filename}"' - return response - - -################################################################################ -# User Record Export (Users) and Import (Admins) -################################################################################ - - -@view_config(route_name="export_user_record") -def export_user_record_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to export your user record.", status=403) - - # Create a JSON representation of the user record - user_record = { - "id": user.id, - "short_id": user.short_id, - "email": user.email, - "username": user.username, - "enable_gravatar": user.enable_gravatar, - "is_admin": user.is_admin, - } - - # Convert to JSON string - user_json = json.dumps(user_record).encode("utf-8") - - response = Response(body=user_json, content_type="application/json") - # Plain ASCII filename is safe - response.headers["Content-Disposition"] = 'attachment; filename="user_record.json"' - return response - - @view_config( - route_name="import_user_record", + route_name="create_namespace", request_method="GET", - renderer="import_user_record.html.j2", + renderer="create_namespace.html.j2", ) -@admin_required -def import_user_record_get_view(request): +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="import_user_record", request_method="POST") -@admin_required -def import_user_record_post_view(request): - user_record_file = request.POST.get("user_record_file") - if ( - user_record_file is None - or not getattr(user_record_file, "filename", "").strip() - ): - return Response("No user record file uploaded.", status=400) +@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) - # Read and parse the JSON data - try: - user_record_data = user_record_file.file.read() - user_record = json.loads(user_record_data) - except Exception as e: - return Response(f"Invalid user record file: {e}", status=400) + 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) - # Check if user already exists - existing_user = s.query(User).filter_by(id=user_record["id"]).first() - if existing_user: - return Response("User already exists in the database.", status=400) + # 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) - # Create new user with provided data - user = User( - id=user_record["id"], - short_id=user_record["short_id"], - email=user_record["email"], - username=user_record["username"], - enable_gravatar=user_record.get("enable_gravatar", False), - is_admin=user_record.get("is_admin", False), - is_verified=True, # Assume verified + 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(user) + s.add(namespace) + + # Add the user as an owner + association = NamespaceUserAssociation( + namespace=namespace, + user=user, + role="owner", + ) + s.add(association) s.flush() - # Do not create user database upon import + # 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() - request.session.flash(f"User {user.username} imported successfully.") - return HTTPFound(location=request.route_url("home")) + 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 + ) + ) ################################################################################ @@ -619,22 +1145,20 @@ def import_user_record_post_view(request): @view_config( - route_name="upload_media", request_method="GET", renderer="upload_media.html.j2" + route_name="upload_media", + request_method="GET", + renderer="upload_media.html.j2", ) +@editor_required def upload_media_get_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to upload media.", status=403) return {"request": request} @view_config(route_name="upload_media", request_method="POST") +@editor_required def upload_media_post_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to upload media.", status=403) - - user_dbsession = request.user_dbsession # Assume this exists for verified users + 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(): @@ -675,7 +1199,6 @@ def upload_media_post_view(request): media = Media( id=media_id, short_id=media_short_id, - user_id=user.id, filename=filename, title=title, # now has a fallback of filename media_type=media_type, @@ -683,13 +1206,13 @@ def upload_media_post_view(request): is_public=is_public, size=file_size, ) - user_dbsession.add(media) - user_dbsession.flush() + namespace_dbsession.add(media) + namespace_dbsession.flush() return HTTPFound( location=request.route_url( "view_media_details", - user_short_id=user.short_id, + namespace_short_id=namespace.short_id, media_short_id=media.short_id, ) ) @@ -697,179 +1220,97 @@ def upload_media_post_view(request): @view_config(route_name="list_media", renderer="list_media.html.j2") def list_media_view(request): - # Aggregate public media from all verified users - s = request.dbsession - users = s.query(User).filter(User.is_verified == True).all() - media_list = [] - for user in users: - user_dbsession = get_user_dbsession_by_user_id(user.id, request) - if not user_dbsession: - continue - media_items = user_dbsession.query(Media).filter(Media.is_public == True).all() - for m in media_items: - media_list.append( - { - "media": m, - "username": user.username, - "user_short_id": user.short_id, - } - ) + namespace = request.namespace + namespace_dbsession = request.namespace_dbsession - # Sort media by upload date (recent first) - media_list.sort(key=lambda x: x["media"].upload_date, reverse=True) + # 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, - "media_list": media_list, + "namespace": namespace, + "media_items": media_items, } -@view_config(route_name="user_media", renderer="user_media.html.j2") -def user_media_view(request): - user_short_id = request.matchdict.get("user_short_id") - log.debug(f"Looking up user with short_id: {user_short_id}") - - try: - user_uuid = short_id_to_uuid(user_short_id) - if not user_uuid: - log.error(f"Could not convert short_id {user_short_id} to UUID") - return Response("User not found.", status=404) - - s = request.dbsession - user = s.query(User).filter_by(id=str(user_uuid)).first() - log.debug(f"User found: {user}") - - if not user: - return Response("User not found.", status=404) - - viewer = request.user - is_owner = viewer and viewer.id == user.id - - user_dbsession = get_user_dbsession_by_user_id(user.id, request) - if not user_dbsession: - return Response("User has no uploads.", status=404) - - if is_owner: - media_items = user_dbsession.query(Media).all() - else: - media_items = ( - user_dbsession.query(Media).filter(Media.is_public == True).all() - ) - - media_items.sort(key=lambda m: m.upload_date, reverse=True) - - return { - "request": request, - "media_items": media_items, - "user": user, - "is_owner": is_owner, - } - - except Exception as e: - log.exception(f"Error processing user_short_id {user_short_id}: {e}") - return Response("Error processing request.", status=500) - - @view_config(route_name="view_media_details", renderer="view_media_details.html.j2") def view_media_details_view(request): + namespace = request.namespace + namespace_dbsession = request.namespace_dbsession media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - if not user_short_id or not media_short_id: - return Response("Invalid request.", status=400) - s = request.dbsession - user = s.query(User).filter_by(short_id=user_short_id).first() - if not user: - return Response("User not found.", status=404) - user_id = user.id - - user_dbsession = get_user_dbsession_by_user_id(user_id, request) - if not user_dbsession: - return Response("User database not found.", status=404) - - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) - viewer = request.user - is_owner = viewer and viewer.id == user_id + role = get_user_or_agent_namespace_role(request) + is_owner_or_editor = role in ["owner", "editor"] - if not media.is_public and not is_owner: + # 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, - "username": user.username, - "user_short_id": user.short_id, - "is_owner": is_owner, + "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") - user_short_id = request.matchdict.get("user_short_id") - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to delete this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) - user_dbsession.delete(media) - user_dbsession.flush() + namespace_dbsession.delete(media) + namespace_dbsession.flush() return HTTPFound( - location=request.route_url("user_media", user_short_id=viewer.short_id) + 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") - user_short_id = request.matchdict.get("user_short_id") - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to edit this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + 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") - user_short_id = request.matchdict.get("user_short_id") - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to edit this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) @@ -901,12 +1342,12 @@ def edit_media_post_view(request): is_public = request.POST.get("is_public") == "on" media.is_public = is_public - user_dbsession.flush() + namespace_dbsession.flush() return HTTPFound( location=request.route_url( "view_media_details", - user_short_id=viewer.short_id, + namespace_short_id=namespace.short_id, media_short_id=media.short_id, ) ) @@ -919,29 +1360,16 @@ def edit_media_post_view(request): @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") - user_short_id = request.matchdict.get("user_short_id") - if not user_short_id or not media_short_id: - return Response("Invalid request.", status=400) - s = request.dbsession - user = s.query(User).filter_by(short_id=user_short_id).first() - if not user: - return Response("User not found.", status=404) - user_id = user.id - - user_dbsession = get_user_dbsession_by_user_id(user_id, request) - if not user_dbsession: - return Response("User database not found.", status=404) - - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + media = namespace_dbsession.query(Media).filter_by(short_id=media_short_id).first() if not media: return Response("Media not found.", status=404) - viewer = request.user - is_owner = viewer and viewer.id == user_id - - if not media.is_public and not is_owner: + # 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) @@ -979,11 +1407,11 @@ def view_media_view(request): def main(global_config=None, **settings): # Configure logging - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig(level=logging.INFO) # Set up the session factory session_factory = SignedCookieSessionFactory( - secret=pyrafiles_secret, + secret=app_secret, hashalg="sha512", timeout=31104000, # Approx. one year in seconds max_age=31104000, # Set Max-Age attribute on cookie @@ -998,14 +1426,14 @@ def main(global_config=None, **settings): settings["sqlalchemy.url"] = DB_URL config = Configurator(settings=settings, session_factory=session_factory) - config.include("pyramid_jinja2") - config.include("pyramid_tm") # Include pyramid_tm for transaction management + 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") @@ -1013,16 +1441,16 @@ def main(global_config=None, **settings): # Set up SQLAlchemy engine = create_engine( - DB_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool + DB_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, ) session_factory_ = sessionmaker(bind=engine) Base.metadata.bind = engine DBSession = scoped_session(session_factory_) - register(DBSession) # Register with zope.sqlalchemy - - # Add user to all requests - config.add_request_method(callable=get_current_user, name="user", reify=True) + # Register with zope.sqlalchemy + register(DBSession) # Provide dbsession to requests def dbsession(request): @@ -1030,8 +1458,18 @@ def main(global_config=None, **settings): config.add_request_method(dbsession, "dbsession", reify=True) - # Add user_dbsession to requests for verified users - config.add_request_method(add_user_dbsession, "user_dbsession", reify=True) + # 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", "/") @@ -1042,23 +1480,41 @@ def main(global_config=None, **settings): config.add_route("logout", "/auth/logout") config.add_route("profile", "/auth/profile") - # Export and Import User Record (Admin only for import) - config.add_route("export_user_record", "/auth/export_user_record") - config.add_route("import_user_record", "/admin/import_user_record") + # 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", "/media/upload") - config.add_route("list_media", "/media/list") - config.add_route("user_media", "/media/user/{user_short_id}") + 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", "/media/{user_short_id}/{media_short_id}/details" + "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.add_route("edit_media", "/media/{user_short_id}/{media_short_id}/edit") - config.add_route("delete_media", "/media/{user_short_id}/{media_short_id}/delete") - config.add_route("view_media", "/media/{user_short_id}/{media_short_id}") - - # Database Download - config.add_route("download_database", "/auth/download_db") config.scan() return config.make_wsgi_app() diff --git a/initialize_db.py b/initialize_db.py index 0c45815..85feae0 100644 --- a/initialize_db.py +++ b/initialize_db.py @@ -1,14 +1,14 @@ #!/usr/bin/env python """ -Initialize or upgrade the main database for pyrafiles. +Initialize or upgrade the main database for PyraFiles. Usage: python initialize_db.py Environment Variables: PYRAFILES_DB_URL (optional): - The SQLAlchemy database URL (e.g., "sqlite:///main.db"). - Defaults to "sqlite:///main.db" if not set. + The SQLAlchemy database URL (e.g., "sqlite:///data/main.db"). + Defaults to "sqlite:///data/main.db" if not set. Description: Creates or updates all tables referenced by `Base.metadata`. @@ -24,11 +24,8 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool -# Adjust these imports to your actual project structure: -# For example, if your models and Base are defined in "models.py", do: -# from pyrafiles import Base -# If you keep Base in a separate module, adjust accordingly. -from app import Base # or from your_project.models import Base +# Import Base and DATA_DIR from your app.py +from app import Base, DATA_DIR # Adjust the import if needed def usage(): script = os.path.basename(sys.argv[0]) @@ -39,12 +36,19 @@ def usage(): def main(): logging.basicConfig(level=logging.INFO) - if len(sys.argv) > 2: - # We only expect optional arguments. If needed, parse them here. + if len(sys.argv) > 1: + # We do not expect any arguments usage() - # Read environment variable for DB URL - db_url = os.environ.get("PYRAFILES_DB_URL", "sqlite:///main.db") + # Ensure DATA_DIR exists + if not os.path.exists(DATA_DIR): + os.makedirs(DATA_DIR) + logging.info(f"Created data directory at {DATA_DIR}") + + # Set default database URL to use the data directory + default_db_url = f"sqlite:///{os.path.join(DATA_DIR, 'main.db')}" + # Read environment variable for DB URL or use default + db_url = os.environ.get("PYRAFILES_DB_URL", default_db_url) logging.info(f"Using DB URL: {db_url}") # Set up engine @@ -65,4 +69,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/openapi.yaml b/openapi.yaml index d3fc9ed..49648f8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4,22 +4,23 @@ info: version: 1.0.0 description: | API specification for the PyraFiles application. - PyraFiles allows users to register, authenticate, upload media files, - and manage their media content. + PyraFiles allows users to register, authenticate, manage namespaces, + generate JWT tokens for agents, and manage media files within namespaces. + servers: - - url: http://localhost:{port} + - url: http://127.0.0.1:{port} description: Local development server variables: port: default: '6544' - - url: https://upload.unturf.com - description: prod for humans & agents to mingle. + - url: https://files.example.com + description: Production server paths: /: get: summary: Home Page - description: Displays the home page. + description: Displays the home page with public namespaces and user namespaces. responses: '200': description: Successful response @@ -57,10 +58,13 @@ paths: responses: '302': description: Redirects to the verification page + headers: + Location: + description: URL of the verification page + schema: + type: string '400': description: Bad Request (e.g., email missing) - '500': - description: Internal Server Error /auth/verify: get: @@ -90,23 +94,44 @@ paths: responses: '302': description: Redirects to the home page upon successful verification + headers: + Location: + description: URL of the home page + schema: + type: string '400': description: Bad Request (e.g., invalid code) - '500': - description: Internal Server Error /auth/logout: - get: + post: summary: Logout User description: Logs out the current user. + security: + - sessionAuth: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + csrf_token: + type: string responses: '302': description: Redirects to the home page + headers: + Location: + description: URL of the home page + schema: + type: string + '403': + description: Unauthorized (user not logged in) /auth/profile: get: summary: Display User Profile - description: Shows the user's profile, including upload stats. + description: Shows the user's profile, including owned namespaces. security: - sessionAuth: [] responses: @@ -135,109 +160,408 @@ paths: enum: ['on'] new_username: type: string + encoding: + enable_gravatar: + contentType: text/plain + new_username: + contentType: text/plain responses: '302': description: Redirects to the profile page + headers: + Location: + description: URL of the profile page + schema: + type: string '400': description: Bad Request (e.g., username already in use) '403': - description: Unauthorized (user not logged in or guest mode) + description: Unauthorized (user not logged in) - /auth/download_db: + /namespace/create: get: - summary: Download User Database - description: Allows the user to download their personal database file. + summary: Display Namespace Creation Page + description: Renders the form to create a new namespace. security: - sessionAuth: [] responses: '200': - description: Database file downloaded - content: - application/octet-stream: - schema: - type: string - format: binary - '403': - description: Unauthorized (user not logged in or unverified) - '404': - description: Database file not found - - /auth/export_user_record: - get: - summary: Export User Record - description: Exports the user's record as a JSON file. - security: - - sessionAuth: [] - responses: - '200': - description: User record JSON file downloaded - content: - application/json: - schema: - type: object - '403': - description: Unauthorized (user not logged in or unverified) - - /admin/import_user_record: - get: - summary: Display Import User Record Page - description: Renders a page to import a user record (Admin only). - security: - - sessionAuth: [] - responses: - '200': - description: Import user record page rendered + description: Namespace creation page rendered content: text/html: schema: type: string '403': - description: Forbidden (user not admin) + description: Unauthorized (user not logged in) post: - summary: Import User Record - description: Processes uploaded user record file and imports the user (Admin only). + summary: Create Namespace + description: Processes the namespace creation form. security: - sessionAuth: [] requestBody: required: true content: - multipart/form-data: + application/x-www-form-urlencoded: schema: type: object properties: - user_record_file: + name: type: string - format: binary - required: - - user_record_file + is_public: + type: string + enum: ['on'] + encoding: + name: + contentType: text/plain + is_public: + contentType: text/plain responses: '302': - description: Redirects to the home page after successful import + description: Redirects to the namespace management page + headers: + Location: + description: URL of the namespace management page + schema: + type: string '400': - description: Bad Request (e.g., invalid file) + description: Bad Request (e.g., namespace name already exists) '403': - description: Forbidden (user not admin) + description: Unauthorized (user not logged in) - /media/upload: + /namespace/{namespace_short_id}/manage: get: - summary: Display Media Upload Page - description: Renders the media upload form. + summary: Manage Namespace (Owners Only) + description: Displays the namespace management page. **Requires 'owner' role.** security: - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + description: The short ID of the namespace responses: '200': - description: Upload media page rendered + description: Namespace management page rendered content: text/html: schema: type: string '403': - description: Unauthorized (user not logged in or unverified) + description: Forbidden (not owner or not logged in) + '404': + description: Namespace not found + + /namespace/{namespace_short_id}/update: post: - summary: Upload Media - description: Processes the uploaded media file. + summary: Update Namespace (Owners Only) + description: Updates namespace properties. **Requires 'owner' role.** security: - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + description: The short ID of the namespace + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + is_public: + type: string + enum: ['on'] + description: 'Checkbox value. Present if checked.' + encoding: + is_public: + contentType: text/plain + responses: + '302': + description: Redirects to the namespace management page + headers: + Location: + description: URL of the namespace management page + schema: + type: string + '403': + description: Forbidden (not owner or not logged in) + '404': + description: Namespace not found + + /namespace/{namespace_short_id}/invite: + post: + summary: Invite User to Namespace (Owners Only) + description: Invites a user to the namespace with a specified role. **Requires 'owner' role.** + security: + - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + description: The short ID of the namespace + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + email: + type: string + format: email + role: + type: string + enum: ['owner', 'editor', 'reader'] + required: + - email + - role + encoding: + email: + contentType: text/plain + role: + contentType: text/plain + responses: + '302': + description: Redirects to the namespace management page + headers: + Location: + description: URL of the namespace management page + schema: + type: string + '400': + description: Bad Request (e.g., invalid role) + '403': + description: Forbidden (not owner or not logged in) + '404': + description: Namespace not found + + /namespace/{namespace_short_id}/remove_user: + post: + summary: Remove User from Namespace (Owners Only) + description: Removes a user from the namespace. **Requires 'owner' role.** + security: + - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + description: The short ID of the namespace + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + user_id: + type: string + required: + - user_id + encoding: + user_id: + contentType: text/plain + responses: + '302': + description: Redirects to the namespace management page + headers: + Location: + description: URL of the namespace management page + schema: + type: string + '400': + description: Bad Request (e.g., cannot remove self) + '403': + description: Forbidden (not owner or not logged in) + '404': + description: Namespace or user not found + + /namespace/{namespace_short_id}/change_member_role: + post: + summary: Change Member Role in Namespace (Owners Only) + description: Changes a member's role in the namespace. **Requires 'owner' role.** + security: + - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + description: The short ID of the namespace + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + user_id: + type: string + role: + type: string + enum: ['owner', 'editor', 'reader'] + required: + - user_id + - role + encoding: + user_id: + contentType: text/plain + role: + contentType: text/plain + responses: + '302': + description: Redirects to the namespace management page + headers: + Location: + description: URL of the namespace management page + schema: + type: string + '400': + description: Bad Request (e.g., cannot change own role) + '403': + description: Forbidden (not owner or not logged in) + '404': + description: Namespace or user not found + + /namespace/{namespace_short_id}/generate_agent_jwt: + post: + summary: Generate Agent JWT (Owners Only) + description: Generates a JWT token for an agent. **Requires 'owner' role.** + security: + - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + description: The short ID of the namespace + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + agent_name: + type: string + agent_role: + type: string + enum: ['owner', 'editor', 'reader'] + required: + - agent_name + - agent_role + encoding: + agent_name: + contentType: text/plain + agent_role: + contentType: text/plain + responses: + '200': + description: Agent JWT generated and displayed + content: + text/html: + schema: + type: string + '400': + description: Bad Request (e.g., agent name missing) + '403': + description: Forbidden (not owner or not logged in) + '404': + description: Namespace not found + + /namespace/{namespace_short_id}/revoke_agent: + post: + summary: Revoke Agent (Owners Only) + description: Revokes an agent's access by invalidating their JWT. **Requires 'owner' role.** + security: + - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + description: The short ID of the namespace + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + agent_id: + type: string + required: + - agent_id + encoding: + agent_id: + contentType: text/plain + responses: + '302': + description: Redirects to the namespace management page + headers: + Location: + description: URL of the namespace management page + schema: + type: string + '400': + description: Bad Request (e.g., agent ID missing) + '403': + description: Forbidden (not owner or not logged in) + '404': + description: Namespace or agent not found + + /namespace/{namespace_short_id}/media/upload: + get: + summary: Display Media Upload Page (Editors and Owners) + description: Renders the media upload form. **Requires 'editor' or 'owner' role.** + security: + - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string + responses: + '200': + description: Media upload page rendered + content: + text/html: + schema: + type: string + '403': + description: Forbidden (insufficient permissions) + post: + summary: Upload Media (Editors and Owners) + description: Uploads a media file to the namespace. **Requires 'editor' or 'owner' role.** + security: + - sessionAuth: [] + - agentAuth: [] + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string requestBody: required: true content: @@ -258,15 +582,32 @@ paths: responses: '302': description: Redirects to the media details page + headers: + Location: + description: URL of the media details page + schema: + type: string '400': - description: Bad Request (e.g., no file uploaded, unsupported media type) + description: Bad Request (e.g., file missing) '403': - description: Unauthorized (user not logged in or unverified) + description: Forbidden (insufficient permissions) + '404': + description: Namespace not found - /media/list: + /namespace/{namespace_short_id}/media/list: get: - summary: List Public Media - description: Displays a list of public media from all users. + summary: List Media in Namespace + description: Lists all media files in the namespace. + security: + - sessionAuth: [] + - agentAuth: [] + - {} # Allow public access if namespace is public + parameters: + - in: path + name: namespace_short_id + required: true + schema: + type: string responses: '200': description: Media list page rendered @@ -274,47 +615,30 @@ paths: text/html: schema: type: string - - /media/user/{user_short_id}: - get: - summary: Display User's Media - description: Shows all media uploaded by a specific user. - parameters: - - in: path - name: user_short_id - required: true - schema: - type: string - description: The short ID of the user - responses: - '200': - description: User's media page rendered - content: - text/html: - schema: - type: string + '403': + description: Forbidden (insufficient permissions) '404': - description: User not found - '500': - description: Internal Server Error + description: Namespace not found - /media/{user_short_id}/{media_short_id}/details: + /namespace/{namespace_short_id}/media/{media_short_id}/details: get: - summary: Display Media Details - description: Shows details of a specific media item. + summary: View Media Details + description: Displays the details of a media file. + security: + - sessionAuth: [] + - agentAuth: [] + - {} # Allow public access if media is public parameters: - in: path - name: user_short_id + name: namespace_short_id required: true schema: type: string - description: The short ID of the user - in: path name: media_short_id required: true schema: type: string - description: The short ID of the media item responses: '200': description: Media details page rendered @@ -322,30 +646,29 @@ paths: text/html: schema: type: string - '404': - description: Media or user not found '403': - description: Forbidden (media not public and not owner) + description: Forbidden (insufficient permissions) + '404': + description: Media or namespace not found - /media/{user_short_id}/{media_short_id}/edit: + /namespace/{namespace_short_id}/media/{media_short_id}/edit: get: - summary: Display Media Edit Page - description: Renders a form to edit media details (owner only). + summary: Display Media Edit Page (Editors and Owners) + description: Renders the media edit form. **Requires 'editor' or 'owner' role.** security: - sessionAuth: [] + - agentAuth: [] parameters: - in: path - name: user_short_id + name: namespace_short_id required: true schema: type: string - description: The short ID of the user - in: path name: media_short_id required: true schema: type: string - description: The short ID of the media item responses: '200': description: Media edit page rendered @@ -354,27 +677,26 @@ paths: schema: type: string '403': - description: Forbidden (not owner or not logged in) + description: Forbidden (insufficient permissions) '404': - description: Media not found + description: Media or namespace not found post: - summary: Edit Media - description: Updates the media item (owner only). + summary: Edit Media (Editors and Owners) + description: Updates the media file or metadata. **Requires 'editor' or 'owner' role.** security: - sessionAuth: [] + - agentAuth: [] parameters: - in: path - name: user_short_id + name: namespace_short_id required: true schema: type: string - description: The short ID of the user - in: path name: media_short_id required: true schema: type: string - description: The short ID of the media item requestBody: required: true content: @@ -384,84 +706,103 @@ paths: properties: title: type: string - is_public: - type: string - enum: ['on'] media_file: type: string format: binary + is_public: + type: string + enum: ['on'] responses: '302': description: Redirects to the media details page + headers: + Location: + description: URL of the media details page + schema: + type: string '400': - description: Bad Request (e.g., file too large) + description: Bad Request (e.g., invalid data) '403': - description: Forbidden (not owner) + description: Forbidden (insufficient permissions) '404': - description: Media not found + description: Media or namespace not found - /media/{user_short_id}/{media_short_id}/delete: + /namespace/{namespace_short_id}/media/{media_short_id}/delete: post: - summary: Delete Media - description: Deletes the media item (owner only). + summary: Delete Media (Editors and Owners) + description: Deletes the specified media file. **Requires 'editor' or 'owner' role.** security: - sessionAuth: [] + - agentAuth: [] parameters: - in: path - name: user_short_id + name: namespace_short_id required: true schema: type: string - description: The short ID of the user - in: path name: media_short_id required: true schema: type: string - description: The short ID of the media item + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + csrf_token: + type: string responses: '302': - description: Redirects to the user's media list + description: Redirects to the media list page + headers: + Location: + description: URL of the media list page + schema: + type: string '403': - description: Forbidden (not owner) + description: Forbidden (insufficient permissions) '404': - description: Media not found + description: Media or namespace not found - /media/{user_short_id}/{media_short_id}: + /namespace/{namespace_short_id}/media/{media_short_id}: get: - summary: View Media - description: Retrieves the media file for viewing or download. + summary: View or Download Media + description: Serves the media file for viewing or downloading. + security: + - sessionAuth: [] + - agentAuth: [] + - {} # Allow public access if media is public parameters: - in: path - name: user_short_id + name: namespace_short_id required: true schema: type: string - description: The short ID of the user - in: path name: media_short_id required: true schema: type: string - description: The short ID of the media item - in: query name: download schema: - type: string - enum: ['true', 'false'] - description: Set to 'true' to trigger download + type: boolean + description: Set to true to download the file responses: '200': - description: Media file retrieved + description: Media file served content: '*/*': schema: type: string format: binary '403': - description: Forbidden (media not public and not owner) + description: Forbidden (insufficient permissions) '404': - description: Media not found + description: Media or namespace not found components: securitySchemes: @@ -470,3 +811,8 @@ components: in: cookie name: session description: Session cookie for authenticated users + agentAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT authentication for agents diff --git a/requirements.txt b/requirements.txt index bd0af6c..69e71b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ pyramid_retry pyramid_tm zope.sqlalchemy +pyramid_openapi3 + bcrypt sqlalchemy werkzeug diff --git a/templates/base.html.j2 b/templates/base.html.j2 index 24ae59c..7d8687d 100644 --- a/templates/base.html.j2 +++ b/templates/base.html.j2 @@ -2,7 +2,7 @@ - {% block title %}Media Hosting App{% endblock %} + {% block title %}PyraFiles{% endblock %}