diff --git a/.gitignore b/.gitignore index 63ad4cf..e3693bc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ *.swp env/ __pycache__/ + +data/ + cookies.txt +jwt_secret.txt +pyralogs_secret.txt + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..af10c4e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,124 @@ +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 1cac10c..b8d1e00 100644 --- a/README.rst +++ b/README.rst @@ -1,18 +1,19 @@ -=========================================== -pyrafiles - A Sophisticated Pyramid Project -=========================================== +================================ +PyraLogs - A Centralized Logging Hub +================================ -``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. +**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. 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. +- **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. - **Configurable** via environment variables (including secret keys). Git Repository @@ -20,32 +21,36 @@ Git Repository The project is maintained at: -- `pyrafiles Git Repo `_ +- `PyraLogs 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: +PyraLogs 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 & log all users out. +- ``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_DB_URL`` +- ``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`` Connection string for the main database. Default: ``sqlite:///main.db``. -- ``PYRAFILES_HOST`` and ``PYRAFILES_PORT`` +- ``PYRALOGS_HOST`` and ``PYRALOGS_PORT`` The host and port to serve on. Defaults: ``0.0.0.0`` (host), ``6544`` (port). -- ``PYRAFILES_SMTP_HOST`` and ``PYRAFILES_SMTP_PORT`` +- ``PYRALOGS_SMTP_HOST`` and ``PYRALOGS_SMTP_PORT`` SMTP server details. Defaults: ``localhost:25``. - Any other environment variables you wish to incorporate can be accessed in the code. - Local Setup =========== @@ -53,8 +58,8 @@ Local Setup .. code-block:: bash - git clone https://git.unturf.com/engineering/unturf/upload.unturf.com.git - cd upload.unturf.com + git clone https://git.unturf.com/engineering/unturf/logs.unturf.com.git + cd logs.unturf.com 2. **Create a Virtual Environment** @@ -71,126 +76,78 @@ Local Setup pip install -r requirements.txt -4. **Run** +4. **Initialize the Database** - Create the database if this is the first time running the applicaiton. + Create the main database before running the application. .. code-block:: bash python initialize_db.py - Otherwise: +5. **Run** .. code-block:: bash - # Optionally set PYRAFILES_SECRET if you want a custom secret. - export PYRAFILES_SECRET="YOUR_OWN_LONG_RANDOM_STRING" - python main.py + # Optionally set PYRALOGS_SECRET if you want a custom secret. + export PYRALOGS_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 all users out. + If ``PYRALOGS_SECRET`` is **not** set, the app automatically generates a 64-character secret at runtime. -5. **Access** +6. **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. 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. +1. Obtain a JWT for logging. +2. Send logs to the PyraLogs server using the JWT. .. code-block:: bash #!/usr/bin/env bash # # Usage: - # MAX_COOKIE_AGE=1800 ./agent_upload.sh /path/to/somefile.jpg + # ./test_agent.sh "Your log message" # # 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. + # Send a log message to the PyraLogs server using a JWT. # # 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. - + # - Replace with your actual JWT token. + # - Replace with your namespace's short ID. + # - Adjust BASE_URL if the server is running elsewhere. + BASE_URL="${BASE_URL:-http://localhost:6544}" - 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" + JWT_TOKEN="" + LOG_MESSAGE="$1" + + if [[ -z "$LOG_MESSAGE" ]]; then + echo "Usage: $0 \"Your log message\"" 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 ``pyrafiles``. +- Uses **uWSGI** to run PyraLogs. - Uses **Caddy** as a reverse proxy (and optionally HTTPS if configured). - Defines a **volume** for databases (so they are stored on the host). @@ -227,7 +184,7 @@ Below is an example Dockerfile that: # Copy uWSGI config if desired # For example (assume you created uwsgi.ini in your repo): # [uwsgi] - # module = main:app + # module = app:app # master = true # processes = 4 # socket = 127.0.0.1:8080 @@ -236,22 +193,19 @@ Below is an example Dockerfile that: 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. - ENV PYRAFILES_SECRET="" - ENV PYRAFILES_DB_URL="sqlite:///data/main.db" - ENV PYRAFILES_HOST="0.0.0.0" - ENV PYRAFILES_PORT="6544" + ENV PYRALOGS_DB_URL="sqlite:///data/main.db" + ENV PYRALOGS_HOST="0.0.0.0" + ENV PYRALOGS_PORT="6544" # Expose HTTP and HTTPS EXPOSE 80 EXPOSE 443 - # Define volume so host can persist user DBs outside the container - # We'll store DB files in /data + # Define volume so host can persist databases outside the container + # We'll store database 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 \ @@ -259,8 +213,8 @@ 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. + - 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. - Make sure to **mount** a volume at ``/data`` when you run the container, e.g.: .. code-block:: bash @@ -268,36 +222,34 @@ Below is an example Dockerfile that: 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. + --name pyralogs \ + pyralogs-image:latest + - 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, ImprovMV) and configure it via environment variables (``PYRAFILES_SMTP_HOST``, etc.). +- 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.). - 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 and Public Domain +License & 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 PyraLogs 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. +If you register a gitlab account, let me know, and I will grant you developer access to contribute. -We hope `pyrafiles` helps you get up and running quickly with a flexible media-sharing and multi-DB infrastructure! +- **Issues**: Please open tickets at the `PyraLogs project page `_. +- For general inquiries, you can reach out to the maintainers directly. -If you register an account, let me know and I will bless you as an developer to contribute. +We hope PyraLogs helps you set up a centralized logging solution quickly and efficiently! -Enjoy and happy building! +Enjoy and happy logging! diff --git a/app.py b/app.py index d351dd0..cb6b899 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,8 @@ ############################################################################### -# app.py - Full pyrafiles Application with Unicode-safe Content-Disposition +# 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 ############################################################################### import os import base64 @@ -20,7 +23,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 +33,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,36 +51,85 @@ 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 +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" # Database URL can be overridden by environment variable -APP_DIR = os.path.dirname(os.path.abspath(__file__)) -default_main_db_url = f"sqlite:///{os.path.join(APP_DIR, 'main.db')}" -DB_URL = os.environ.get("PYRAFILES_DB_URL", default_main_db_url) +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) # Host and port for the application -HOST = os.environ.get("PYRAFILES_HOST", "0.0.0.0") -PORT = int(os.environ.get("PYRAFILES_PORT", "6544")) +HOST = os.environ.get("PYRALOGS_HOST", "0.0.0.0") +PORT = int(os.environ.get("PYRALOGS_PORT", "6544")) # SMTP host/port -smtp_host = os.environ.get("PYRAFILES_SMTP_HOST", "localhost") -smtp_port = int(os.environ.get("PYRAFILES_SMTP_PORT", "25")) +smtp_host = os.environ.get("PYRALOGS_SMTP_HOST", "localhost") +smtp_port = int(os.environ.get("PYRALOGS_SMTP_PORT", "25")) ################################################################################ # Helper Functions @@ -94,23 +149,24 @@ 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): - log.debug("======= Email Sent =======") - log.debug(f"To: {to_email}") - log.debug(f"Subject: {subject}") - log.debug(f"Body:\n{body}") - log.debug("==========================") - +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") msg = MIMEText(body) msg["Subject"] = subject - msg["From"] = "noreply@example.com" + msg["From"] = from_email msg["To"] = to_email - try: s = smtplib.SMTP(smtp_host, smtp_port) - s.sendmail("noreply@example.com", [to_email], msg.as_string()) + s.sendmail(from_email, [to_email], msg.as_string()) s.quit() except Exception as e: + log = logging.getLogger(__name__) + log.info("======= Email Sent =======") + log.info(f"To: {to_email}") + log.info(f"Subject: {subject}") + log.info(f"Body:\n{body}") + log.info("==========================") log.error(f"Error sending email: {e}") @@ -133,48 +189,34 @@ 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; 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 + # No valid user in session; return None + return None - # 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, +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() ) - 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 + if association: + return association.role + else: + return None def uuid_to_short_id(u): @@ -193,39 +235,21 @@ 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). - Converts to ASCII, replacing or removing characters that won't encode. - """ - normalized = unicodedata.normalize("NFKD", filename) - ascii_bytes = normalized.encode("ascii", "ignore") # drop non-ASCII - safe = ascii_bytes.decode("ascii") - # Replace any remaining bad chars with underscores - # e.g. keep alphanumerics, dots, underscores, hyphens, etc. - safe = re.sub(r"[^A-Za-z0-9._-]+", "_", safe) - return safe or "download" +def 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" ################################################################################ @@ -237,44 +261,86 @@ 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): - __tablename__ = "media" +class Namespace(Base): + __tablename__ = "namespaces" id = Column(String, primary_key=True) # UUID short_id = Column(String, unique=True, nullable=False) - user_id = Column(String, nullable=False) # Owner's UUID - filename = Column(String, nullable=False) - title = Column(String, nullable=True) # Optional title - media_type = Column(String, nullable=False) # 'image', 'audio', 'video' - media_b64 = Column(String, nullable=False) - upload_date = Column(DateTime, default=datetime.datetime.utcnow) - is_public = Column(Boolean, default=True) - size = Column(Integer, nullable=False) # Size in bytes + name = Column(String, unique=True, nullable=False) + is_public = Column(Boolean, default=False) - __table_args__ = ( - Index("ix_media_id", "id"), - Index("ix_media_short_id", "short_id"), + # 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")) + 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"" + ) ################################################################################ @@ -285,7 +351,7 @@ class Media(Base): @subscriber(IJinja2Environment) def add_jinja2_filters(event): env = event.environment - env.filters["filesizeformat"] = filesizeformat + # Add any custom Jinja2 filters here ################################################################################ @@ -293,46 +359,170 @@ 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_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 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(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 ################################################################################ -# Routes +# 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 ################################################################################ @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 +626,52 @@ 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() + # Authentication cookies are used for other routes. 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 +# 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 ################################################################################ @@ -465,22 +682,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, } @@ -490,7 +702,8 @@ 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" @@ -509,467 +722,502 @@ 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 - - request.session.flash(f"User {user.username} imported successfully.") - return HTTPFound(location=request.route_url("home")) - - -################################################################################ -# Media Upload, Listing, and Management -################################################################################ - - -@view_config( - route_name="upload_media", request_method="GET", renderer="upload_media.html.j2" -) -def upload_media_get_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to upload media.", status=403) - return {"request": request} - - -@view_config(route_name="upload_media", request_method="POST") -def upload_media_post_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to upload media.", status=403) - - user_dbsession = request.user_dbsession # Assume this exists for verified users - - media_file = request.POST.get("media_file") - if media_file is None or not getattr(media_file, "filename", "").strip(): - return Response("No file uploaded.", status=400) - - raw_bytes = media_file.file.read() - max_size = 30 * 1024 * 1024 # 30 MB - if len(raw_bytes) > max_size: - return Response("File size exceeds the 30MB limit.", status=400) - - file_size = len(raw_bytes) - - # Determine media type based on MIME type - filename = media_file.filename - mime_type = get_mime_type(filename) - if not mime_type.startswith(("image/", "audio/", "video/")): - return Response("Unsupported media type.", status=400) - - media_type = mime_type.split("/")[0] - - # Get title from form - title = request.POST.get("title", "").strip() - - # If user did not enter a title, default to the original filename - if not title: - title = filename - - # Encode content to base64 - encoded_str = base64.b64encode(raw_bytes).decode("utf-8") - - # Generate UUID and short ID for the media - media_uuid = uuid.uuid4() - media_id = str(media_uuid) - media_short_id = uuid_to_short_id(media_uuid) - - is_public = request.POST.get("is_public") == "on" - - media = Media( - id=media_id, - short_id=media_short_id, - user_id=user.id, - filename=filename, - title=title, # now has a fallback of filename - media_type=media_type, - media_b64=encoded_str, - is_public=is_public, - size=file_size, + # 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, ) - user_dbsession.add(media) - user_dbsession.flush() + NamespaceBase.metadata.create_all(engine) + engine.dispose() return HTTPFound( location=request.route_url( - "view_media_details", - user_short_id=user.short_id, - media_short_id=media.short_id, + "manage_namespace", namespace_short_id=namespace.short_id ) ) -@view_config(route_name="list_media", renderer="list_media.html.j2") -def list_media_view(request): - # Aggregate public media from all verified users +@view_config(route_name="manage_namespace", renderer="manage_namespace.html.j2") +@editor_required +def manage_namespace_view(request): + namespace = request.namespace s = request.dbsession - users = s.query(User).filter(User.is_verified == True).all() - media_list = [] - for user in users: - user_dbsession = get_user_dbsession_by_user_id(user.id, request) - if not user_dbsession: - continue - media_items = user_dbsession.query(Media).filter(Media.is_public == True).all() - for m in media_items: - media_list.append( - { - "media": m, - "username": user.username, - "user_short_id": user.short_id, - } - ) - # Sort media by upload date (recent first) - media_list.sort(key=lambda x: x["media"].upload_date, reverse=True) - - return { - "request": request, - "media_list": media_list, - } - - -@view_config(route_name="user_media", renderer="user_media.html.j2") -def user_media_view(request): - user_short_id = request.matchdict.get("user_short_id") - log.debug(f"Looking up user with short_id: {user_short_id}") - - try: - user_uuid = short_id_to_uuid(user_short_id) - if not user_uuid: - log.error(f"Could not convert short_id {user_short_id} to UUID") - return Response("User not found.", status=404) - - s = request.dbsession - user = s.query(User).filter_by(id=str(user_uuid)).first() - log.debug(f"User found: {user}") - - if not user: - return Response("User not found.", status=404) - - viewer = request.user - is_owner = viewer and viewer.id == user.id - - user_dbsession = get_user_dbsession_by_user_id(user.id, request) - if not user_dbsession: - return Response("User has no uploads.", status=404) - - if is_owner: - media_items = user_dbsession.query(Media).all() - else: - media_items = ( - user_dbsession.query(Media).filter(Media.is_public == True).all() - ) - - media_items.sort(key=lambda m: m.upload_date, reverse=True) - - return { - "request": request, - "media_items": media_items, - "user": user, - "is_owner": is_owner, - } - - except Exception as e: - log.exception(f"Error processing user_short_id {user_short_id}: {e}") - return Response("Error processing request.", status=500) - - -@view_config(route_name="view_media_details", renderer="view_media_details.html.j2") -def view_media_details_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - if not user_short_id or not media_short_id: - return Response("Invalid request.", status=400) - - s = request.dbsession - user = s.query(User).filter_by(short_id=user_short_id).first() - if not user: - return Response("User not found.", status=404) - user_id = user.id - - user_dbsession = get_user_dbsession_by_user_id(user_id, request) - if not user_dbsession: - return Response("User database not found.", status=404) - - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) - - viewer = request.user - is_owner = viewer and viewer.id == user_id - - if not media.is_public and not is_owner: - return Response("Media not available.", status=403) - - return { - "request": request, - "media": media, - "username": user.username, - "user_short_id": user.short_id, - "is_owner": is_owner, - } - - -@view_config(route_name="delete_media", request_method="POST") -def delete_media_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to delete this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) - - user_dbsession.delete(media) - user_dbsession.flush() - - return HTTPFound( - location=request.route_url("user_media", user_short_id=viewer.short_id) + # 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}) -@view_config( - route_name="edit_media", request_method="GET", renderer="edit_media.html.j2" -) -def edit_media_get_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to edit this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) + # 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, - "media": media, + "namespace": namespace, + "users": users, + "agents": agents, } -@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") +@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 - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) + user_id = request.POST.get("user_id") + new_role = request.POST.get("role") - if viewer.short_id != user_short_id: - return Response("You are not authorized to edit this media.", status=403) + if not user_id or not new_role: + return Response("User ID and new role are required.", status=400) - 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) + if new_role not in ["owner", "editor", "reader"]: + return Response("Invalid role.", status=400) - # Handle title update - new_title = request.POST.get("title", "").strip() - media.title = new_title + # Prevent owners from changing their own role + if user_id == current_user.id: + return Response("Owners cannot change their own role.", status=400) - # 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") + # 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) - media.filename = filename - media.media_type = media_type - media.media_b64 = encoded_str - media.size = file_size + # Update the user's role + association.role = new_role + s.flush() - # Handle public/private update - is_public = request.POST.get("is_public") == "on" - media.is_public = is_public - - user_dbsession.flush() + request.session.flash(f"User's role has been updated to {new_role}.") return HTTPFound( location=request.route_url( - "view_media_details", - user_short_id=viewer.short_id, - media_short_id=media.short_id, + "manage_namespace", namespace_short_id=namespace.short_id ) ) -################################################################################ -# Media Viewing and Downloading -################################################################################ - - -@view_config(route_name="view_media") -def view_media_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - if not user_short_id or not media_short_id: - return Response("Invalid request.", status=400) - +@view_config(route_name="update_namespace", request_method="POST") +@owner_required +def update_namespace_view(request): + namespace = request.namespace s = request.dbsession - user = s.query(User).filter_by(short_id=user_short_id).first() + + # 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) - 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) + # Prevent owners from removing themselves + if user_to_remove.id == current_user.id: + return Response("Owners cannot remove themselves.", 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) + # 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) - viewer = request.user - is_owner = viewer and viewer.id == user_id + # Remove the association + s.delete(association) + s.flush() - if not media.is_public and not is_owner: - return Response("Media not available.", status=403) + # Provide a success message + request.session.flash( + f"User '{user_to_remove.username}' has been removed from the namespace." + ) - media_data = base64.b64decode(media.media_b64) - mime_type = get_mime_type(media.filename) + return HTTPFound( + location=request.route_url( + "manage_namespace", namespace_short_id=namespace.short_id + ) + ) - # 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}" + +@view_config( + route_name="generate_agent_jwt", + request_method="POST", + renderer="display_agent_jwt.html.j2", +) +@editor_required +def generate_agent_jwt_view(request): + namespace = request.namespace + s = request.dbsession + + agent_name = request.POST.get("agent_name", "").strip() + if not agent_name: + return Response("Agent name is required.", 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 + 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: - download_filename = sanitize_filename_for_http_header(media.filename) + # Generate UUID for the new agent + agent_uuid = uuid.uuid4() + agent_id = str(agent_uuid) - # Check if user wants attachment or inline - download = request.GET.get("download", "false").lower() == "true" - content_disposition = "attachment" if download else "inline" + # Create a new agent entry + agent = Agent( + id=agent_id, + name=agent_name, + namespace_id=namespace.id, + token_version=0, + status="active", + ) - response = Response(body=media_data, content_type=mime_type) - response.headers.update( - { - "Access-Control-Allow-Origin": "*", - "Content-Disposition": f'{content_disposition}; filename="{download_filename}"', - } + 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") +@editor_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() ) - return response + 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 + ) + ) + + +################################################################################ +# Logging Views +################################################################################ + + +@view_config(route_name="log_webhook", request_method="POST") +def log_webhook_view(request): + s = request.dbsession + + # 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) + + # 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) + + # Check agent status + if agent.status != "active": + return Response("Agent is revoked or inactive.", status=401) + + # Check token_version + if payload.get("token_version") != agent.token_version: + return Response("Token has been revoked.", status=401) + + # 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 + + # 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), + ) + ) + + logs = query.order_by(LogEntry.timestamp.desc()).all() + + return { + "request": request, + "namespace": namespace, + "logs": logs, + "search_query": search_query, + } ################################################################################ @@ -977,13 +1225,13 @@ def view_media_view(request): ################################################################################ -def main(global_config=None, **settings): +def main(*config, **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=pyralogs_secret, hashalg="sha512", timeout=31104000, # Approx. one year in seconds max_age=31104000, # Set Max-Age attribute on cookie @@ -999,7 +1247,12 @@ def main(global_config=None, **settings): config = Configurator(settings=settings, session_factory=session_factory) config.include("pyramid_jinja2") - config.include("pyramid_tm") # Include pyramid_tm for transaction management + # 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.add_jinja2_renderer(".j2") @@ -1009,16 +1262,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): @@ -1026,41 +1279,56 @@ 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, 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 + ) # Routes config.add_route("home", "/") - # Auth + # Auth for cookie sessions. config.add_route("login", "/auth/login") config.add_route("verify", "/auth/verify") config.add_route("logout", "/auth/logout") config.add_route("profile", "/auth/profile") - # Export and Import User Record (Admin only for import) - config.add_route("export_user_record", "/auth/export_user_record") - config.add_route("import_user_record", "/admin/import_user_record") - - # Media - config.add_route("upload_media", "/media/upload") - config.add_route("list_media", "/media/list") - config.add_route("user_media", "/media/user/{user_short_id}") + # 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( - "view_media_details", "/media/{user_short_id}/{media_short_id}/details" + "change_member_role", "/namespace/{namespace_short_id}/change_member_role" ) - 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") + # Routes to generate JWTs for agents without access to an email mailbox. + config.add_route("revoke_agent", "/namespace/{namespace_short_id}/revoke_agent") + config.add_route( + "generate_agent_jwt", "/namespace/{namespace_short_id}/generate_agent_jwt" + ) + + # Logging + config.add_route("log_webhook", "/webhook") + config.add_route("view_namespace_logs", "/namespace/{namespace_short_id}/logs") 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 0c45815..00d0298 100644 --- a/initialize_db.py +++ b/initialize_db.py @@ -1,18 +1,14 @@ #!/usr/bin/env python """ -Initialize or upgrade the main database for pyrafiles. +Initialize or upgrade the main database for PyraLogs. 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`. - If the database file/tables do not exist, they will be created. + 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. """ import os @@ -24,11 +20,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 (Adjust if needed) +from app import Base, DATA_DIR # Make sure app.py defines Base and DATA_DIR def usage(): script = os.path.basename(sys.argv[0]) @@ -39,19 +32,25 @@ def usage(): def main(): logging.basicConfig(level=logging.INFO) - if len(sys.argv) > 2: - # We only expect optional arguments. If needed, parse them here. + + # We do not allow any command-line arguments. + if len(sys.argv) > 1: 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}") + + # Always use the default path from app.py for the database + db_url = f"sqlite:///{os.path.join(DATA_DIR, 'main.db')}" logging.info(f"Using DB URL: {db_url}") - # Set up engine + # Set up the engine engine = create_engine( db_url, - connect_args={"check_same_thread": False} if "sqlite" in db_url else {}, - poolclass=StaticPool if "sqlite" in db_url else None, + connect_args={"check_same_thread": False}, + poolclass=StaticPool ) SessionFactory = sessionmaker(bind=engine) @@ -65,4 +64,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..4c119d6 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,603 @@ +openapi: 3.0.3 +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 + +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. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + 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 + + /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. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + 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: + 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 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 diff --git a/requirements.txt b/requirements.txt index bd0af6c..d88d1a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,10 @@ pyramid_debugtoolbar waitress pyramid_retry +pyramid_jwt + +pyramid_openapi3 + pyramid_tm zope.sqlalchemy diff --git a/templates/base.html.j2 b/templates/base.html.j2 index 24ae59c..614ab6c 100644 --- a/templates/base.html.j2 +++ b/templates/base.html.j2 @@ -2,7 +2,7 @@ - {% block title %}Media Hosting App{% endblock %} + {% block title %}PyraLogs{% endblock %}