diff --git a/.gitignore b/.gitignore index e3693bc..63ad4cf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,4 @@ *.swp env/ __pycache__/ - -data/ - cookies.txt -jwt_secret.txt -pyralogs_secret.txt - diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index af10c4e..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,124 +0,0 @@ -stages: - - deploy - -variables: - APP_NAME: "logs.unturf.com" - APP_DIR: "/opt/${APP_NAME}" - VENV_DIR: "${APP_DIR}/venv" - CADDYFILE_DIR: "/etc/caddy" - CADDY_SNIPPETS_DIR: "/etc/caddy/caddy_snippets" - -deploy: - stage: deploy - tags: - - master.unturf.com - only: - - main # Adjust if you want to deploy from other branches - script: - # Step 1: Define variables with substitutions - - | - # Variables with substitutions must be defined in the script section - SERVICE_NAME="uwsgi-${APP_NAME//./-}.service" # Replace dots with dashes - UWSGI_SOCKET="${APP_DIR}/uwsgi.sock" # Place socket in app directory - - # Step 2: Synchronize the code to the application directory - - | - if [ ! -d "${APP_DIR}" ]; then - mkdir -p "${APP_DIR}" - fi - rsync -a --exclude=data/ --exclude=venv/ --delete "${CI_PROJECT_DIR}/" "${APP_DIR}/" - - # Step 3: Set up Python virtual environment and install dependencies - - | - if [ ! -d "${VENV_DIR}" ]; then - python3 -m venv "${VENV_DIR}" - fi - source "${VENV_DIR}/bin/activate" - pip install --upgrade pip - pip install -r "${APP_DIR}/requirements.txt" - - # Step 4: Install uWSGI in virtual environment - - | - source "${VENV_DIR}/bin/activate" - pip install uwsgi - - # Step 5: Create or update the systemd user service file for uWSGI - - | - mkdir -p ~/.config/systemd/user/ - - # Determine the correct module and callable for your application - # Adjust 'app:app' based on your application's entry point - # For example, if your main application file is 'app.py' and the Flask app is named 'app', use 'app:app' - - # If your application entry point is different, adjust accordingly - # For example, if your main file is 'wsgi.py' and the callable is 'application', use 'wsgi:application' - - # Set the MODULE variable based on your application's entry point - MODULE="app:uwsgi_app" # Adjust this based on your application - - cat > ~/.config/systemd/user/${SERVICE_NAME} < "${CADDY_SNIPPETS_DIR}/${APP_NAME}.caddy" < "${CADDYFILE_DIR}/Caddyfile" - cat "${CADDY_SNIPPETS_DIR}"/*.caddy >> "${CADDYFILE_DIR}/Caddyfile" - - # Validate the Caddyfile - caddy validate --config "${CADDYFILE_DIR}/Caddyfile" - - # Step 8: Reload Caddy service, gitlab-runner is in sudoers. - # gitlab-runner ALL=(ALL) NOPASSWD: /bin/systemctl * caddy.service - - sudo /bin/systemctl restart caddy.service - - # Deployment completed - - echo "Deployment completed successfully." diff --git a/README.rst b/README.rst index b8d1e00..1cac10c 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,18 @@ -================================ -PyraLogs - A Centralized Logging Hub -================================ +=========================================== +pyrafiles - A Sophisticated Pyramid Project +=========================================== -**PyraLogs** is a **public domain** application built on the `Pyramid `_ framework. It offers a flexible, agent-friendly solution for centralized logging, allowing environments and agents to log events to a central hub. PyraLogs supports a multi-database design where each namespace maintains its own SQLite database for logs. The main database (``main.db``) stores system- and user-level data, while each namespace’s personal DB file handles their specific logs. +``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. Key Features ============ - **Passwordless Login** using email verification codes. -- **Namespace-Based Logging** with per-namespace SQLite databases. -- **JWT Authentication for Agents**, enabling secure log submissions. -- **Token Versioning** for per-agent token revocation without affecting others. -- **Public/Private** namespace visibility controls. -- **Admin Tools** for managing namespaces, agents, and user access. -- **Agent-Friendly** architecture: easily scriptable endpoints for logging and management. +- **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. - **Configurable** via environment variables (including secret keys). Git Repository @@ -21,36 +20,32 @@ Git Repository The project is maintained at: -- `PyraLogs Git Repo `_ +- `pyrafiles Git Repo `_ Since this is public domain, you can adapt and redistribute it freely. + Configuration via Environment ============================= -PyraLogs fetches settings from environment variables with sensible defaults: +`pyrafiles` fetches settings from environment variables with sensible defaults: -- ``PYRALOGS_SECRET`` - The secret key for cookie session signing. - If missing, PyraLogs automatically generates a **random 64-character** string at runtime. - Remove this file or change this value to log our all cookie sessions for all namespaces. +- ``PYRAFILES_SECRET`` + The secret key for session signing. + If missing, ``pyrafiles`` automatically generates a **random 64-character** string at runtime & log all users out. -- ``PYRALOGS_JWT_SECRET`` - The secret key for signing JSON Web Tokens (JWTs) for agents without a mailbox. - If missing, PyraLogs automatically generates a **random 64-character** string at runtime. - Remove this file or change this value to log out all JWT agents for all namespaces. - -- ``PYRALOGS_DB_URL`` +- ``PYRAFILES_DB_URL`` Connection string for the main database. Default: ``sqlite:///main.db``. -- ``PYRALOGS_HOST`` and ``PYRALOGS_PORT`` +- ``PYRAFILES_HOST`` and ``PYRAFILES_PORT`` The host and port to serve on. Defaults: ``0.0.0.0`` (host), ``6544`` (port). -- ``PYRALOGS_SMTP_HOST`` and ``PYRALOGS_SMTP_PORT`` +- ``PYRAFILES_SMTP_HOST`` and ``PYRAFILES_SMTP_PORT`` SMTP server details. Defaults: ``localhost:25``. - Any other environment variables you wish to incorporate can be accessed in the code. + Local Setup =========== @@ -58,8 +53,8 @@ Local Setup .. code-block:: bash - git clone https://git.unturf.com/engineering/unturf/logs.unturf.com.git - cd logs.unturf.com + git clone https://git.unturf.com/engineering/unturf/upload.unturf.com.git + cd upload.unturf.com 2. **Create a Virtual Environment** @@ -76,78 +71,126 @@ Local Setup pip install -r requirements.txt -4. **Initialize the Database** +4. **Run** - Create the main database before running the application. + Create the database if this is the first time running the applicaiton. .. code-block:: bash python initialize_db.py -5. **Run** + Otherwise: .. code-block:: bash - # Optionally set PYRALOGS_SECRET if you want a custom secret. - export PYRALOGS_SECRET="YOUR_OWN_LONG_RANDOM_STRING" - python app.py + # Optionally set PYRAFILES_SECRET if you want a custom secret. + export PYRAFILES_SECRET="YOUR_OWN_LONG_RANDOM_STRING" + python main.py - If ``PYRALOGS_SECRET`` is **not** set, the app automatically generates a 64-character secret at runtime. + If ``PYRAFILES_SECRET`` is **not** set, the app automatically generates a 64-char secret at runtime & log all users out. -6. **Access** +5. **Access** Point your browser to `http://localhost:6544` or `http://:` according to your environment variables. + Example: Agent Workflow Script ============================== Below is a sample Bash script showing how an **agent** might: -1. Obtain a JWT for logging. -2. Send logs to the PyraLogs server using the JWT. +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. .. code-block:: bash #!/usr/bin/env bash # # Usage: - # ./test_agent.sh "Your log message" + # MAX_COOKIE_AGE=1800 ./agent_upload.sh /path/to/somefile.jpg # # Description: - # Send a log message to the PyraLogs server using a JWT. + # 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: - # - Replace with your actual JWT token. - # - Replace with your namespace's short ID. - # - Adjust BASE_URL if the server is running elsewhere. - + # 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. + BASE_URL="${BASE_URL:-http://localhost:6544}" - JWT_TOKEN="" - LOG_MESSAGE="$1" - - if [[ -z "$LOG_MESSAGE" ]]; then - echo "Usage: $0 \"Your log message\"" + EMAIL="${EMAIL:-agent@example.com}" + 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" 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 + 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." + fi + + echo "" + echo "Uploading media: $MEDIA_FILE (public)" + curl -s -c cookies.txt -b cookies.txt \ + -X POST \ + -F "media_file=@$MEDIA_FILE" \ + -F "is_public=on" \ + "$BASE_URL/media/upload" + + echo "" + echo "Upload complete. File is public." - echo "Sending log message..." - RESPONSE=$(curl -s -X POST "$BASE_URL/webhook" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $JWT_TOKEN" \ - -d "{ - \"message\": \"$LOG_MESSAGE\", - \"level\": \"INFO\", - \"metadata\": {\"w\": 1} - }") - echo "Server Response: $RESPONSE" Dockerfile with Caddy + uWSGI ============================= Below is an example Dockerfile that: -- Uses **uWSGI** to run PyraLogs. +- 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). @@ -184,7 +227,7 @@ Below is an example Dockerfile that: # Copy uWSGI config if desired # For example (assume you created uwsgi.ini in your repo): # [uwsgi] - # module = app:app + # module = main:app # master = true # processes = 4 # socket = 127.0.0.1:8080 @@ -193,19 +236,22 @@ Below is an example Dockerfile that: COPY uwsgi.ini /app/uwsgi.ini # Environment variables (optional overrides). - ENV PYRALOGS_DB_URL="sqlite:///data/main.db" - ENV PYRALOGS_HOST="0.0.0.0" - ENV PYRALOGS_PORT="6544" + # If PYRAFILES_SECRET is empty, the app itself generates a random 64-char secret & logs all users out. + ENV PYRAFILES_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 - # Define volume so host can persist databases outside the container - # We'll store database files in /data + # Define volume so host can persist user DBs outside the container + # We'll store DB files in /data VOLUME ["/data"] # Command starts uWSGI and then starts Caddy + # The app itself checks for PYRAFILES_SECRET and auto-generates one if missing. CMD ["/bin/sh", "-c", "\ uwsgi --ini /app/uwsgi.ini & \ caddy run --config /etc/caddy/Caddyfile \ @@ -213,8 +259,8 @@ Below is an example Dockerfile that: .. note:: - - We use ``/data`` as the volume. By default, the environment variable ``PYRALOGS_DB_URL`` is set to ``sqlite:///data/main.db``, so the main DB (and any namespace DB files) go inside ``/data``. - - For namespace DB files, your app can also interpret an environment variable (like ``PYRALOGS_DB_BASEPATH=/data``) if you want to make that path configurable in the code. + - 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 @@ -222,34 +268,36 @@ Below is an example Dockerfile that: docker run -d \ -p 80:80 -p 443:443 \ -v /my/local/dbfolder:/data \ - --name pyralogs \ - pyralogs-image:latest + --name pyrafiles \ + pyrafiles-image:latest + + - With that, any user database is stored in ``/my/local/dbfolder`` on the host. - - With that, any namespace database is stored in ``/my/local/dbfolder`` on the host. Tips ==== -- For **production**, you likely want to set up a real SMTP server or third-party service (e.g., Mailgun, Improvmx) and configure it via environment variables (``PYRALOGS_SMTP_HOST``, etc.). +- 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. -- **Security Reminder**: Always keep your ``PYRALOGS_SECRET`` and ``PYRALOGS_JWT_SECRET`` secure. Do not expose them in your code repositories or logs. -License & Public Domain + +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. +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! -If you find PyraLogs helpful, feel free to contribute back or share your enhancements! Support and Contact =================== -If you register a gitlab account, let me know, and I will grant you developer access to contribute. +- 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 `PyraLogs project page `_. -- For general inquiries, you can 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! -We hope PyraLogs helps you set up a centralized logging solution quickly and efficiently! +If you register an account, let me know and I will bless you as an developer to contribute. -Enjoy and happy logging! +Enjoy and happy building! diff --git a/app.py b/app.py index cb6b899..a76eed7 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,5 @@ ############################################################################### -# app.py - PyraLogs Application with Enhanced Secret Management -# - Secrets are generated once, saved to the filesystem, and loaded thereafter -# - Different secrets are used for session signing and JWT signing -# - Includes previous enhancements for agent management and OpenAPI integration +# app.py - Full pyrafiles Application with Unicode-safe Content-Disposition ############################################################################### import os import base64 @@ -23,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, HTTPNotFound +from pyramid.httpexceptions import HTTPFound, HTTPForbidden from pyramid.session import SignedCookieSessionFactory from sqlalchemy import ( create_engine, @@ -33,13 +30,10 @@ 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, relationship +from sqlalchemy.orm import scoped_session from sqlalchemy.pool import StaticPool from waitress import serve @@ -51,85 +45,36 @@ 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 -PYRALOGS_SECRET_FILE = os.path.join(DATA_DIR, "pyralogs_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 -pyralogs_secret = get_or_create_secret("PYRALOGS_SECRET", PYRALOGS_SECRET_FILE) -JWT_SECRET = get_or_create_secret("PYRALOGS_JWT_SECRET", JWT_SECRET_FILE) -JWT_ALGORITHM = "HS256" +# If PYRAFILES_SECRET is missing, generate a random 64-character secret. +pyrafiles_secret = os.environ.get("PYRAFILES_SECRET", "") +if not pyrafiles_secret: + pyrafiles_secret = "".join( + random.choices(string.ascii_letters + string.digits, k=64) + ) + log.info(f"Generated random PYRAFILES_SECRET: {pyrafiles_secret}") # Database URL can be overridden by environment variable -default_main_db_url = f"sqlite:///{os.path.join(DATA_DIR, 'main.db')}" -DB_URL = os.environ.get("PYRALOGS_DB_URL", default_main_db_url) +APP_DIR = os.path.dirname(os.path.abspath(__file__)) +default_main_db_url = f"sqlite:///{os.path.join(APP_DIR, 'main.db')}" +DB_URL = os.environ.get("PYRAFILES_DB_URL", default_main_db_url) # Host and port for the application -HOST = os.environ.get("PYRALOGS_HOST", "0.0.0.0") -PORT = int(os.environ.get("PYRALOGS_PORT", "6544")) +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("PYRALOGS_SMTP_HOST", "localhost") -smtp_port = int(os.environ.get("PYRALOGS_SMTP_PORT", "25")) +smtp_host = os.environ.get("PYRAFILES_SMTP_HOST", "localhost") +smtp_port = int(os.environ.get("PYRAFILES_SMTP_PORT", "25")) ################################################################################ # Helper Functions @@ -149,24 +94,23 @@ def get_gravatar_url(email, size=100): 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("PYRALOGS_FROM_EMAIL", "master@master.unturf.com") +def send_email(to_email, subject, body): + log.debug("======= Email Sent =======") + log.debug(f"To: {to_email}") + log.debug(f"Subject: {subject}") + log.debug(f"Body:\n{body}") + log.debug("==========================") + msg = MIMEText(body) msg["Subject"] = subject - msg["From"] = from_email + msg["From"] = "noreply@example.com" msg["To"] = to_email + try: s = smtplib.SMTP(smtp_host, smtp_port) - s.sendmail(from_email, [to_email], msg.as_string()) + s.sendmail("noreply@example.com", [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}") @@ -189,34 +133,48 @@ def get_current_user(request): # Try to get the user from the database user = s.query(User).filter_by(id=user_id).first() if user: - user.dbsession = s # Attach dbsession to 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; return None - return None + # 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) -def get_user_namespace_role(request): - user = request.user - namespace = request.namespace - if not user or not namespace: - return None - s = request.dbsession - association = ( - s.query(NamespaceUserAssociation) - .filter( - NamespaceUserAssociation.user_id == user.id, - NamespaceUserAssociation.namespace_id == namespace.id, - ) - .first() + # 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, ) - if association: - return association.role - else: - return None + s.add(guest_user) + s.flush() # Use flush instead of commit in pyramid_tm + + # Store the user ID in the session + request.session["user_id"] = guest_user.id + + return guest_user + + +def get_mime_type(filename): + # Guess the MIME type based on the file extension + mime_type, _ = mimetypes.guess_type(filename) + if not mime_type: + mime_type = "application/octet-stream" + return mime_type def uuid_to_short_id(u): @@ -235,21 +193,39 @@ def short_id_to_uuid(sid): except Exception: continue - log.error(f"Failed to convert short_id {sid} to UUID after trying all paddings") + log.error( + f"Failed to convert short_id {sid} to UUID after trying all padding lengths" + ) 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") +def get_user_db_url(user_id): + """Return the database URL for the user's SQLite database, in the same directory.""" + db_file = os.path.join(APP_DIR, f"user_{user_id}.db") return f"sqlite:///{db_file}" -def slugify_filename(filename): - """Sanitize and slugify filename to prevent path traversal.""" - filename = os.path.basename(filename) - filename = re.sub(r"[^\w\.-]", "_", filename) - return filename or "file" +def filesizeformat(value): + """Returns the human-readable file size.""" + for unit in ["bytes", "KB", "MB", "GB", "TB"]: + if value < 1024.0: + return f"{value:.2f} {unit}" + value /= 1024.0 + return f"{value:.2f} PB" + + +def sanitize_filename_for_http_header(filename): + """ + Ensure that the filename is safe for Waitress (Latin-1 headers). + Converts to ASCII, replacing or removing characters that won't encode. + """ + normalized = unicodedata.normalize("NFKD", filename) + ascii_bytes = normalized.encode("ascii", "ignore") # drop non-ASCII + safe = ascii_bytes.decode("ascii") + # Replace any remaining bad chars with underscores + # e.g. keep alphanumerics, dots, underscores, hyphens, etc. + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", safe) + return safe or "download" ################################################################################ @@ -261,86 +237,44 @@ 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) + email = Column(String, unique=True, nullable=True) # Guests don't have an email. + username = Column(String, unique=True, nullable=False) # user-chosen handle code_hash = Column(String, nullable=True) # bcrypt hash of code code_expires = Column(DateTime, nullable=True) # time limit for code is_verified = Column(Boolean, default=False) enable_gravatar = Column(Boolean, default=False) # Gravatar support is_admin = Column(Boolean, default=False) # Admin flag - # Namespaces the user is associated with - namespace_associations = relationship( - "NamespaceUserAssociation", back_populates="user" + __table_args__ = ( + Index("ix_users_id", "id"), + Index("ix_users_short_id", "short_id"), ) - 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 Namespace(Base): - __tablename__ = "namespaces" +class Media(Base): + __tablename__ = "media" 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) + user_id = Column(String, nullable=False) # Owner's UUID + filename = Column(String, nullable=False) + title = Column(String, nullable=True) # Optional title + media_type = Column(String, nullable=False) # 'image', 'audio', 'video' + media_b64 = Column(String, nullable=False) + upload_date = Column(DateTime, default=datetime.datetime.utcnow) + is_public = Column(Boolean, default=True) + size = Column(Integer, nullable=False) # Size in bytes - # Users associated with the namespace - user_associations = relationship( - "NamespaceUserAssociation", back_populates="namespace" + __table_args__ = ( + Index("ix_media_id", "id"), + Index("ix_media_short_id", "short_id"), ) - 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")) - 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"" - ) ################################################################################ @@ -351,7 +285,7 @@ class Agent(Base): @subscriber(IJinja2Environment) def add_jinja2_filters(event): env = event.environment - # Add any custom Jinja2 filters here + env.filters["filesizeformat"] = filesizeformat ################################################################################ @@ -359,170 +293,46 @@ def add_jinja2_filters(event): ################################################################################ -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_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 +def add_user_dbsession(request): + """Adds user_dbsession to request for verified users.""" + if request.user and request.user.is_verified: + user_dbsession = get_user_dbsession_by_user_id(request.user.id, request) + return user_dbsession else: - return None # No namespace selected + return None # Guests do not have user_dbsession -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") +def get_user_dbsession_by_user_id(user_id, request): + """Helper function to get a user_dbsession for a given user_id.""" + user_db_url = get_user_db_url(user_id) + db_file = os.path.join(APP_DIR, f"user_{user_id}.db") if not os.path.exists(db_file): - # 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, + return None # User database does not exist + user_engine = create_engine( + user_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 + UserSessionFactory = sessionmaker(bind=user_engine) + user_dbsession = scoped_session(UserSessionFactory) + register(user_dbsession) # Register with zope.sqlalchemy # Attach cleanup callbacks def cleanup(_request): - namespace_dbsession.remove() - namespace_engine.dispose() + user_dbsession.remove() + user_engine.dispose() request.add_finished_callback(cleanup) - return namespace_dbsession - - -def check_namespace_permission(user, namespace, required_role): - """Check if the user has the required role in the namespace.""" - if not namespace: - return False - s = user.dbsession - association = ( - s.query(NamespaceUserAssociation) - .filter( - NamespaceUserAssociation.namespace_id == namespace.id, - NamespaceUserAssociation.user_id == user.id, - ) - .first() - ) - if not association: - if namespace.is_public and required_role == "reader": - return True - else: - return False - user_role = association.role - roles_hierarchy = {"owner": 3, "editor": 2, "reader": 1} - return roles_hierarchy.get(user_role, 0) >= roles_hierarchy.get(required_role, 0) - - -def owner_required(view_func): - def wrapper(request): - user = request.user - namespace = request.namespace - if not namespace: - return HTTPNotFound("Namespace not found.") - if not user or not check_namespace_permission(user, namespace, "owner"): - return HTTPForbidden("You must be an owner to access this page.") - return view_func(request) - - return wrapper - - -def editor_required(view_func): - def wrapper(request): - user = request.user - namespace = request.namespace - if not namespace: - return HTTPNotFound("Namespace not found.") - if not user or not check_namespace_permission(user, namespace, "editor"): - return HTTPForbidden("You must be an editor to access this page.") - return view_func(request) - - return wrapper - - -def reader_required(view_func): - def wrapper(request): - user = request.user - namespace = request.namespace - if not namespace: - return HTTPNotFound("Namespace not found.") - if not user or not check_namespace_permission(user, namespace, "reader"): - return HTTPForbidden("You do not have access to this namespace.") - return view_func(request) - - return wrapper + return user_dbsession ################################################################################ -# Namespace Database Models -################################################################################ - -NamespaceBase = declarative_base() - - -class LogEntry(NamespaceBase): - __tablename__ = "log_entries" - id = Column(Integer, primary_key=True, autoincrement=True) - timestamp = Column(DateTime, default=datetime.datetime.utcnow) - message = Column(Text, nullable=False) - level = Column(String, nullable=False) - log_metadata = Column(Text, nullable=True) - - def __repr__(self): - return f"" - - -################################################################################ -# Routes and Views +# Routes ################################################################################ @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, } @@ -626,52 +436,25 @@ def verify_post_view(request): request.session["user_id"] = user.id - # Authentication cookies are used for other routes. + # 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", request_method="POST", require_csrf=True) +@view_config(route_name="logout") def logout_view(request): request.session.invalidate() return HTTPFound(location=request.route_url("home")) ################################################################################ -# JWT Helper Functions -################################################################################ - - -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, - "token_version": agent.token_version, - "iat": datetime.datetime.utcnow(), - # "exp": datetime.datetime.utcnow() + expires_delta, # Removed to make token not expire - } - 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 - - -################################################################################ -# Profile and Namespace Management +# Profile and Media Upload ################################################################################ @@ -682,17 +465,22 @@ 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 "" - # 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) + # Initialize stats + total_uploads = 0 + total_size = 0 + + user_dbsession = request.user_dbsession + if user_dbsession: + media_items = user_dbsession.query(Media).all() + total_uploads = len(media_items) + total_size = sum(media.size for media in media_items) return { "request": request, "user": user, "gravatar_url": gravatar_url, - "owner_namespaces": owner_namespaces, + "total_uploads": total_uploads, + "total_size": total_size, } @@ -702,8 +490,7 @@ def profile_post_view(request): 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, + "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" @@ -722,502 +509,467 @@ 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="create_namespace", + route_name="import_user_record", request_method="GET", - renderer="create_namespace.html.j2", + renderer="import_user_record.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) +@admin_required +def import_user_record_get_view(request): 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) +@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) - name = request.POST.get("name", "").strip() - if not name: - return Response("Namespace name is required.", status=400) + # Read and parse the JSON data + try: + user_record_data = user_record_file.file.read() + user_record = json.loads(user_record_data) + except Exception as e: + return Response(f"Invalid user record file: {e}", status=400) s = request.dbsession - 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) + # Check if user already exists + existing_user = s.query(User).filter_by(id=user_record["id"]).first() + if existing_user: + return Response("User already exists in the database.", status=400) + + # Create new user with provided data + user = User( + id=user_record["id"], + short_id=user_record["short_id"], + email=user_record["email"], + username=user_record["username"], + enable_gravatar=user_record.get("enable_gravatar", False), + is_admin=user_record.get("is_admin", False), + is_verified=True, # Assume verified + ) + s.add(user) + s.flush() + + # Do not create user database upon import + + request.session.flash(f"User {user.username} imported successfully.") + return HTTPFound(location=request.route_url("home")) + + +################################################################################ +# Media Upload, Listing, and Management +################################################################################ + + +@view_config( + route_name="upload_media", request_method="GET", renderer="upload_media.html.j2" +) +def upload_media_get_view(request): + user = request.user + if not user or not user.is_verified: + return Response("You must be logged in to upload media.", status=403) + return {"request": request} + + +@view_config(route_name="upload_media", request_method="POST") +def upload_media_post_view(request): + user = request.user + if not user or not user.is_verified: + return Response("You must be logged in to upload media.", status=403) + + user_dbsession = request.user_dbsession # Assume this exists for verified users + + media_file = request.POST.get("media_file") + if media_file is None or not getattr(media_file, "filename", "").strip(): + return Response("No file uploaded.", status=400) + + raw_bytes = media_file.file.read() + max_size = 30 * 1024 * 1024 # 30 MB + if len(raw_bytes) > max_size: + return Response("File size exceeds the 30MB limit.", status=400) + + file_size = len(raw_bytes) + + # Determine media type based on MIME type + filename = media_file.filename + mime_type = get_mime_type(filename) + if not mime_type.startswith(("image/", "audio/", "video/")): + return Response("Unsupported media type.", status=400) + + media_type = mime_type.split("/")[0] + + # Get title from form + title = request.POST.get("title", "").strip() + + # If user did not enter a title, default to the original filename + if not title: + title = filename + + # Encode content to base64 + encoded_str = base64.b64encode(raw_bytes).decode("utf-8") + + # Generate UUID and short ID for the media + media_uuid = uuid.uuid4() + media_id = str(media_uuid) + media_short_id = uuid_to_short_id(media_uuid) is_public = request.POST.get("is_public") == "on" - namespace = Namespace( - id=namespace_id, - short_id=namespace_short_id, - name=name, + media = Media( + id=media_id, + short_id=media_short_id, + user_id=user.id, + filename=filename, + title=title, # now has a fallback of filename + media_type=media_type, + media_b64=encoded_str, is_public=is_public, + size=file_size, ) - 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() + user_dbsession.add(media) + user_dbsession.flush() return HTTPFound( location=request.route_url( - "manage_namespace", namespace_short_id=namespace.short_id + "view_media_details", + user_short_id=user.short_id, + media_short_id=media.short_id, ) ) -@view_config(route_name="manage_namespace", renderer="manage_namespace.html.j2") -@editor_required -def manage_namespace_view(request): - namespace = request.namespace +@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, + } + ) - # 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() - ) + # Sort media by upload date (recent first) + media_list.sort(key=lambda x: x["media"].upload_date, reverse=True) return { "request": request, - "namespace": namespace, - "users": users, - "agents": agents, + "media_list": media_list, } -@view_config(route_name="change_member_role", request_method="POST") -@owner_required -def change_member_role_view(request): +@view_config(route_name="user_media", renderer="user_media.html.j2") +def user_media_view(request): + user_short_id = request.matchdict.get("user_short_id") + log.debug(f"Looking up user with short_id: {user_short_id}") + + try: + user_uuid = short_id_to_uuid(user_short_id) + if not user_uuid: + log.error(f"Could not convert short_id {user_short_id} to UUID") + return Response("User not found.", status=404) + + s = request.dbsession + user = s.query(User).filter_by(id=str(user_uuid)).first() + log.debug(f"User found: {user}") + + if not user: + return Response("User not found.", status=404) + + viewer = request.user + is_owner = viewer and viewer.id == user.id + + user_dbsession = get_user_dbsession_by_user_id(user.id, request) + if not user_dbsession: + return Response("User has no uploads.", status=404) + + if is_owner: + media_items = user_dbsession.query(Media).all() + else: + media_items = ( + user_dbsession.query(Media).filter(Media.is_public == True).all() + ) + + media_items.sort(key=lambda m: m.upload_date, reverse=True) + + return { + "request": request, + "media_items": media_items, + "user": user, + "is_owner": is_owner, + } + + except Exception as e: + log.exception(f"Error processing user_short_id {user_short_id}: {e}") + return Response("Error processing request.", status=500) + + +@view_config(route_name="view_media_details", renderer="view_media_details.html.j2") +def view_media_details_view(request): + media_short_id = request.matchdict.get("media_short_id") + user_short_id = request.matchdict.get("user_short_id") + if not user_short_id or not media_short_id: + return Response("Invalid request.", status=400) + s = request.dbsession - 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() + user = s.query(User).filter_by(short_id=user_short_id).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) + user_id = user.id - # Prevent owners from removing themselves - if user_to_remove.id == current_user.id: - return Response("Owners cannot remove themselves.", status=400) + user_dbsession = get_user_dbsession_by_user_id(user_id, request) + if not user_dbsession: + return Response("User database not found.", status=404) - # 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) + media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + if not media: + return Response("Media not found.", status=404) - # Remove the association - s.delete(association) - s.flush() + viewer = request.user + is_owner = viewer and viewer.id == user_id - # Provide a success message - request.session.flash( - f"User '{user_to_remove.username}' has been removed from the namespace." - ) + if not media.is_public and not is_owner: + return Response("Media not available.", status=403) + + return { + "request": request, + "media": media, + "username": user.username, + "user_short_id": user.short_id, + "is_owner": is_owner, + } + + +@view_config(route_name="delete_media", request_method="POST") +def delete_media_view(request): + media_short_id = request.matchdict.get("media_short_id") + user_short_id = request.matchdict.get("user_short_id") + + viewer = request.user + if not viewer or not viewer.is_verified: + return Response("You must be logged in.", status=403) + + if viewer.short_id != user_short_id: + return Response("You are not authorized to delete this media.", status=403) + + user_dbsession = request.user_dbsession + media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + if not media: + return Response("Media not found.", status=404) + + user_dbsession.delete(media) + user_dbsession.flush() return HTTPFound( - location=request.route_url( - "manage_namespace", namespace_short_id=namespace.short_id - ) + location=request.route_url("user_media", user_short_id=viewer.short_id) ) @view_config( - route_name="generate_agent_jwt", - request_method="POST", - renderer="display_agent_jwt.html.j2", + route_name="edit_media", request_method="GET", renderer="edit_media.html.j2" ) -@editor_required -def generate_agent_jwt_view(request): - namespace = request.namespace - s = request.dbsession +def edit_media_get_view(request): + media_short_id = request.matchdict.get("media_short_id") + user_short_id = request.matchdict.get("user_short_id") - agent_name = request.POST.get("agent_name", "").strip() - if not agent_name: - return Response("Agent name is required.", status=400) + viewer = request.user + if not viewer or not viewer.is_verified: + return Response("You must be logged in.", status=403) - # 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 viewer.short_id != user_short_id: + return Response("You are not authorized to edit this media.", status=403) - if agent: - # Agent exists, increment token_version and regenerate JWT - agent.token_version += 1 - agent.status = "active" # Ensure the agent is active - 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, - 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." + user_dbsession = request.user_dbsession + media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + if not media: + return Response("Media not found.", status=404) return { "request": request, - "namespace": namespace, - "agent_name": agent_name, - "jwt_token": jwt_token, - "message": message, + "media": media, } -@view_config(route_name="revoke_agent", request_method="POST") -@editor_required -def revoke_agent_view(request): - namespace = request.namespace - s = request.dbsession +@view_config(route_name="edit_media", request_method="POST") +def edit_media_post_view(request): + media_short_id = request.matchdict.get("media_short_id") + user_short_id = request.matchdict.get("user_short_id") - agent_id = request.POST.get("agent_id") - if not agent_id: - return Response("Agent ID is required.", status=400) + viewer = request.user + if not viewer or not viewer.is_verified: + return Response("You must be logged in.", status=403) - # Get the agent - agent = ( - s.query(Agent) - .filter( - Agent.id == agent_id, - Agent.namespace_id == namespace.id, - ) - .first() - ) + if viewer.short_id != user_short_id: + return Response("You are not authorized to edit this media.", status=403) - if not agent: - return Response("Agent not found.", status=404) + user_dbsession = request.user_dbsession + media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + if not media: + return Response("Media not found.", status=404) - # 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.") + # Handle title update + new_title = request.POST.get("title", "").strip() + media.title = new_title + + # Handle media file update + new_media_file = request.POST.get("media_file") + if new_media_file and getattr(new_media_file, "filename", "").strip(): + raw_bytes = new_media_file.file.read() + max_size = 30 * 1024 * 1024 + if len(raw_bytes) > max_size: + return Response("File size exceeds the 30MB limit.", status=400) + file_size = len(raw_bytes) + filename = new_media_file.filename + mime_type = get_mime_type(filename) + if not mime_type.startswith(("image/", "audio/", "video/")): + return Response("Unsupported media type.", status=400) + media_type = mime_type.split("/")[0] + encoded_str = base64.b64encode(raw_bytes).decode("utf-8") + + media.filename = filename + media.media_type = media_type + media.media_b64 = encoded_str + media.size = file_size + + # Handle public/private update + is_public = request.POST.get("is_public") == "on" + media.is_public = is_public + + user_dbsession.flush() return HTTPFound( location=request.route_url( - "manage_namespace", namespace_short_id=namespace.short_id + "view_media_details", + user_short_id=viewer.short_id, + media_short_id=media.short_id, ) ) ################################################################################ -# Logging Views +# Media Viewing and Downloading ################################################################################ -@view_config(route_name="log_webhook", request_method="POST") -def log_webhook_view(request): +@view_config(route_name="view_media") +def view_media_view(request): + media_short_id = request.matchdict.get("media_short_id") + user_short_id = request.matchdict.get("user_short_id") + if not user_short_id or not media_short_id: + return Response("Invalid request.", status=400) + s = request.dbsession + user = s.query(User).filter_by(short_id=user_short_id).first() + if not user: + return Response("User not found.", status=404) + user_id = user.id - # Attempt authentication via JWT - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - return Response("Missing or invalid Authorization header.", status=401) - token = auth_header[len("Bearer ") :].strip() - payload = verify_jwt_token(token) - if not payload: - return Response("Invalid or expired token.", status=401) + user_dbsession = get_user_dbsession_by_user_id(user_id, request) + if not user_dbsession: + return Response("User database not found.", status=404) - # Get agent from the JWT - agent_id = payload.get("agent_id") - if not agent_id: - return Response("Agent ID not specified in token.", status=400) - agent = s.query(Agent).filter(Agent.id == agent_id).first() - if not agent: - return Response("Agent not found.", status=404) + media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() + if not media: + return Response("Media not found.", status=404) - # Check agent status - if agent.status != "active": - return Response("Agent is revoked or inactive.", status=401) + viewer = request.user + is_owner = viewer and viewer.id == user_id - # Check token_version - if payload.get("token_version") != agent.token_version: - return Response("Token has been revoked.", status=401) + if not media.is_public and not is_owner: + return Response("Media not available.", status=403) - # Get namespace from the agent - namespace = s.query(Namespace).filter(Namespace.id == agent.namespace_id).first() - if not namespace: - return Response("Namespace not found.", status=404) - request.namespace = namespace + media_data = base64.b64decode(media.media_b64) + mime_type = get_mime_type(media.filename) - # No need to issue a new token since tokens do not expire due to time - - # Access namespace_dbsession - namespace_dbsession = request.namespace_dbsession - - # Parse payload - try: - json_payload = request.json_body - except json.decoder.JSONDecodeError: - return Response("Invalid JSON payload.", status=400) - message = json_payload.get("message", "") - if not message: - return Response("Log message is required.", status=400) - level = json_payload.get("level", "INFO") # Default to 'INFO' if not provided - meta_data = json_payload.get("metadata", None) - if meta_data is not None: - meta_data = json.dumps(meta_data) - - # Create log entry - log_entry = LogEntry( - message=message, - level=level, - log_metadata=meta_data, - ) - namespace_dbsession.add(log_entry) - namespace_dbsession.flush() - - return Response("Log entry created.", status=201) - - -@view_config(route_name="view_namespace_logs", renderer="view_namespace_logs.html.j2") -def view_namespace_logs_view(request): - namespace = request.namespace - if not namespace: - return HTTPNotFound("Namespace not found.") - namespace_dbsession = request.namespace_dbsession - - # Check if the namespace is public - if namespace.is_public: - # Allow access to anyone - pass # Proceed to retrieve logs - else: - # Namespace is private - # Check if user is authenticated - user = request.user - if not user: - # User is not authenticated - return HTTPForbidden("You must be logged in to access this namespace.") - - # Check if user has at least 'reader' role in this namespace - if not check_namespace_permission(user, namespace, required_role="reader"): - return HTTPForbidden("You do not have access to this namespace.") - - # Get the search query from the request parameters - search_query = request.params.get("q", "").strip() - - # Start building the query - query = namespace_dbsession.query(LogEntry) - - if search_query: - # Create a search pattern for case-insensitive search - search_pattern = f"%{search_query}%" - query = query.filter( - or_( - LogEntry.message.ilike(search_pattern), - LogEntry.level.ilike(search_pattern), - LogEntry.log_metadata.ilike(search_pattern), - ) + # Build a safe filename (avoid Unicode issues in the header) + if media.title: + file_extension = os.path.splitext(media.filename)[1] + raw_title = media.title + download_filename = sanitize_filename_for_http_header( + f"{raw_title}{file_extension}" ) + else: + download_filename = sanitize_filename_for_http_header(media.filename) - logs = query.order_by(LogEntry.timestamp.desc()).all() + # Check if user wants attachment or inline + download = request.GET.get("download", "false").lower() == "true" + content_disposition = "attachment" if download else "inline" - return { - "request": request, - "namespace": namespace, - "logs": logs, - "search_query": search_query, - } + 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 ################################################################################ @@ -1225,13 +977,13 @@ def view_namespace_logs_view(request): ################################################################################ -def main(*config, **settings): +def main(global_config=None, **settings): # Configure logging - logging.basicConfig(level=logging.INFO) + logging.basicConfig(level=logging.DEBUG) # Set up the session factory session_factory = SignedCookieSessionFactory( - secret=pyralogs_secret, + secret=pyrafiles_secret, hashalg="sha512", timeout=31104000, # Approx. one year in seconds max_age=31104000, # Set Max-Age attribute on cookie @@ -1247,31 +999,38 @@ def main(*config, **settings): config = Configurator(settings=settings, session_factory=session_factory) config.include("pyramid_jinja2") - # 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") + config.include("pyramid_tm") # Include pyramid_tm for transaction management # Add .html.j2 extension for Jinja2 templates config.add_jinja2_renderer(".j2") config.add_jinja2_search_path("templates", name=".j2") - # The Jinja2 filters are added via the event subscriber above + # Add settings for pyramid_openapi3 + config.add_settings( + { + "pyramid_openapi3.spec": os.path.join(APP_DIR, "openapi.yaml"), + "pyramid_openapi3.enable_request_validation": True, + "pyramid_openapi3.enable_response_validation": False, + "pyramid_openapi3.route": "/openapi.yaml", + "pyramid_openapi3.ui_route": "/docs/", + } + ) + + # Include pyramid_openapi3 + config.include("pyramid_openapi3") # Set up SQLAlchemy engine = create_engine( - DB_URL, - connect_args={"check_same_thread": False}, - poolclass=StaticPool, + DB_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool ) session_factory_ = sessionmaker(bind=engine) Base.metadata.bind = engine DBSession = scoped_session(session_factory_) - # Register with zope.sqlalchemy - register(DBSession) + register(DBSession) # Register with zope.sqlalchemy + + # Add user to all requests + config.add_request_method(callable=get_current_user, name="user", reify=True) # Provide dbsession to requests def dbsession(request): @@ -1279,56 +1038,41 @@ def main(*config, **settings): config.add_request_method(dbsession, "dbsession", reify=True) - # Add user, namespace, user_namespace_role to requests - config.add_request_method(get_current_user, "user", reify=True) - config.add_request_method(get_namespace, "namespace", reify=True) - config.add_request_method( - get_user_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 - ) + # Add user_dbsession to requests for verified users + config.add_request_method(add_user_dbsession, "user_dbsession", reify=True) # Routes config.add_route("home", "/") - # Auth for cookie sessions. + # 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" - ) + # 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") - # Routes to generate JWTs for agents without access to an email mailbox. - config.add_route("revoke_agent", "/namespace/{namespace_short_id}/revoke_agent") + # 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( - "generate_agent_jwt", "/namespace/{namespace_short_id}/generate_agent_jwt" + "view_media_details", "/media/{user_short_id}/{media_short_id}/details" ) + config.add_route("edit_media", "/media/{user_short_id}/{media_short_id}/edit") + config.add_route("delete_media", "/media/{user_short_id}/{media_short_id}/delete") + config.add_route("view_media", "/media/{user_short_id}/{media_short_id}") - # Logging - config.add_route("log_webhook", "/webhook") - config.add_route("view_namespace_logs", "/namespace/{namespace_short_id}/logs") + # Database Download + config.add_route("download_database", "/auth/download_db") config.scan() return config.make_wsgi_app() -# Expose the WSGI application callable for uwsgi -uwsgi_app = main({}) - - if __name__ == "__main__": - app = main({}) + app = main() log.info(f"Serving on http://{HOST}:{PORT}") serve(app, host=HOST, port=PORT) diff --git a/initialize_db.py b/initialize_db.py index 00d0298..0c45815 100644 --- a/initialize_db.py +++ b/initialize_db.py @@ -1,14 +1,18 @@ #!/usr/bin/env python """ -Initialize or upgrade the main database for PyraLogs. +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. + Description: Creates or updates all tables referenced by `Base.metadata`. - The database file will be created (if it does not exist) in the - DATA_DIR as specified in the app file. No custom paths are allowed. + If the database file/tables do not exist, they will be created. """ import os @@ -20,8 +24,11 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool -# Import Base and DATA_DIR from your app.py (Adjust if needed) -from app import Base, DATA_DIR # Make sure app.py defines Base and DATA_DIR +# 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 def usage(): script = os.path.basename(sys.argv[0]) @@ -32,25 +39,19 @@ def usage(): def main(): logging.basicConfig(level=logging.INFO) - - # We do not allow any command-line arguments. - if len(sys.argv) > 1: + if len(sys.argv) > 2: + # We only expect optional arguments. If needed, parse them here. usage() - # Ensure DATA_DIR exists - if not os.path.exists(DATA_DIR): - os.makedirs(DATA_DIR) - logging.info(f"Created data directory at {DATA_DIR}") - - # Always use the default path from app.py for the database - db_url = f"sqlite:///{os.path.join(DATA_DIR, 'main.db')}" + # Read environment variable for DB URL + db_url = os.environ.get("PYRAFILES_DB_URL", "sqlite:///main.db") logging.info(f"Using DB URL: {db_url}") - # Set up the engine + # Set up engine engine = create_engine( db_url, - connect_args={"check_same_thread": False}, - poolclass=StaticPool + connect_args={"check_same_thread": False} if "sqlite" in db_url else {}, + poolclass=StaticPool if "sqlite" in db_url else None, ) SessionFactory = sessionmaker(bind=engine) @@ -64,3 +65,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/openapi.yaml b/openapi.yaml index 4c119d6..367c5e5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,47 +1,11 @@ -openapi: 3.0.3 +openapi: 3.0.0 info: - title: PyraLogs API - version: 1.0.0 - description: | - API specification for the PyraLogs application. - PyraLogs allows users to register, authenticate, manage namespaces, - generate JWT tokens for agents, and submit logs via a webhook. -servers: - - url: http://127.0.0.1:{port} - description: Local development server - variables: - port: - default: '6544' - - url: https://logs.unturf.com - description: Production server - + title: PyraFiles API + version: '1.0' paths: - /: - get: - summary: Home Page - description: Displays the home page with public namespaces and user namespaces. - responses: - '200': - description: Successful response - content: - text/html: - schema: - type: string - /auth/login: - get: - summary: Display Login Page - description: Renders the login page where users can enter their email. - responses: - '200': - description: Login page rendered - content: - text/html: - schema: - type: string post: - summary: Process Login - description: Sends a verification code to the user's email. + summary: User login requestBody: required: true content: @@ -51,36 +15,12 @@ paths: properties: email: type: string - format: email - required: - - email 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 - + description: Redirect to verification page /auth/verify: - get: - summary: Display Verification Page - description: Renders the verification page where users can enter their code. - responses: - '200': - description: Verification page rendered - content: - text/html: - schema: - type: string post: - summary: Verify User - description: Verifies the user's code and logs them in. + summary: Verify user code requestBody: required: true content: @@ -90,514 +30,24 @@ paths: properties: code: type: string - required: - - code 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: + description: Redirect to home page + /media/upload: post: - summary: Logout User - description: Logs out the current user. - security: - - sessionAuth: [] + summary: Upload media file requestBody: required: true content: - application/x-www-form-urlencoded: + multipart/form-data: schema: type: object properties: - csrf_token: + media_file: + type: string + format: binary + title: 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 owned namespaces. - security: - - sessionAuth: [] - responses: - '200': - description: Profile page rendered - content: - text/html: - schema: - type: string - '403': - description: Unauthorized (user not logged in) - post: - summary: Update User Profile - description: Updates the user's profile settings. - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - enable_gravatar: - type: string - enum: ['on'] - new_username: - type: string - required: - - new_username - 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) - - /namespace/create: - get: - summary: Display Namespace Creation Page - description: Renders the form to create a new namespace. - security: - - sessionAuth: [] - responses: - '200': - description: Namespace creation page rendered - content: - text/html: - schema: - type: string - '403': - description: Unauthorized (user not logged in) - post: - summary: Create Namespace - description: Processes the namespace creation form. - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - name: - type: string - is_public: - type: string - enum: ['on'] - encoding: - name: - contentType: text/plain - 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 - '400': - description: Bad Request (e.g., namespace name already exists) - '403': - description: Unauthorized (user not logged in) - - /namespace/{namespace_short_id}/manage: - get: - summary: Manage Namespace - description: Displays the namespace management page (owners only). - security: - - sessionAuth: [] - parameters: - - in: path - name: namespace_short_id - required: true - schema: - type: string - description: The short ID of the namespace - responses: - '200': - description: Namespace management page rendered - content: - text/html: - schema: - type: string - '403': - description: Forbidden (not owner or not logged in) - '404': - description: Namespace not found - - /namespace/{namespace_short_id}/update: - post: - summary: Update Namespace - description: Updates namespace properties (owners only). - security: - - sessionAuth: [] - 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 - description: Invites a user to the namespace with a specified role (owners only). - security: - - sessionAuth: [] - 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 - description: Removes a user from the namespace (owners only, cannot remove self). - security: - - sessionAuth: [] - 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 - description: Changes a member's role in the namespace (owners only, cannot change own role). - security: - - sessionAuth: [] - 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 - description: Generates a JWT token for an agent (owners only). - security: - - sessionAuth: [] - 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 - required: - - agent_name - encoding: - agent_name: - 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 - description: Revokes an agent's access by invalidating their JWT (owners only). - security: - - sessionAuth: [] - 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}/logs: - get: - summary: View Namespace Logs - description: | - Displays logs for the specified namespace. - - **Public Namespaces:** Accessible to all users, including guests. - - **Private Namespaces:** Requires authentication and at least 'reader' role. - security: - - {} # Allows public access to this endpoint - - sessionAuth: [] - parameters: - - in: path - name: namespace_short_id - required: true - schema: - type: string - description: The short ID of the namespace - - in: query - name: q - schema: - type: string - description: Search query to filter logs - responses: - '200': - description: Logs page rendered - content: - text/html: - schema: - type: string - '403': - description: Forbidden (user does not have access to private namespace) - '404': - description: Namespace not found - - /webhook: - post: - summary: Submit Log Entry - description: Allows agents to submit log entries via JWT authentication. - security: - - agentAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - message: - type: string - description: The log message - level: - type: string - description: Log level (e.g., INFO, ERROR) - default: INFO - metadata: - type: object - description: Additional metadata for the log entry - required: - - message - responses: - '201': - description: Log entry created - '400': - description: Bad Request (e.g., message missing) - '401': - description: Unauthorized (missing or invalid JWT) - '404': - description: Namespace or agent not found - -components: - securitySchemes: - sessionAuth: - type: apiKey - in: cookie - name: session - description: Session cookie for authenticated users - agentAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: JWT authentication for agents + description: Redirect to media details page diff --git a/requirements.txt b/requirements.txt index d88d1a7..bad1dc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,13 +5,11 @@ pyramid_debugtoolbar waitress pyramid_retry -pyramid_jwt - -pyramid_openapi3 - pyramid_tm zope.sqlalchemy bcrypt sqlalchemy werkzeug + +pyramid_openapi3 diff --git a/templates/base.html.j2 b/templates/base.html.j2 index 614ab6c..24ae59c 100644 --- a/templates/base.html.j2 +++ b/templates/base.html.j2 @@ -2,7 +2,7 @@ - {% block title %}PyraLogs{% endblock %} + {% block title %}Media Hosting App{% endblock %}