Compare commits

..

1 commit

Author SHA1 Message Date
beb4f96d81 halp, why doesn't this work? getting http 404
modified:   app.py
	new file:   openapi.yaml
	modified:   requirements.txt
2025-01-07 08:03:27 -05:00
22 changed files with 1095 additions and 1965 deletions

6
.gitignore vendored
View file

@ -2,10 +2,4 @@
*.swp *.swp
env/ env/
__pycache__/ __pycache__/
data/
cookies.txt cookies.txt
jwt_secret.txt
pyralogs_secret.txt

View file

@ -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} <<EOF
[Unit]
Description=uWSGI instance to serve ${APP_NAME}
After=network.target
[Service]
WorkingDirectory=${APP_DIR}
Environment="PATH=${VENV_DIR}/bin"
ExecStart=${VENV_DIR}/bin/uwsgi \\
--master \\
--enable-threads \\
--processes 4 \\
--http-socket ${UWSGI_SOCKET} \\
--chmod-socket=660 \\
--chown-socket=gitlab-runner:caddy \\
--vacuum \\
--die-on-term \\
--chdir ${APP_DIR} \\
--logto2 ${APP_DIR}/uwsgi.log \\
--module ${MODULE}
Restart=always
KillSignal=SIGQUIT
Type=notify
NotifyAccess=all
[Install]
WantedBy=default.target
EOF
# Step 6: Reload systemd user daemon and restart the uWSGI service
- systemctl --user daemon-reload
- systemctl --user restart ${SERVICE_NAME}
- systemctl --user enable ${SERVICE_NAME}
# Step 7: Handle Caddy configurations
- |
# Create Caddy snippets directory if it doesn't exist
mkdir -p "${CADDY_SNIPPETS_DIR}"
# Write the application's Caddy configuration to its own snippet file
cat > "${CADDY_SNIPPETS_DIR}/${APP_NAME}.caddy" <<EOF
${APP_NAME} {
encode gzip
tls {
on_demand
}
handle {
reverse_proxy unix//${UWSGI_SOCKET}
}
}
EOF
# Concatenate all snippets into the main Caddyfile
echo '# Main Caddyfile - concatenated snippets' > "${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."

View file

@ -1,19 +1,18 @@
================================ ===========================================
PyraLogs - A Centralized Logging Hub pyrafiles - A Sophisticated Pyramid Project
================================ ===========================================
**PyraLogs** is a **public domain** application built on the `Pyramid <https://trypyramid.com>`_ 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 namespaces personal DB file handles their specific logs. ``pyrafiles`` is a **public domain** application built on the `Pyramid <https://trypyramid.com>`_ 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 users personal DB file handles their specific uploads.
Key Features Key Features
============ ============
- **Passwordless Login** using email verification codes. - **Passwordless Login** using email verification codes.
- **Namespace-Based Logging** with per-namespace SQLite databases. - **Guest Sessions** for unverified visitors.
- **JWT Authentication for Agents**, enabling secure log submissions. - **Per-user SQLite** databases for media uploads (limit ~30MB each).
- **Token Versioning** for per-agent token revocation without affecting others. - **Public/Private** media visibility controls.
- **Public/Private** namespace visibility controls. - **Admin Tools** (import user records, manage site settings).
- **Admin Tools** for managing namespaces, agents, and user access. - **Agent-friendly** architecture: easily scriptable endpoints for login, verification, media uploads, etc.
- **Agent-Friendly** architecture: easily scriptable endpoints for logging and management.
- **Configurable** via environment variables (including secret keys). - **Configurable** via environment variables (including secret keys).
Git Repository Git Repository
@ -21,36 +20,32 @@ Git Repository
The project is maintained at: The project is maintained at:
- `PyraLogs Git Repo <https://git.unturf.com/engineering/unturf/logs.unturf.com>`_ - `pyrafiles Git Repo <https://git.unturf.com/engineering/unturf/upload.unturf.com>`_
Since this is public domain, you can adapt and redistribute it freely. Since this is public domain, you can adapt and redistribute it freely.
Configuration via Environment Configuration via Environment
============================= =============================
PyraLogs fetches settings from environment variables with sensible defaults: `pyrafiles` fetches settings from environment variables with sensible defaults:
- ``PYRALOGS_SECRET`` - ``PYRAFILES_SECRET``
The secret key for cookie session signing. The secret key for session signing.
If missing, PyraLogs automatically generates a **random 64-character** string at runtime. If missing, ``pyrafiles`` automatically generates a **random 64-character** string at runtime & log all users out.
Remove this file or change this value to log our all cookie sessions for all namespaces.
- ``PYRALOGS_JWT_SECRET`` - ``PYRAFILES_DB_URL``
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``. 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). 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``. SMTP server details. Defaults: ``localhost:25``.
- Any other environment variables you wish to incorporate can be accessed in the code. - Any other environment variables you wish to incorporate can be accessed in the code.
Local Setup Local Setup
=========== ===========
@ -58,8 +53,8 @@ Local Setup
.. code-block:: bash .. code-block:: bash
git clone https://git.unturf.com/engineering/unturf/logs.unturf.com.git git clone https://git.unturf.com/engineering/unturf/upload.unturf.com.git
cd logs.unturf.com cd upload.unturf.com
2. **Create a Virtual Environment** 2. **Create a Virtual Environment**
@ -76,78 +71,126 @@ Local Setup
pip install -r requirements.txt 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 .. code-block:: bash
python initialize_db.py python initialize_db.py
5. **Run** Otherwise:
.. code-block:: bash .. code-block:: bash
# Optionally set PYRALOGS_SECRET if you want a custom secret. # Optionally set PYRAFILES_SECRET if you want a custom secret.
export PYRALOGS_SECRET="YOUR_OWN_LONG_RANDOM_STRING" export PYRAFILES_SECRET="YOUR_OWN_LONG_RANDOM_STRING"
python app.py 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://<HOST>:<PORT>` according to your environment variables. Point your browser to `http://localhost:6544` or `http://<HOST>:<PORT>` according to your environment variables.
Example: Agent Workflow Script Example: Agent Workflow Script
============================== ==============================
Below is a sample Bash script showing how an **agent** might: Below is a sample Bash script showing how an **agent** might:
1. Obtain a JWT for logging. 1. Start the session (to get a cookie).
2. Send logs to the PyraLogs server using the JWT. 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 .. code-block:: bash
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Usage: # Usage:
# ./test_agent.sh "Your log message" # MAX_COOKIE_AGE=1800 ./agent_upload.sh /path/to/somefile.jpg
# #
# Description: # 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: # Notes:
# - Replace <YOUR_NAMESPACE_JWT> with your actual JWT token. # 1. If not set, MAX_COOKIE_AGE defaults to 31536000 (one year).
# - Replace <NAMESPACE_SHORT_ID> with your namespace's short ID. # 2. We assume the server runs on http://localhost:6544. Adjust BASE_URL if needed.
# - Adjust BASE_URL if the server is running elsewhere.
BASE_URL="${BASE_URL:-http://localhost:6544}" BASE_URL="${BASE_URL:-http://localhost:6544}"
JWT_TOKEN="<YOUR_NAMESPACE_JWT>" EMAIL="${EMAIL:-agent@example.com}"
LOG_MESSAGE="$1" MEDIA_FILE="$1"
# Default to 1 year in seconds if not provided:
if [[ -z "$LOG_MESSAGE" ]]; then MAX_COOKIE_AGE="${MAX_COOKIE_AGE:-31536000}"
echo "Usage: $0 \"Your log message\""
if [[ -z "$MEDIA_FILE" ]]; then
echo "Usage: MAX_COOKIE_AGE=<seconds> $0 /path/to/mediafile"
exit 1 exit 1
fi 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 Dockerfile with Caddy + uWSGI
============================= =============================
Below is an example Dockerfile that: 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). - Uses **Caddy** as a reverse proxy (and optionally HTTPS if configured).
- Defines a **volume** for databases (so they are stored on the host). - 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 # Copy uWSGI config if desired
# For example (assume you created uwsgi.ini in your repo): # For example (assume you created uwsgi.ini in your repo):
# [uwsgi] # [uwsgi]
# module = app:app # module = main:app
# master = true # master = true
# processes = 4 # processes = 4
# socket = 127.0.0.1:8080 # socket = 127.0.0.1:8080
@ -193,19 +236,22 @@ Below is an example Dockerfile that:
COPY uwsgi.ini /app/uwsgi.ini COPY uwsgi.ini /app/uwsgi.ini
# Environment variables (optional overrides). # Environment variables (optional overrides).
ENV PYRALOGS_DB_URL="sqlite:///data/main.db" # If PYRAFILES_SECRET is empty, the app itself generates a random 64-char secret & logs all users out.
ENV PYRALOGS_HOST="0.0.0.0" ENV PYRAFILES_SECRET=""
ENV PYRALOGS_PORT="6544" ENV PYRAFILES_DB_URL="sqlite:///data/main.db"
ENV PYRAFILES_HOST="0.0.0.0"
ENV PYRAFILES_PORT="6544"
# Expose HTTP and HTTPS # Expose HTTP and HTTPS
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
# Define volume so host can persist databases outside the container # Define volume so host can persist user DBs outside the container
# We'll store database files in /data # We'll store DB files in /data
VOLUME ["/data"] VOLUME ["/data"]
# Command starts uWSGI and then starts Caddy # Command starts uWSGI and then starts Caddy
# The app itself checks for PYRAFILES_SECRET and auto-generates one if missing.
CMD ["/bin/sh", "-c", "\ CMD ["/bin/sh", "-c", "\
uwsgi --ini /app/uwsgi.ini & \ uwsgi --ini /app/uwsgi.ini & \
caddy run --config /etc/caddy/Caddyfile \ caddy run --config /etc/caddy/Caddyfile \
@ -213,8 +259,8 @@ Below is an example Dockerfile that:
.. note:: .. 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``. - 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 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. - 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.: - Make sure to **mount** a volume at ``/data`` when you run the container, e.g.:
.. code-block:: bash .. code-block:: bash
@ -222,34 +268,36 @@ Below is an example Dockerfile that:
docker run -d \ docker run -d \
-p 80:80 -p 443:443 \ -p 80:80 -p 443:443 \
-v /my/local/dbfolder:/data \ -v /my/local/dbfolder:/data \
--name pyralogs \ --name pyrafiles \
pyralogs-image:latest 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 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. - 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. - 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 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 <https://git.unturf.com/engineering/unturf/upload.unturf.com>`_.
- For general inquiries, you can reach out to the maintainers directly.
- **Issues**: Please open tickets at the `PyraLogs project page <https://git.unturf.com/engineering/unturf/logs.unturf.com>`_. We hope `pyrafiles` helps you get up and running quickly with a flexible media-sharing and multi-DB infrastructure!
- For general inquiries, you can reach out to the maintainers directly.
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!

1396
app.py

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,18 @@
#!/usr/bin/env python #!/usr/bin/env python
""" """
Initialize or upgrade the main database for PyraLogs. Initialize or upgrade the main database for pyrafiles.
Usage: Usage:
python initialize_db.py 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: Description:
Creates or updates all tables referenced by `Base.metadata`. Creates or updates all tables referenced by `Base.metadata`.
The database file will be created (if it does not exist) in the If the database file/tables do not exist, they will be created.
DATA_DIR as specified in the app file. No custom paths are allowed.
""" """
import os import os
@ -20,8 +24,11 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
# Import Base and DATA_DIR from your app.py (Adjust if needed) # Adjust these imports to your actual project structure:
from app import Base, DATA_DIR # Make sure app.py defines Base and DATA_DIR # 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(): def usage():
script = os.path.basename(sys.argv[0]) script = os.path.basename(sys.argv[0])
@ -32,25 +39,19 @@ def usage():
def main(): def main():
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
if len(sys.argv) > 2:
# We do not allow any command-line arguments. # We only expect optional arguments. If needed, parse them here.
if len(sys.argv) > 1:
usage() usage()
# Ensure DATA_DIR exists # Read environment variable for DB URL
if not os.path.exists(DATA_DIR): db_url = os.environ.get("PYRAFILES_DB_URL", "sqlite:///main.db")
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}") logging.info(f"Using DB URL: {db_url}")
# Set up the engine # Set up engine
engine = create_engine( engine = create_engine(
db_url, db_url,
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False} if "sqlite" in db_url else {},
poolclass=StaticPool poolclass=StaticPool if "sqlite" in db_url else None,
) )
SessionFactory = sessionmaker(bind=engine) SessionFactory = sessionmaker(bind=engine)
@ -64,3 +65,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -1,47 +1,11 @@
openapi: 3.0.3 openapi: 3.0.0
info: info:
title: PyraLogs API title: PyraFiles API
version: 1.0.0 version: '1.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: 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: /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: post:
summary: Process Login summary: User login
description: Sends a verification code to the user's email.
requestBody: requestBody:
required: true required: true
content: content:
@ -51,36 +15,12 @@ paths:
properties: properties:
email: email:
type: string type: string
format: email
required:
- email
responses: responses:
'302': '302':
description: Redirects to the verification page description: Redirect to 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: /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: post:
summary: Verify User summary: Verify user code
description: Verifies the user's code and logs them in.
requestBody: requestBody:
required: true required: true
content: content:
@ -90,514 +30,24 @@ paths:
properties: properties:
code: code:
type: string type: string
required:
- code
responses: responses:
'302': '302':
description: Redirects to the home page upon successful verification description: Redirect to home page
headers: /media/upload:
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: post:
summary: Logout User summary: Upload media file
description: Logs out the current user.
security:
- sessionAuth: []
requestBody: requestBody:
required: true required: true
content: content:
application/x-www-form-urlencoded: multipart/form-data:
schema: schema:
type: object type: object
properties: properties:
csrf_token: media_file:
type: string
format: binary
title:
type: string type: string
responses: responses:
'302': '302':
description: Redirects to the home page description: Redirect to media details 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

View file

@ -5,13 +5,11 @@ pyramid_debugtoolbar
waitress waitress
pyramid_retry pyramid_retry
pyramid_jwt
pyramid_openapi3
pyramid_tm pyramid_tm
zope.sqlalchemy zope.sqlalchemy
bcrypt bcrypt
sqlalchemy sqlalchemy
werkzeug werkzeug
pyramid_openapi3

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}PyraLogs{% endblock %}</title> <title>{% block title %}Media Hosting App{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://www.unturf.com/css/pico.classless.min.css" rel="stylesheet"> <link href="https://www.unturf.com/css/pico.classless.min.css" rel="stylesheet">
<style> <style>
@ -39,26 +39,6 @@
margin: 0; margin: 0;
} }
button.link-button {
width: auto;
display: inline;
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--pico-primary);
text-decoration: none;
cursor: pointer;
}
button.link-button:hover {
text-decoration: underline;
}
form.inline-form {
display: inline;
}
/* Hamburger Menu Button */ /* Hamburger Menu Button */
#mobile-menu-button { #mobile-menu-button {
display: none; display: none;
@ -119,21 +99,16 @@
</head> </head>
<body> <body>
<nav> <nav>
<a href="{{ request.route_url('home') }}"><strong>{{ request.domain }}</strong></a> <a href="{{ request.route_url('home') }}"><strong>upload.unturf.com</strong></a>
<!-- Hamburger Menu Button (only visible on mobile) --> <!-- Hamburger Menu Button (only visible on mobile) -->
<button id="mobile-menu-button" aria-label="Open Menu">☰</button> <button id="mobile-menu-button" aria-label="Open Menu">☰</button>
<!-- Navigation Links --> <!-- Navigation Links -->
<ul> <ul>
<li><a href="{{ request.route_url('home') }}">Home</a></li> <li><a href="{{ request.route_url('list_media') }}">Browse Media</a></li>
{% if request.user and request.user.is_verified %} {% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li> <li><a href="{{ request.route_url('upload_media') }}">Upload Media</a></li>
<li><a href="{{ request.route_url('profile') }}">Profile</a></li> <li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li> <li><a href="{{ request.route_url('logout') }}">Logout</a></li>
<form action="{{ request.route_url('logout') }}" method="post" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<button type="submit" class="link-button">Logout</button>
</form>
</li>
{% else %} {% else %}
<li><a href="{{ request.route_url('login') }}">Login</a></li> <li><a href="{{ request.route_url('login') }}">Login</a></li>
{% endif %} {% endif %}
@ -146,16 +121,11 @@
<button aria-label="Close Menu" id="mobile-menu-close">✕</button> <button aria-label="Close Menu" id="mobile-menu-close">✕</button>
<nav> <nav>
<ul> <ul>
<li><a href="{{ request.route_url('home') }}">Home</a></li> <li><a href="{{ request.route_url('list_media') }}">Browse Media</a></li>
{% if request.user and request.user.is_verified %} {% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li> <li><a href="{{ request.route_url('upload_media') }}">Upload Media</a></li>
<li><a href="{{ request.route_url('profile') }}">Profile</a></li> <li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li> <li><a href="{{ request.route_url('logout') }}">Logout</a></li>
<form action="{{ request.route_url('logout') }}" method="post" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<button type="submit" class="link-button">Logout</button>
</form>
</li>
{% else %} {% else %}
<li><a href="{{ request.route_url('login') }}">Login</a></li> <li><a href="{{ request.route_url('login') }}">Login</a></li>
{% endif %} {% endif %}
@ -164,23 +134,18 @@
</article> </article>
</dialog> </dialog>
<!-- Flash messages -->
{% if request.session.peek_flash() %}
{% for message in request.session.pop_flash() %}
<div class="alert">{{ message }}</div>
{% endfor %}
{% endif %}
<main class="container"> <main class="container">
<!-- Flash messages -->
{% if request.session.peek_flash() %}
{% for message in request.session.pop_flash() %}
<div class="alert"><mark>{{ message }}</mark></div>
<br/>
{% endfor %}
{% endif %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</main> </main>
<footer>
<a href="https://git.unturf.com/engineering/unturf/logs.unturf.com">PyraLogs public domain source code</a>
</footer>
<!-- JavaScript for Mobile Menu --> <!-- JavaScript for Mobile Menu -->
<script> <script>
const mobileMenuButton = document.getElementById('mobile-menu-button'); const mobileMenuButton = document.getElementById('mobile-menu-button');

View file

@ -1,18 +0,0 @@
{% extends 'base.html.j2' %}
{% block title %}Create Namespace{% endblock %}
{% block content %}
<h1>Create a New Namespace</h1>
<form method="POST">
<label for="name">Namespace Name:</label>
<input type="text" name="name" required><br><br>
<label>
<input type="checkbox" name="is_public">
Make Namespace Public
</label><br><br>
<button type="submit">Create Namespace</button>
</form>
{% endblock %}

View file

@ -1,16 +0,0 @@
{% extends 'base.html.j2' %}
{% block title %}Agent JWT{% endblock %}
{% block content %}
<h1>JWT for Agent '{{ agent_name }}'</h1>
{% if message %}
<p>{{ message }}</p>
{% endif %}
<p>Please store this JWT securely. It will not be shown again.</p>
<pre>{{ jwt_token }}</pre>
<p>You can use this JWT to authenticate your agent when sending logs to the namespace '{{ namespace.name }}'.</p>
<p><a href="{{ request.route_url('manage_namespace', namespace_short_id=namespace.short_id) }}">Back to Manage Namespace</a></p>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends 'base.html.j2' %}
{% block title %}Edit Media{% endblock %}
{% block content %}
<h1>Edit Media</h1>
<form method="POST" enctype="multipart/form-data">
<label for="title">Title (optional):</label>
<input type="text" name="title" value="{{ media.title }}" placeholder="Enter a title for the media"><br><br>
<label for="media_file">Replace Media File (leave blank to keep current):</label>
<input type="file" name="media_file" accept="image/*,audio/*,video/*"><br><br>
<label>
<input type="checkbox" name="is_public" {% if media.is_public %}checked{% endif %}>
Make Media Public
</label><br><br>
<button type="submit">Update</button>
</form>
<p>
<!-- View and Download links -->
<a href="{{ request.route_url('view_media', user_short_id=request.user.short_id, media_short_id=media.short_id) }}">View</a> |
<a href="{{ request.route_url('view_media', user_short_id=request.user.short_id, media_short_id=media.short_id, _query={'download': 'true'}) }}">Download</a> |
<a href="{{ request.route_url('view_media_details', user_short_id=request.user.short_id, media_short_id=media.short_id) }}">Back to Media Details</a>
</p>
<br/>
<form action="{{ request.route_url('delete_media', user_short_id=request.user.short_id, media_short_id=media.short_id) }}" method="post">
<button type="submit" onclick="return confirm('Are you sure you want to delete this media?');" style="border: 0px; background-color: #AA0000; color: white;">Delete</button>
</form>
{% endblock %}

View file

@ -3,7 +3,7 @@
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% block content %} {% block content %}
<h1>Welcome to PyraLogs</h1> <h1>Welcome to the Media Hosting App</h1>
<p> <p>
{% if request.user and request.user.is_verified %} {% if request.user and request.user.is_verified %}
Hello, {{ request.user.username }}! Hello, {{ request.user.username }}!
@ -11,38 +11,5 @@
Welcome, Guest! Welcome, Guest!
{% endif %} {% endif %}
</p> </p>
<p>This application allows environments and agents to centrally log events to this hub. You can create namespaces to organize logs and control access.</p> <p>This application allows authenticated users to upload images, audio, and short videos. Guests can browse and download publicly available media.</p>
{% if request.user and request.user.is_verified %}
<h2>Your Namespaces</h2>
{% if user_namespaces %}
<ul>
{% for ns in user_namespaces %}
<li>
<a href="{{ request.route_url('view_namespace_logs', namespace_short_id=ns.short_id) }}">{{ ns.name }} logs</a>
{% if ns.role == 'editor' or ns.role == 'owner' %}
| <a href="{{ request.route_url('manage_namespace', namespace_short_id=ns.short_id) }}">Manage</a>
{% endif %}
<br>
</li>
{% endfor %}
</ul>
{% else %}
<p>You are not a member of any namespaces.</p>
{% endif %}
{% endif %}
<h2>Public Namespaces</h2>
{% if public_namespaces %}
<ul>
{% for ns in public_namespaces %}
<li>
<a href="{{ request.route_url('view_namespace_logs', namespace_short_id=ns.short_id) }}">{{ ns.name }} logs</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>No public namespaces available.</p>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,12 @@
{% extends 'base.html.j2' %}
{% block title %}Import User Record{% endblock %}
{% block content %}
<h1>Import User Record</h1>
<form method="POST" enctype="multipart/form-data">
<label for="user_record_file">Select User Record JSON File:</label>
<input type="file" name="user_record_file" accept="application/json" required><br><br>
<button type="submit">Import</button>
</form>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends 'base.html.j2' %}
{% block title %}Media List{% endblock %}
{% block content %}
<h1>Media List</h1>
{% if media_list %}
<ul>
{% for media_item in media_list %}
{% set media = media_item.media %}
<li>
<strong>{{ media.title if media.title else media.filename }}</strong><br>
Uploaded by: <a href="{{ request.route_url('user_media', user_short_id=media_item.user_short_id) }}">{{ media_item.username }}</a><br>
<!-- View and Download links -->
<a href="{{ request.route_url('view_media', user_short_id=media_item.user_short_id, media_short_id=media.short_id) }}">View</a> |
<a href="{{ request.route_url('view_media', user_short_id=media_item.user_short_id, media_short_id=media.short_id, _query={'download': 'true'}) }}">Download</a> |
<!-- Link to media detail page -->
<a href="{{ request.route_url('view_media_details', user_short_id=media_item.user_short_id, media_short_id=media.short_id) }}">Details</a>
{% if request.user and request.user.id == media.user_id %}
| <a href="{{ request.route_url('edit_media', user_short_id=media_item.user_short_id, media_short_id=media.short_id) }}">Edit</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No media found.</p>
{% endif %}
{% endblock %}

View file

@ -1,100 +0,0 @@
{% extends 'base.html.j2' %}
{% block title %}Manage Namespace{% endblock %}
{% block content %}
<h1>Manage Namespace: {{ namespace.name }}</h1>
<form method="POST" action="{{ request.route_url('update_namespace', namespace_short_id=namespace.short_id) }}">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label>
<input type="checkbox" name="is_public" {% if namespace.is_public %}checked{% endif %}>
Make Namespace Public
</label><br><br>
<button type="submit">Update Namespace</button>
</form>
<!-- Only owners can see the user management section -->
{% if request.user_namespace_role == 'owner' %}
<h2>Users in Namespace</h2>
{% for member in users %}
<div>
<span>
{{ member.user.username }}
{% if request.user.id != member.user.id %}
<!-- Form to remove user -->
<form action="{{ request.route_url('remove_user', namespace_short_id=namespace.short_id) }}" method="post" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<input type="hidden" name="user_id" value="{{ member.user.id }}">
<button type="submit" class="link-button">Remove</button>
</form>
{% endif %}
</span>
<span>
{% if request.user.id != member.user.id %}
<!-- Form to change role -->
<form action="{{ request.route_url('change_member_role', namespace_short_id=namespace.short_id) }}" method="post" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<input type="hidden" name="user_id" value="{{ member.user.id }}">
<select name="role">
<option value="owner" {% if member.role == 'owner' %}selected{% endif %}>Owner</option>
<option value="editor" {% if member.role == 'editor' %}selected{% endif %}>Editor</option>
<option value="reader" {% if member.role == 'reader' %}selected{% endif %}>Reader</option>
</select>
<button type="submit" class="link-button">Change Role</button>
</form>
{% else %}
{{ member.role }}
{% endif %}
</span>
</div>
<hr />
{% endfor %}
<h2>Invite User</h2>
<form method="POST" action="{{ request.route_url('invite_user', namespace_short_id=namespace.short_id) }}">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="email">User Email:</label>
<input type="email" name="email" required><br><br>
<label for="role">Role:</label>
<select name="role" required>
<option value="owner">Owner</option>
<option value="editor">Editor</option>
<option value="reader">Reader</option>
</select><br><br>
<button type="submit">Invite User</button>
</form>
{% endif %}
<!-- Only owners can see the user management section -->
<h2>Agents</h2>
{% if agents %}
<ul>
{% for agent in agents %}
<li>
{{ agent.name }} ({{ agent.status|capitalize }})
<form method="POST" action="{{ request.route_url('revoke_agent', namespace_short_id=namespace.short_id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<input type="hidden" name="agent_id" value="{{ agent.id }}">
<button type="submit" class="link-button" onclick="return confirm('Revoke access for {{ agent.name }}?')">Revoke Access</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>No agents found.</p>
{% endif %}
<h2>Generate JWT for Agent</h2>
<form method="POST" action="{{ request.route_url('generate_agent_jwt', namespace_short_id=namespace.short_id) }}">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="agent_name">Agent Name:</label>
<input type="text" name="agent_name" required><br><br>
<button type="submit">Generate JWT</button>
</form>
{% endblock %}

View file

@ -23,5 +23,15 @@
<button type="submit">Update Profile</button> <button type="submit">Update Profile</button>
</form> </form>
<h2>Your Stats</h2>
<ul>
<li>Total Uploads: {{ total_uploads }}</li>
<li>Total Storage Used: {{ total_size | filesizeformat }}</li>
</ul>
<h2>Your Media</h2>
<p><a href="{{ request.route_url('user_media', user_short_id=user.short_id) }}">View Your Uploads</a></p>
<p><a href="{{ request.route_url('upload_media') }}">Upload New Media</a></p>
<p><a href="{{ request.route_url('download_database') }}">Download Your Database</a></p>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,22 @@
{% extends 'base.html.j2' %}
{% block title %}Upload Media{% endblock %}
{% block content %}
<h1>Upload Media</h1>
<form method="POST" enctype="multipart/form-data">
<label for="media_file">Select Media File (Max 30MB):</label>
<input type="file" name="media_file" accept="image/*,audio/*,video/*" required><br><br>
<label for="title">Title (optional):</label>
<input type="text" name="title" placeholder="Enter a title for the media"><br><br>
<!-- Optionally, allow users to set media as public or private -->
<label>
<input type="checkbox" name="is_public" checked>
Make Media Public
</label><br><br>
<button type="submit">Upload</button>
</form>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends 'base.html.j2' %}
{% block title %}{{ user.username }}'s Media{% endblock %}
{% block content %}
<h1>{{ user.username }}'s Media</h1>
{% if media_items %}
<ul>
{% for media in media_items %}
<li>
<strong>{{ media.title if media.title else media.filename }}</strong><br>
{% if media.is_public %}
<span>Status: Public</span>
{% else %}
<span>Status: Private</span>
{% endif %}<br>
<!-- View and Download links -->
<a href="{{ request.route_url('view_media', user_short_id=user.short_id, media_short_id=media.short_id) }}">View</a> |
<a href="{{ request.route_url('view_media', user_short_id=user.short_id, media_short_id=media.short_id, _query={'download': 'true'}) }}">Download</a> |
<!-- Link to media detail page -->
<a href="{{ request.route_url('view_media_details', user_short_id=user.short_id, media_short_id=media.short_id) }}">Details</a>
{% if request.user and request.user.id == media.user_id %}
| <a href="{{ request.route_url('edit_media', user_short_id=user.short_id, media_short_id=media.short_id) }}">Edit</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No media found.</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends 'base.html.j2' %}
{% block title %}View Media{% endblock %}
{% block content %}
<h1>{{ media.title if media.title else media.filename }}</h1>
<p>Uploaded by <a href="{{ request.route_url('user_media', user_short_id=user_short_id) }}">{{ username }}</a> on {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}</p>
<p>Size: {{ media.size | filesizeformat }}</p>
<!-- Display media based on type -->
{% if media.media_type == 'image' %}
<img src="{{ request.route_url('view_media', user_short_id=user_short_id, media_short_id=media.short_id) }}" alt="{{ media.filename }}">
{% elif media.media_type == 'audio' %}
<audio controls>
<source src="{{ request.route_url('view_media', user_short_id=user_short_id, media_short_id=media.short_id) }}">
Your browser does not support the audio element.
</audio>
{% elif media.media_type == 'video' %}
<video controls width="600">
<source src="{{ request.route_url('view_media', user_short_id=user_short_id, media_short_id=media.short_id) }}">
Your browser does not support the video tag.
</video>
{% endif %}
<!-- View and Download links -->
<p>
<a href="{{ request.route_url('view_media', user_short_id=user_short_id, media_short_id=media.short_id) }}">View</a> |
<a href="{{ request.route_url('view_media', user_short_id=user_short_id, media_short_id=media.short_id, _query={'download': 'true'}) }}">Download</a>
</p>
{% if is_owner %}
<!-- Edit button for the owner -->
<p>
<a href="{{ request.route_url('edit_media', user_short_id=user_short_id, media_short_id=media.short_id) }}">Edit</a>
</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html.j2' %}
{% block title %}Media Details{% endblock %}
{% block content %}
<h1>Media Details</h1>
<p><strong>Title:</strong> {{ media.title if media.title else 'Untitled' }}</p>
<p><strong>Filename:</strong> {{ media.filename }}</p>
<p><strong>Uploaded by:</strong> <a href="{{ request.route_url('user_media', user_short_id=user_short_id) }}">{{ username }}</a></p>
<p><strong>Upload Date:</strong> {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}</p>
<p><strong>Status:</strong> {{ 'Public' if media.is_public else 'Private' }}</p>
<p><strong>Size:</strong> {{ media.size | filesizeformat }}</p>
<!-- View and Download links -->
<a href="{{ request.route_url('view_media', user_short_id=user_short_id, media_short_id=media.short_id) }}">View</a> |
<a href="{{ request.route_url('view_media', user_short_id=user_short_id, media_short_id=media.short_id, _query={'download': 'true'}) }}">Download</a>
{% if is_owner %}
<!-- Edit button for the owner -->
| <a href="{{ request.route_url('edit_media', user_short_id=user_short_id, media_short_id=media.short_id) }}">Edit</a>
{% endif %}
{% endblock %}

View file

@ -1,100 +0,0 @@
{% extends 'base.html.j2' %}
{% block title %}Logs for {{ namespace.name }}{% endblock %}
{% block content %}
<h1>Logs for Namespace: {{ namespace.name }}</h1>
<!-- Search Form -->
<form method="get" action="{{ request.current_route_url() }}">
<input type="text" name="q" value="{{ search_query }}" placeholder="Search logs">
<button type="submit">Search</button>
{% if search_query %}
<a href="{{ request.route_url('view_namespace_logs', namespace_short_id=namespace.short_id) }}">Clear Search</a>
{% endif %}
</form>
<!-- Logs Table -->
<div class="logs-container">
{% if logs %}
<table class="logs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Level</th>
<th>Message</th>
<th>Metadata</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="log-entry">
<td class="log-timestamp">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td class="log-level">{{ log.level }}</td>
<td class="log-message">{{ log.message }}</td>
<td class="log-metadata">
{% if log.log_metadata %}
<pre>{{ log.log_metadata }}</pre>
{% else %}
N/A
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No logs found.</p>
{% endif %}
</div>
<!-- Include JavaScript for highlighting -->
{% if search_query %}
<script>
(function() {
// Function to escape special characters in regex
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
var searchQuery = {{ search_query|tojson }};
if (!searchQuery) {
return;
}
var regex = new RegExp(escapeRegExp(searchQuery), 'gi');
// Function to highlight text
function highlightText(element) {
element.childNodes.forEach(function(node) {
if (node.nodeType === Node.TEXT_NODE && node.parentNode.nodeName !== 'MARK') {
var text = node.textContent;
var html = text.replace(regex, function(match) {
return '<mark>' + match + '</mark>';
});
if (html !== text) {
var temp = document.createElement('span');
temp.innerHTML = html;
// Replace the text node with the new HTML
var fragment = document.createDocumentFragment();
while (temp.firstChild) {
fragment.appendChild(temp.firstChild);
}
node.parentNode.replaceChild(fragment, node);
}
} else if (node.nodeType === Node.ELEMENT_NODE && node.nodeName !== 'SCRIPT') {
highlightText(node);
}
});
}
// Highlight search terms in log entries
document.querySelectorAll('.log-entry td').forEach(function(element) {
highlightText(element);
});
})();
</script>
{% endif %}
{% endblock %}

View file

@ -1,34 +1,150 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#
# A Bash script to demonstrate and test multiple authenticated routes
# in the pyrafiles application (or a similar Pyramid-based app).
#
# This script:
# 1. Checks if cookies.txt is older than a defined threshold (default 1 year).
# - If too old, it deletes cookies.txt so we force re-login.
# 2. If cookies.txt is still valid or absent, proceeds to the next steps:
# - If cookies.txt does not exist or was removed, it does the email + OTP flow.
# - If cookies.txt exists and is fresh, it reuses that session.
# 3. Fetches and prints the Profile page (GET /auth/profile) as a quick test.
# 4. Uploads a sample media file (POST /media/upload), marked public.
# 5. Parses the server's redirect "Location" header to extract user_short_id and media_short_id.
# 6. Views media details (GET /media/<user_short_id>/<media_short_id>/details).
# 7. Edits the newly uploaded media (POST /media/<user_short_id>/<media_short_id>/edit).
# 8. Deletes the media (POST /media/<user_short_id>/<media_short_id>/delete).
# 9. Lists all public media (GET /media/list).
# 10. Logs out (GET /auth/logout).
#
# The script exits on the first command error (set -e).
# Adjust BASE_URL, EMAIL, and file paths as needed.
#
# Usage: # Usage:
# ./test_agent.sh "Your log message" # MAX_COOKIE_AGE=<seconds> ./test_all_routes.sh /path/to/somefile.jpg
# By default, MAX_COOKIE_AGE=31536000 (1 year).
# If cookies.txt is younger than that, we skip re-login. Otherwise, we do the OTP flow.
#
# By default, uses http://localhost:6544 and agent@example.com
# Description: set -e # Exit on first error
# Sends a log message to the PyraLogs server using the provided JWT token.
# Adjust the BASE_URL and JWT_TOKEN variables as needed.
# Variables BASE_URL="${BASE_URL:-http://localhost:6544}"
BASE_URL="http://127.0.0.1:6544" EMAIL="${EMAIL:-agent@example.com}"
JWT_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9pZCI6IjcxZmQ0ZjA0LTQ2NzgtNGEyNi05ZmU4LTkxNjU2OTNmOTBmOSIsImFnZW50X25hbWUiOiJ0ZXN0LTkwMDAiLCJuYW1lc3BhY2VfaWQiOiJhZGQwYWQ1YS0zNjE0LTRhODgtOTRmZC0xNGQ4ZDBkYjIxYjUiLCJ0b2tlbl92ZXJzaW9uIjo1LCJpYXQiOjE3MzY2ODIxMTV9.p2HQmjFBtL9XLePtVCu3HyPhw89993BO4b6E82SwyEM" MEDIA_FILE="$1"
MAX_COOKIE_AGE="${MAX_COOKIE_AGE:-31536000}" # default ~1 year in seconds
# Log message from the first argument if [[ -z "$MEDIA_FILE" ]]; then
LOG_MESSAGE="$1" echo "Usage: MAX_COOKIE_AGE=<seconds> $0 /path/to/mediafile"
# Check if a log message was provided
if [[ -z "$LOG_MESSAGE" ]]; then
echo "Usage: $0 \"Your log message\""
exit 1 exit 1
fi fi
# Send the log message using curl echo "=== Checking cookies.txt freshness ==="
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}
}")
# Output the server's response COOKIE_FRESH=0
echo "Server Response: $RESPONSE" if [[ -f cookies.txt ]]; then
# 'stat -c %Y' returns the file modification time on Linux
# 'stat -f %m' does the same on BSD/macOS
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))
echo "cookies.txt is $AGE seconds old, threshold is $MAX_COOKIE_AGE."
if (( AGE < MAX_COOKIE_AGE )); then
COOKIE_FRESH=1
echo "Reusing existing cookies.txt (fresh enough)."
else
echo "cookies.txt is too old. Removing it to force re-login."
rm -f cookies.txt
fi
else
echo "No cookies.txt found. A new session will be created."
fi
if [[ "$COOKIE_FRESH" -eq 0 ]]; then
echo ""
echo "=== Starting new session and logging in with OTP flow ==="
curl -s -i -c cookies.txt -b cookies.txt "$BASE_URL/" >/dev/null
echo "Logging in with email: $EMAIL (POST /auth/login)"
curl -s -i -c cookies.txt -b cookies.txt \
-X POST \
-F "email=$EMAIL" \
"$BASE_URL/auth/login"
echo ""
echo "Check your server logs or email for the 6-digit verification code."
read -p "Enter the 6-digit code: " VERIFICATION_CODE
echo "Verifying code (POST /auth/verify)"
curl -s -i -c cookies.txt -b cookies.txt \
-X POST \
-F "code=$VERIFICATION_CODE" \
"$BASE_URL/auth/verify"
echo "Login/OTP flow complete. cookies.txt saved."
fi
echo ""
echo "=== Quick check: Fetching Profile page (GET /auth/profile) ==="
curl -i -c cookies.txt -b cookies.txt "$BASE_URL/auth/profile"
echo ""
echo "=== Uploading media (POST /media/upload) as public ==="
UPLOAD_RESPONSE=$(curl -i -c cookies.txt -b cookies.txt \
-X POST \
-F "title=TestUploadByAgent" \
-F "media_file=@${MEDIA_FILE}" \
-F "is_public=on" \
"$BASE_URL/media/upload")
echo "$UPLOAD_RESPONSE"
echo ""
echo "Parsing 'Location' header from upload response..."
LOCATION_HEADER=$(echo "$UPLOAD_RESPONSE" | grep -i "^Location:" | head -n1 | sed 's/\r//g')
LOCATION_URL="${LOCATION_HEADER#Location: }"
LOCATION_URL=$(echo "$LOCATION_URL" | tr -d '[:space:]')
LOCATION_PATH="${LOCATION_URL#*//*/}"
USER_SHORT_ID=$(echo "$LOCATION_PATH" | cut -d'/' -f2)
MEDIA_SHORT_ID=$(echo "$LOCATION_PATH" | cut -d'/' -f3)
echo "user_short_id=$USER_SHORT_ID"
echo "media_short_id=$MEDIA_SHORT_ID"
if [[ -z "$USER_SHORT_ID" || -z "$MEDIA_SHORT_ID" ]]; then
echo "Failed to parse user or media short ID. Exiting."
exit 1
fi
echo ""
echo "=== Viewing media details (GET /media/$USER_SHORT_ID/$MEDIA_SHORT_ID/details) ==="
curl -i -c cookies.txt -b cookies.txt \
"$BASE_URL/media/$USER_SHORT_ID/$MEDIA_SHORT_ID/details"
echo ""
echo "=== Editing media title to 'RenamedByAgent' (POST /media/$USER_SHORT_ID/$MEDIA_SHORT_ID/edit) ==="
curl -i -c cookies.txt -b cookies.txt \
-X POST \
-F "title=RenamedByAgent" \
"$BASE_URL/media/$USER_SHORT_ID/$MEDIA_SHORT_ID/edit"
echo ""
echo "=== Deleting the media (POST /media/$USER_SHORT_ID/$MEDIA_SHORT_ID/delete) ==="
curl -i -c cookies.txt -b cookies.txt \
-X POST \
"$BASE_URL/media/$USER_SHORT_ID/$MEDIA_SHORT_ID/delete"
echo ""
echo "=== Listing all public media (GET /media/list) ==="
curl -i -c cookies.txt -b cookies.txt "$BASE_URL/media/list"
echo ""
echo "=== Logging out (GET /auth/logout) ==="
curl -i -c cookies.txt -b cookies.txt "$BASE_URL/auth/logout"
echo ""
echo "Test complete. We exercised multiple routes, including login/verify, profile, "
echo "upload/delete/edit media, and logout. If no errors appeared, all requests succeeded!"