JWT and complete RBAC

This commit is contained in:
Russell Ballestrini 2025-01-12 22:54:11 +00:00
parent 7a2a2133cf
commit 5c226f2a2e
16 changed files with 1803 additions and 783 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
env/
__pycache__/
cookies.txt
data/

View file

@ -1,18 +1,25 @@
===========================================
pyrafiles - A Sophisticated Pyramid Project
PyraFiles - A Sophisticated Pyramid Project
===========================================
``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.
**PyraFiles** is a **public domain** application built on the `Pyramid <https://trypyramid.com>`_ framework. It demonstrates a flexible, multi-database design where each namespace (group of users and agents) maintains its own SQLite database for media uploads (images, audio, video). The main database (``main.db``) stores system-level data, including user accounts, namespaces, agents, and roles.
PyraFiles supports both human users and agents:
- **Users** authenticate via OTP (One-Time Password) email verification codes and interact through the web interface.
- **Agents** authenticate via JWT tokens and can interact programmatically with the application.
Key Features
============
- **Passwordless Login** using email verification codes.
- **Guest Sessions** for unverified visitors.
- **Per-user SQLite** databases for media uploads (limit ~30MB each).
- **Public/Private** media visibility controls.
- **Admin Tools** (import user records, manage site settings).
- **Agent-friendly** architecture: easily scriptable endpoints for login, verification, media uploads, etc.
- **Passwordless Login** using email verification codes (OTP).
- **Namespaces** to organize media files and control access.
- **Role-Based Access Control** with roles: *owner*, *editor*, and *reader*.
- **JWT Authentication** for agents, enabling programmatic access.
- **Per-Namespace SQLite Databases** for media uploads.
- **Public/Private** namespace and media visibility controls.
- **Admin Tools** for namespace and agent management.
- **OpenAPI Documentation** available for API interactions.
- **Configurable** via environment variables (including secret keys).
Git Repository
@ -20,22 +27,25 @@ Git Repository
The project is maintained at:
- `pyrafiles Git Repo <https://git.unturf.com/engineering/unturf/upload.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.
Configuration via Environment
=============================
`pyrafiles` fetches settings from environment variables with sensible defaults:
PyraFiles fetches settings from environment variables with sensible defaults:
- ``PYRAFILES_SECRET``
The secret key for session signing.
If missing, ``pyrafiles`` automatically generates a **random 64-character** string at runtime & will log out all users out.
If missing, PyraFiles automatically generates a **random 64-character** string at runtime, which will log out all users.
- ``PYRAFILES_JWT_SECRET``
The secret key used for signing JWT tokens for agents.
If missing, PyraFiles automatically generates a **random 64-character** string at runtime, which will invalidate existing agent tokens.
- ``PYRAFILES_DB_URL``
Connection string for the main database. Default: ``sqlite:///main.db``.
Connection string for the main database. Default: ``sqlite:///data/main.db``.
- ``PYRAFILES_HOST`` and ``PYRAFILES_PORT``
The host and port to serve on. Defaults: ``0.0.0.0`` (host), ``6544`` (port).
@ -45,7 +55,6 @@ Configuration via Environment
- Any other environment variables you wish to incorporate can be accessed in the code.
Local Setup
===========
@ -60,7 +69,7 @@ Local Setup
.. code-block:: bash
python -m venv venv
python3 -m venv venv
source venv/bin/activate # Linux/Mac
# or
venv\Scripts\activate.bat # Windows
@ -71,136 +80,156 @@ Local Setup
pip install -r requirements.txt
4. **Run**
Create the database if this is the first time running the applicaiton.
4. **Initialize the Database**
.. code-block:: bash
python initialize_db.py
Otherwise:
This creates or updates the main database file (``data/main.db``) and ensures all tables are set up.
5. **Run the Application**
.. code-block:: bash
# Optionally set PYRAFILES_SECRET if you want a custom secret.
# Optionally set PYRAFILES_SECRET and PYRAFILES_JWT_SECRET if you want custom secrets.
export PYRAFILES_SECRET="YOUR_OWN_LONG_RANDOM_STRING"
python main.py
export PYRAFILES_JWT_SECRET="YOUR_OWN_LONG_RANDOM_STRING"
python app.py
If ``PYRAFILES_SECRET`` is **not** set, the app automatically generates a 64-char secret at runtime & log out all users.
If ``PYRAFILES_SECRET`` and ``PYRAFILES_JWT_SECRET`` are **not** set, the app automatically generates 64-character secrets at runtime, which will log out all users and invalidate existing JWT tokens.
5. **Access**
6. **Access the Application**
Point your browser to `http://localhost:6544` or `http://<HOST>:<PORT>` according to your environment variables.
OTP Authentication
==================
Example: Agent Workflow Script
==============================
PyraFiles uses a passwordless login system for users:
First of all, everything is documented as OpenAPI for agentic flows.
1. **Login with Email**
* http://localhost:6544/docs
* http://localhost:6544/openapi.yaml
- Users enter their email address on the login page.
- A 6-digit verification code is sent to the provided email address.
Below is a sample Bash script showing how an **agent** might:
2. **Verify with Code**
1. Start the session (to get a cookie).
2. Log in with an email.
3. Prompt the user for a verification code.
4. Complete the verification.
5. Upload a file.
- Users enter the 6-digit code on the verification page.
- Upon successful verification, the user is logged in.
3. **Session Management**
- User sessions are managed via signed cookies.
- Sessions persist across browser restarts unless the server secret changes.
JWT Agent Authentication
========================
Agents authenticate with PyraFiles using JWT tokens, allowing programmatic interaction:
1. **Generate Agent JWT**
- **Owners** of a namespace can generate JWT tokens for agents.
- Agents are assigned a role: *owner*, *editor*, or *reader*.
- Each agent has a unique token, which includes their role and namespace ID.
2. **Authenticate with JWT**
- Agents include the JWT in the `Authorization` header as a Bearer token:
.. code-block:: http
Authorization: Bearer <JWT_TOKEN>
- The token is verified on each request, and the agent's role is used for access control.
3. **Access Control**
- **Owner** agents can manage the namespace, including inviting users and creating other agents.
- **Editor** agents can upload, edit, and delete media within the namespace.
- **Reader** agents can view media if they have appropriate permissions.
4. **Token Revocation**
- Owners can revoke agent tokens, which invalidates the JWT.
Example: Agent Workflow with JWT
================================
Below is a sample Bash script showing how an **agent** might upload a media file using a JWT token.
.. code-block:: bash
#!/usr/bin/env bash
#
# Usage:
# MAX_COOKIE_AGE=1800 ./agent_upload.sh /path/to/somefile.jpg
# ./agent_upload_jwt.sh /path/to/mediafile.jpg
#
# Description:
# Upload a file to the pyrafiles server as an agent, making it public.
# - If cookies.txt is newer than MAX_COOKIE_AGE seconds (default 31536000, ~1 year),
# we skip re-login.
# - Otherwise, we prompt for OTP again.
#
# Notes:
# 1. If not set, MAX_COOKIE_AGE defaults to 31536000 (one year).
# 2. We assume the server runs on http://localhost:6544. Adjust BASE_URL if needed.
# Upload a media file to the PyraFiles server as an agent using a JWT token.
BASE_URL="${BASE_URL:-http://localhost:6544}"
EMAIL="${EMAIL:-agent@example.com}"
JWT_TOKEN="${JWT_TOKEN:-your_agent_jwt_token}"
NAMESPACE_SHORT_ID="${NAMESPACE_SHORT_ID:-your_namespace_short_id}"
MEDIA_FILE="$1"
# Default to 1 year in seconds if not provided:
MAX_COOKIE_AGE="${MAX_COOKIE_AGE:-31536000}"
if [[ -z "$MEDIA_FILE" ]]; then
echo "Usage: MAX_COOKIE_AGE=<seconds> $0 /path/to/mediafile"
echo "Usage: $0 /path/to/mediafile.jpg"
exit 1
fi
# Check if cookies.txt is 'fresh enough':
COOKIE_FRESH=0
if [[ -f cookies.txt ]]; then
# 'stat -c %Y' returns the file's mod time on Linux; 'stat -f %m' on macOS/BSD
MOD_TIME=$(stat -c %Y cookies.txt 2>/dev/null || stat -f %m cookies.txt 2>/dev/null)
NOW=$(date +%s)
AGE=$((NOW - MOD_TIME))
if (( AGE < MAX_COOKIE_AGE )); then
COOKIE_FRESH=1
fi
if [[ -z "$JWT_TOKEN" ]]; then
echo "Error: JWT_TOKEN environment variable is not set."
exit 1
fi
if [[ "$COOKIE_FRESH" -eq 1 ]]; then
echo "Reusing existing cookies (file age < $MAX_COOKIE_AGE seconds)."
echo "Skipping OTP prompt."
else
echo "Starting new session (cookie missing or stale)..."
curl -s -c cookies.txt -b cookies.txt "$BASE_URL/" >/dev/null
echo "Logging in with email: $EMAIL"
curl -s -c cookies.txt -b cookies.txt \
-X POST \
-F "email=$EMAIL" \
"$BASE_URL/auth/login"
echo ""
echo "Check your console or email for the 6-digit verification code."
read -p "Enter the 6-digit code: " VERIFICATION_CODE
echo "Verifying code..."
curl -s -c cookies.txt -b cookies.txt \
-X POST \
-F "code=$VERIFICATION_CODE" \
"$BASE_URL/auth/verify"
echo "Cookies refreshed."
if [[ -z "$NAMESPACE_SHORT_ID" ]]; then
echo "Error: NAMESPACE_SHORT_ID environment variable is not set."
exit 1
fi
echo ""
echo "Uploading media: $MEDIA_FILE (public)"
curl -s -c cookies.txt -b cookies.txt \
echo "Uploading media: $MEDIA_FILE"
curl -s \
-X POST \
-H "Authorization: Bearer $JWT_TOKEN" \
-F "media_file=@$MEDIA_FILE" \
-F "is_public=on" \
"$BASE_URL/media/upload"
"$BASE_URL/namespace/$NAMESPACE_SHORT_ID/media/upload"
echo ""
echo "Upload complete. File is public."
echo "Upload complete."
OpenAPI Documentation
=====================
PyraFiles provides comprehensive OpenAPI documentation for all endpoints, making it easier to integrate agents and other services.
Dockerfile with Caddy + uWSGI
=============================
- **Swagger UI Documentation**
Access the interactive API documentation at:
- http://localhost:6544/docs
- **OpenAPI Specification**
Download the OpenAPI YAML file:
- http://localhost:6544/openapi.yaml
Docker Deployment
=================
Below is an example Dockerfile that:
- Uses **uWSGI** to run ``pyrafiles``.
- Uses **uWSGI** to run PyraFiles.
- Uses **Caddy** as a reverse proxy (and optionally HTTPS if configured).
- Defines a **volume** for databases (so they are stored on the host).
.. code-block:: dockerfile
# Dockerfile for PyraFiles
###########################################################
# Stage 1: Build the Python environment
###########################################################
@ -217,7 +246,6 @@ Below is an example Dockerfile that:
###########################################################
FROM caddy:2-alpine
# Install Python, pip, and uWSGI from Alpine
RUN apk add --no-cache python3 py3-pip uwsgi-python3
WORKDIR /app
@ -226,37 +254,25 @@ Below is an example Dockerfile that:
COPY --from=builder /app /app
# Copy Caddyfile for the reverse proxy
# (Assuming you have a Caddyfile in your repo root)
COPY Caddyfile /etc/caddy/Caddyfile
# Copy uWSGI config if desired
# For example (assume you created uwsgi.ini in your repo):
# [uwsgi]
# module = main:app
# master = true
# processes = 4
# socket = 127.0.0.1:8080
# vacuum = true
# die-on-term = true
# Copy uWSGI configuration
COPY uwsgi.ini /app/uwsgi.ini
# Environment variables (optional overrides).
# If PYRAFILES_SECRET is empty, the app itself generates a random 64-char secret & logs all users out.
# Set environment variables
ENV PYRAFILES_SECRET=""
ENV PYRAFILES_JWT_SECRET=""
ENV PYRAFILES_DB_URL="sqlite:///data/main.db"
ENV PYRAFILES_HOST="0.0.0.0"
ENV PYRAFILES_PORT="6544"
# Expose HTTP and HTTPS
EXPOSE 80
EXPOSE 443
EXPOSE 80 443
# Define volume so host can persist user DBs outside the container
# We'll store DB files in /data
# Define volume for data
VOLUME ["/data"]
# Command starts uWSGI and then starts Caddy
# The app itself checks for PYRAFILES_SECRET and auto-generates one if missing.
# Start uWSGI and Caddy
CMD ["/bin/sh", "-c", "\
uwsgi --ini /app/uwsgi.ini & \
caddy run --config /etc/caddy/Caddyfile \
@ -264,45 +280,32 @@ Below is an example Dockerfile that:
.. note::
- We use ``/data`` as the volume. By default, the environment variable ``PYRAFILES_DB_URL`` is set to ``sqlite:///data/main.db``, so the main DB (and any user DB files) go inside ``/data``.
- For user DB files, your app can also interpret an environment variable (like ``PYRAFILES_DB_BASEPATH=/data``) if you want to make that path configurable in the code.
- Make sure to **mount** a volume at ``/data`` when you run the container, e.g.:
.. code-block:: bash
docker run -d \
-p 80:80 -p 443:443 \
-v /my/local/dbfolder:/data \
--name pyrafiles \
pyrafiles-image:latest
- With that, any user database is stored in ``/my/local/dbfolder`` on the host.
- The ``/data`` volume is used to persist database files.
- Ensure you mount a volume at ``/data`` when running the container.
- Set ``PYRAFILES_SECRET`` and ``PYRAFILES_JWT_SECRET`` to persistent values in a production environment.
Tips
====
- For **production**, you likely want to set up a real SMTP server or third-party service (e.g. Mailgun, ImprovMV) and configure it via environment variables (``PYRAFILES_SMTP_HOST``, etc.).
- Ensure you mount a volume for persistent SQLite files if you want to avoid data loss when containers are removed or replaced.
- Because the project is **public domain**, you can adapt it without restriction, removing features, adding custom logic, etc.
- For **production**, set up a real SMTP server or use a third-party service (e.g., Mailgun) and configure it via environment variables (``PYRAFILES_SMTP_HOST``, etc.).
- **Persist Secrets**: In production, set ``PYRAFILES_SECRET`` and ``PYRAFILES_JWT_SECRET`` to persistent values to avoid invalidating sessions and JWT tokens.
- **Mount Data Volume**: Mount the ``/data`` directory to persist database files and avoid data loss.
- **OpenAPI Integration**: Use the provided OpenAPI specification to generate client code or integrate with other services.
License and Public Domain
=========================
This project is in the public domain. You are free to use, adapt, and redistribute it without attribution or additional licensing.
If you find `pyrafiles` helpful, feel free to contribute back or share your enhancements!
If you find PyraFiles helpful, feel free to contribute back or share your enhancements!
Support and Contact
===================
- Issues: Please open tickets at the `git.unturf.com project page <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 `PyraFiles Git Repo <https://git.unturf.com/engineering/unturf/upload.unturf.com>`_.
- **Contributions**: If you register an account, let us know, and we will grant you developer access to contribute.
- **General Inquiries**: Reach out to the maintainers directly.
We hope `pyrafiles` helps you get up and running quickly with a flexible media-sharing and multi-DB infrastructure!
If you register an account, let me know and I will bless you as an developer to contribute.
We hope PyraFiles helps you get up and running quickly with a flexible media-sharing and multi-database infrastructure!
Enjoy and happy building!

1262
app.py

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
#!/usr/bin/env python
"""
Initialize or upgrade the main database for pyrafiles.
Initialize or upgrade the main database for PyraFiles.
Usage:
python initialize_db.py
Environment Variables:
PYRAFILES_DB_URL (optional):
The SQLAlchemy database URL (e.g., "sqlite:///main.db").
Defaults to "sqlite:///main.db" if not set.
The SQLAlchemy database URL (e.g., "sqlite:///data/main.db").
Defaults to "sqlite:///data/main.db" if not set.
Description:
Creates or updates all tables referenced by `Base.metadata`.
@ -24,11 +24,8 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Adjust these imports to your actual project structure:
# For example, if your models and Base are defined in "models.py", do:
# from pyrafiles import Base
# If you keep Base in a separate module, adjust accordingly.
from app import Base # or from your_project.models import Base
# Import Base and DATA_DIR from your app.py
from app import Base, DATA_DIR # Adjust the import if needed
def usage():
script = os.path.basename(sys.argv[0])
@ -39,12 +36,19 @@ def usage():
def main():
logging.basicConfig(level=logging.INFO)
if len(sys.argv) > 2:
# We only expect optional arguments. If needed, parse them here.
if len(sys.argv) > 1:
# We do not expect any arguments
usage()
# Read environment variable for DB URL
db_url = os.environ.get("PYRAFILES_DB_URL", "sqlite:///main.db")
# Ensure DATA_DIR exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
logging.info(f"Created data directory at {DATA_DIR}")
# Set default database URL to use the data directory
default_db_url = f"sqlite:///{os.path.join(DATA_DIR, 'main.db')}"
# Read environment variable for DB URL or use default
db_url = os.environ.get("PYRAFILES_DB_URL", default_db_url)
logging.info(f"Using DB URL: {db_url}")
# Set up engine
@ -65,4 +69,3 @@ def main():
if __name__ == "__main__":
main()

View file

@ -4,22 +4,23 @@ info:
version: 1.0.0
description: |
API specification for the PyraFiles application.
PyraFiles allows users to register, authenticate, upload media files,
and manage their media content.
PyraFiles allows users to register, authenticate, manage namespaces,
generate JWT tokens for agents, and manage media files within namespaces.
servers:
- url: http://localhost:{port}
- url: http://127.0.0.1:{port}
description: Local development server
variables:
port:
default: '6544'
- url: https://upload.unturf.com
description: prod for humans & agents to mingle.
- url: https://files.example.com
description: Production server
paths:
/:
get:
summary: Home Page
description: Displays the home page.
description: Displays the home page with public namespaces and user namespaces.
responses:
'200':
description: Successful response
@ -57,10 +58,13 @@ paths:
responses:
'302':
description: Redirects to the verification page
headers:
Location:
description: URL of the verification page
schema:
type: string
'400':
description: Bad Request (e.g., email missing)
'500':
description: Internal Server Error
/auth/verify:
get:
@ -90,23 +94,44 @@ paths:
responses:
'302':
description: Redirects to the home page upon successful verification
headers:
Location:
description: URL of the home page
schema:
type: string
'400':
description: Bad Request (e.g., invalid code)
'500':
description: Internal Server Error
/auth/logout:
get:
post:
summary: Logout User
description: Logs out the current user.
security:
- sessionAuth: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
csrf_token:
type: string
responses:
'302':
description: Redirects to the home page
headers:
Location:
description: URL of the home page
schema:
type: string
'403':
description: Unauthorized (user not logged in)
/auth/profile:
get:
summary: Display User Profile
description: Shows the user's profile, including upload stats.
description: Shows the user's profile, including owned namespaces.
security:
- sessionAuth: []
responses:
@ -135,109 +160,408 @@ paths:
enum: ['on']
new_username:
type: string
encoding:
enable_gravatar:
contentType: text/plain
new_username:
contentType: text/plain
responses:
'302':
description: Redirects to the profile page
headers:
Location:
description: URL of the profile page
schema:
type: string
'400':
description: Bad Request (e.g., username already in use)
'403':
description: Unauthorized (user not logged in or guest mode)
description: Unauthorized (user not logged in)
/auth/download_db:
/namespace/create:
get:
summary: Download User Database
description: Allows the user to download their personal database file.
summary: Display Namespace Creation Page
description: Renders the form to create a new namespace.
security:
- sessionAuth: []
responses:
'200':
description: Database file downloaded
content:
application/octet-stream:
schema:
type: string
format: binary
'403':
description: Unauthorized (user not logged in or unverified)
'404':
description: Database file not found
/auth/export_user_record:
get:
summary: Export User Record
description: Exports the user's record as a JSON file.
security:
- sessionAuth: []
responses:
'200':
description: User record JSON file downloaded
content:
application/json:
schema:
type: object
'403':
description: Unauthorized (user not logged in or unverified)
/admin/import_user_record:
get:
summary: Display Import User Record Page
description: Renders a page to import a user record (Admin only).
security:
- sessionAuth: []
responses:
'200':
description: Import user record page rendered
description: Namespace creation page rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden (user not admin)
description: Unauthorized (user not logged in)
post:
summary: Import User Record
description: Processes uploaded user record file and imports the user (Admin only).
summary: Create Namespace
description: Processes the namespace creation form.
security:
- sessionAuth: []
requestBody:
required: true
content:
multipart/form-data:
application/x-www-form-urlencoded:
schema:
type: object
properties:
user_record_file:
name:
type: string
format: binary
required:
- user_record_file
is_public:
type: string
enum: ['on']
encoding:
name:
contentType: text/plain
is_public:
contentType: text/plain
responses:
'302':
description: Redirects to the home page after successful import
description: Redirects to the namespace management page
headers:
Location:
description: URL of the namespace management page
schema:
type: string
'400':
description: Bad Request (e.g., invalid file)
description: Bad Request (e.g., namespace name already exists)
'403':
description: Forbidden (user not admin)
description: Unauthorized (user not logged in)
/media/upload:
/namespace/{namespace_short_id}/manage:
get:
summary: Display Media Upload Page
description: Renders the media upload form.
summary: Manage Namespace (Owners Only)
description: Displays the namespace management page. **Requires 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the namespace
responses:
'200':
description: Upload media page rendered
description: Namespace management page rendered
content:
text/html:
schema:
type: string
'403':
description: Unauthorized (user not logged in or unverified)
description: Forbidden (not owner or not logged in)
'404':
description: Namespace not found
/namespace/{namespace_short_id}/update:
post:
summary: Upload Media
description: Processes the uploaded media file.
summary: Update Namespace (Owners Only)
description: Updates namespace properties. **Requires 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the namespace
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
is_public:
type: string
enum: ['on']
description: 'Checkbox value. Present if checked.'
encoding:
is_public:
contentType: text/plain
responses:
'302':
description: Redirects to the namespace management page
headers:
Location:
description: URL of the namespace management page
schema:
type: string
'403':
description: Forbidden (not owner or not logged in)
'404':
description: Namespace not found
/namespace/{namespace_short_id}/invite:
post:
summary: Invite User to Namespace (Owners Only)
description: Invites a user to the namespace with a specified role. **Requires 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the namespace
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
email:
type: string
format: email
role:
type: string
enum: ['owner', 'editor', 'reader']
required:
- email
- role
encoding:
email:
contentType: text/plain
role:
contentType: text/plain
responses:
'302':
description: Redirects to the namespace management page
headers:
Location:
description: URL of the namespace management page
schema:
type: string
'400':
description: Bad Request (e.g., invalid role)
'403':
description: Forbidden (not owner or not logged in)
'404':
description: Namespace not found
/namespace/{namespace_short_id}/remove_user:
post:
summary: Remove User from Namespace (Owners Only)
description: Removes a user from the namespace. **Requires 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the namespace
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
user_id:
type: string
required:
- user_id
encoding:
user_id:
contentType: text/plain
responses:
'302':
description: Redirects to the namespace management page
headers:
Location:
description: URL of the namespace management page
schema:
type: string
'400':
description: Bad Request (e.g., cannot remove self)
'403':
description: Forbidden (not owner or not logged in)
'404':
description: Namespace or user not found
/namespace/{namespace_short_id}/change_member_role:
post:
summary: Change Member Role in Namespace (Owners Only)
description: Changes a member's role in the namespace. **Requires 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the namespace
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
user_id:
type: string
role:
type: string
enum: ['owner', 'editor', 'reader']
required:
- user_id
- role
encoding:
user_id:
contentType: text/plain
role:
contentType: text/plain
responses:
'302':
description: Redirects to the namespace management page
headers:
Location:
description: URL of the namespace management page
schema:
type: string
'400':
description: Bad Request (e.g., cannot change own role)
'403':
description: Forbidden (not owner or not logged in)
'404':
description: Namespace or user not found
/namespace/{namespace_short_id}/generate_agent_jwt:
post:
summary: Generate Agent JWT (Owners Only)
description: Generates a JWT token for an agent. **Requires 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the namespace
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
agent_name:
type: string
agent_role:
type: string
enum: ['owner', 'editor', 'reader']
required:
- agent_name
- agent_role
encoding:
agent_name:
contentType: text/plain
agent_role:
contentType: text/plain
responses:
'200':
description: Agent JWT generated and displayed
content:
text/html:
schema:
type: string
'400':
description: Bad Request (e.g., agent name missing)
'403':
description: Forbidden (not owner or not logged in)
'404':
description: Namespace not found
/namespace/{namespace_short_id}/revoke_agent:
post:
summary: Revoke Agent (Owners Only)
description: Revokes an agent's access by invalidating their JWT. **Requires 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the namespace
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
agent_id:
type: string
required:
- agent_id
encoding:
agent_id:
contentType: text/plain
responses:
'302':
description: Redirects to the namespace management page
headers:
Location:
description: URL of the namespace management page
schema:
type: string
'400':
description: Bad Request (e.g., agent ID missing)
'403':
description: Forbidden (not owner or not logged in)
'404':
description: Namespace or agent not found
/namespace/{namespace_short_id}/media/upload:
get:
summary: Display Media Upload Page (Editors and Owners)
description: Renders the media upload form. **Requires 'editor' or 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
responses:
'200':
description: Media upload page rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden (insufficient permissions)
post:
summary: Upload Media (Editors and Owners)
description: Uploads a media file to the namespace. **Requires 'editor' or 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
requestBody:
required: true
content:
@ -258,15 +582,32 @@ paths:
responses:
'302':
description: Redirects to the media details page
headers:
Location:
description: URL of the media details page
schema:
type: string
'400':
description: Bad Request (e.g., no file uploaded, unsupported media type)
description: Bad Request (e.g., file missing)
'403':
description: Unauthorized (user not logged in or unverified)
description: Forbidden (insufficient permissions)
'404':
description: Namespace not found
/media/list:
/namespace/{namespace_short_id}/media/list:
get:
summary: List Public Media
description: Displays a list of public media from all users.
summary: List Media in Namespace
description: Lists all media files in the namespace.
security:
- sessionAuth: []
- agentAuth: []
- {} # Allow public access if namespace is public
parameters:
- in: path
name: namespace_short_id
required: true
schema:
type: string
responses:
'200':
description: Media list page rendered
@ -274,47 +615,30 @@ paths:
text/html:
schema:
type: string
/media/user/{user_short_id}:
get:
summary: Display User's Media
description: Shows all media uploaded by a specific user.
parameters:
- in: path
name: user_short_id
required: true
schema:
type: string
description: The short ID of the user
responses:
'200':
description: User's media page rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden (insufficient permissions)
'404':
description: User not found
'500':
description: Internal Server Error
description: Namespace not found
/media/{user_short_id}/{media_short_id}/details:
/namespace/{namespace_short_id}/media/{media_short_id}/details:
get:
summary: Display Media Details
description: Shows details of a specific media item.
summary: View Media Details
description: Displays the details of a media file.
security:
- sessionAuth: []
- agentAuth: []
- {} # Allow public access if media is public
parameters:
- in: path
name: user_short_id
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the user
- in: path
name: media_short_id
required: true
schema:
type: string
description: The short ID of the media item
responses:
'200':
description: Media details page rendered
@ -322,30 +646,29 @@ paths:
text/html:
schema:
type: string
'404':
description: Media or user not found
'403':
description: Forbidden (media not public and not owner)
description: Forbidden (insufficient permissions)
'404':
description: Media or namespace not found
/media/{user_short_id}/{media_short_id}/edit:
/namespace/{namespace_short_id}/media/{media_short_id}/edit:
get:
summary: Display Media Edit Page
description: Renders a form to edit media details (owner only).
summary: Display Media Edit Page (Editors and Owners)
description: Renders the media edit form. **Requires 'editor' or 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: user_short_id
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the user
- in: path
name: media_short_id
required: true
schema:
type: string
description: The short ID of the media item
responses:
'200':
description: Media edit page rendered
@ -354,27 +677,26 @@ paths:
schema:
type: string
'403':
description: Forbidden (not owner or not logged in)
description: Forbidden (insufficient permissions)
'404':
description: Media not found
description: Media or namespace not found
post:
summary: Edit Media
description: Updates the media item (owner only).
summary: Edit Media (Editors and Owners)
description: Updates the media file or metadata. **Requires 'editor' or 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: user_short_id
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the user
- in: path
name: media_short_id
required: true
schema:
type: string
description: The short ID of the media item
requestBody:
required: true
content:
@ -384,84 +706,103 @@ paths:
properties:
title:
type: string
is_public:
type: string
enum: ['on']
media_file:
type: string
format: binary
is_public:
type: string
enum: ['on']
responses:
'302':
description: Redirects to the media details page
headers:
Location:
description: URL of the media details page
schema:
type: string
'400':
description: Bad Request (e.g., file too large)
description: Bad Request (e.g., invalid data)
'403':
description: Forbidden (not owner)
description: Forbidden (insufficient permissions)
'404':
description: Media not found
description: Media or namespace not found
/media/{user_short_id}/{media_short_id}/delete:
/namespace/{namespace_short_id}/media/{media_short_id}/delete:
post:
summary: Delete Media
description: Deletes the media item (owner only).
summary: Delete Media (Editors and Owners)
description: Deletes the specified media file. **Requires 'editor' or 'owner' role.**
security:
- sessionAuth: []
- agentAuth: []
parameters:
- in: path
name: user_short_id
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the user
- in: path
name: media_short_id
required: true
schema:
type: string
description: The short ID of the media item
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
csrf_token:
type: string
responses:
'302':
description: Redirects to the user's media list
description: Redirects to the media list page
headers:
Location:
description: URL of the media list page
schema:
type: string
'403':
description: Forbidden (not owner)
description: Forbidden (insufficient permissions)
'404':
description: Media not found
description: Media or namespace not found
/media/{user_short_id}/{media_short_id}:
/namespace/{namespace_short_id}/media/{media_short_id}:
get:
summary: View Media
description: Retrieves the media file for viewing or download.
summary: View or Download Media
description: Serves the media file for viewing or downloading.
security:
- sessionAuth: []
- agentAuth: []
- {} # Allow public access if media is public
parameters:
- in: path
name: user_short_id
name: namespace_short_id
required: true
schema:
type: string
description: The short ID of the user
- in: path
name: media_short_id
required: true
schema:
type: string
description: The short ID of the media item
- in: query
name: download
schema:
type: string
enum: ['true', 'false']
description: Set to 'true' to trigger download
type: boolean
description: Set to true to download the file
responses:
'200':
description: Media file retrieved
description: Media file served
content:
'*/*':
schema:
type: string
format: binary
'403':
description: Forbidden (media not public and not owner)
description: Forbidden (insufficient permissions)
'404':
description: Media not found
description: Media or namespace not found
components:
securitySchemes:
@ -470,3 +811,8 @@ components:
in: cookie
name: session
description: Session cookie for authenticated users
agentAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT authentication for agents

View file

@ -8,6 +8,8 @@ pyramid_retry
pyramid_tm
zope.sqlalchemy
pyramid_openapi3
bcrypt
sqlalchemy
werkzeug

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Media Hosting App{% endblock %}</title>
<title>{% block title %}PyraFiles{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://www.unturf.com/css/pico.classless.min.css" rel="stylesheet">
<style>
@ -39,6 +39,26 @@
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 */
#mobile-menu-button {
display: none;
@ -99,16 +119,21 @@
</head>
<body>
<nav>
<a href="{{ request.route_url('home') }}"><strong>upload.unturf.com</strong></a>
<a href="{{ request.route_url('home') }}"><strong>{{ request.domain }}</strong></a>
<!-- Hamburger Menu Button (only visible on mobile) -->
<button id="mobile-menu-button" aria-label="Open Menu">☰</button>
<!-- Navigation Links -->
<ul>
<li><a href="{{ request.route_url('list_media') }}">Browse Media</a></li>
<li><a href="{{ request.route_url('home') }}">Home</a></li>
{% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('upload_media') }}">Upload Media</a></li>
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
<li>
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<button type="submit" class="link-button">Logout</button>
</form>
</li>
{% else %}
<li><a href="{{ request.route_url('login') }}">Login</a></li>
{% endif %}
@ -121,11 +146,16 @@
<button aria-label="Close Menu" id="mobile-menu-close">✕</button>
<nav>
<ul>
<li><a href="{{ request.route_url('list_media') }}">Browse Media</a></li>
<li><a href="{{ request.route_url('home') }}">Home</a></li>
{% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('upload_media') }}">Upload Media</a></li>
<li><a href="{{ request.route_url('create_namespace') }}">Create Namespace</a></li>
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
<li>
<form action="{{ request.route_url('logout') }}" method="post" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<button type="submit" class="link-button">Logout</button>
</form>
</li>
{% else %}
<li><a href="{{ request.route_url('login') }}">Login</a></li>
{% endif %}
@ -134,14 +164,14 @@
</article>
</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">
<!-- 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 %}
{% endblock %}
</main>

View file

@ -0,0 +1,18 @@
{% 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

@ -0,0 +1,15 @@
{% 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 interacting with 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

@ -3,13 +3,13 @@
{% 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>
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="title">Title:</label>
<input type="text" name="title" value="{{ media.title }}"><br><br>
<label for="media_file">Replace Media File (leave blank to keep current):</label>
<label for="media_file">Replace Media File (optional):</label>
<input type="file" name="media_file" accept="image/*,audio/*,video/*"><br><br>
<label>
@ -17,19 +17,8 @@
Make Media Public
</label><br><br>
<button type="submit">Update</button>
<button type="submit">Update Media</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>
<p><a href="{{ request.route_url('view_media_details', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Back to Media Details</a></p>
{% endblock %}

View file

@ -3,7 +3,7 @@
{% block title %}Home{% endblock %}
{% block content %}
<h1>Welcome to the Media Hosting App</h1>
<h1>Welcome to PyraFiles</h1>
<p>
{% if request.user and request.user.is_verified %}
Hello, {{ request.user.username }}!
@ -11,5 +11,37 @@
Welcome, Guest!
{% endif %}
</p>
<p>This application allows authenticated users to upload images, audio, and short videos. Guests can browse and download publicly available media.</p>
<p>This application allows environments and agents to centrally manage and share files within namespaces. You can create namespaces to organize files and control access.</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('list_media', namespace_short_id=ns.short_id) }}">{{ ns.name }}</a> ({{ ns.role }})
{% if ns.role == "owner" %}
| <a href="{{ request.route_url('manage_namespace', namespace_short_id=ns.short_id) }}">Manage</a>
{% endif %}
</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('list_media', namespace_short_id=ns.short_id) }}">{{ ns.name }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>No public namespaces available.</p>
{% endif %}
{% endblock %}

View file

@ -1,30 +1,25 @@
{% extends 'base.html.j2' %}
{% block title %}Media List{% endblock %}
{% block title %}Media in {{ namespace.name }}{% endblock %}
{% block content %}
<h1>Media List</h1>
<h1>Media in Namespace: {{ namespace.name }}</h1>
{% if media_list %}
{% if media_items %}
<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 %}
{% for media in media_items %}
<li>
<a href="{{ request.route_url('view_media_details', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">{{ media.title or media.filename }}</a>
({{ media.media_type }}, {{ media.size|filesizeformat }})
</li>
{% endfor %}
</ul>
{% else %}
<p>No media found.</p>
<p>No media found in this namespace.</p>
{% endif %}
{% if request.user_namespace_role in ['owner', 'editor'] %}
<p><a href="{{ request.route_url('upload_media', namespace_short_id=namespace.short_id) }}">Upload Media</a></p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,107 @@
{% 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.role|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>
<label for="agent_role">Agent Role:</label>
<select name="agent_role" required>
<option value="owner">Owner</option>
<option value="editor" selected>Editor</option>
<option value="reader">Reader</option>
</select><br><br>
<button type="submit">Generate JWT</button>
</form>
{% endblock %}

View file

@ -23,15 +23,17 @@
<button type="submit">Update Profile</button>
</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>
<h2>Your Owned Namespaces</h2>
{% if owner_namespaces %}
<ul>
{% for ns in owner_namespaces %}
<li>
<a href="{{ request.route_url('manage_namespace', namespace_short_id=ns.short_id) }}">{{ ns.name }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>You do not own any namespaces.</p>
{% endif %}
{% endblock %}

View file

@ -3,15 +3,15 @@
{% block title %}Upload Media{% endblock %}
{% block content %}
<h1>Upload Media</h1>
<h1>Upload Media to Namespace '{{ namespace.name }}'</h1>
<form method="POST" enctype="multipart/form-data">
<label for="media_file">Select Media File (Max 30MB):</label>
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<label for="media_file">Media File:</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>
<input type="text" name="title"><br><br>
<!-- Optionally, allow users to set media as public or private -->
<label>
<input type="checkbox" name="is_public" checked>
Make Media Public

View file

@ -4,21 +4,42 @@
{% block content %}
<h1>Media Details</h1>
<p><strong>Title:</strong> {{ media.title if media.title else 'Untitled' }}</p>
<p><strong>Title:</strong> {{ media.title or '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>
<p><strong>Type:</strong> {{ media.media_type }}</p>
<p><strong>Size:</strong> {{ media.size|filesizeformat }}</p>
<p><strong>Uploaded:</strong> {{ media.upload_date.strftime('%Y-%m-%d %H:%M:%S') }}</p>
<p><strong>Public:</strong> {{ 'Yes' if media.is_public else 'No' }}</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>
<h2>Preview</h2>
{% if media.media_type == 'image' %}
<img src="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" alt="{{ media.title }}" style="max-width: 100%;">
{% elif media.media_type == 'audio' %}
<audio controls>
<source src="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" type="{{ media.media_type }}">
Your browser does not support the audio element.
</audio>
{% elif media.media_type == 'video' %}
<video controls style="max-width: 100%;">
<source src="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" type="{{ media.media_type }}">
Your browser does not support the video element.
</video>
{% else %}
<p>No preview available.</p>
{% endif %}
<h2>Actions</h2>
<p>
<a href="{{ request.route_url('view_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}?download=true">Download</a>
{% if is_owner_or_editor %}
| <a href="{{ request.route_url('edit_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}">Edit</a>
| <form method="POST" action="{{ request.route_url('delete_media', namespace_short_id=namespace.short_id, media_short_id=media.short_id) }}" class="inline-form" onsubmit="return confirm('Are you sure you want to delete this media?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get_csrf_token() }}">
<button type="submit" class="link-button">Delete</button>
</form>
{% endif %}
</p>
<p><a href="{{ request.route_url('list_media', namespace_short_id=namespace.short_id) }}">Back to Media List</a></p>
{% endblock %}