From beb4f96d810db5daad5f9d5ebdc2108bb78febef Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 7 Jan 2025 08:03:27 -0500 Subject: [PATCH 01/22] halp, why doesn't this work? getting http 404 modified: app.py new file: openapi.yaml modified: requirements.txt --- app.py | 20 ++++++++++++++---- openapi.yaml | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 ++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 openapi.yaml diff --git a/app.py b/app.py index d351dd0..a76eed7 100644 --- a/app.py +++ b/app.py @@ -524,9 +524,9 @@ def download_database_view(request): download_filename = sanitize_filename_for_http_header(f"user_{user.id}.db") response = Response(body=data, content_type="application/octet-stream") - response.headers[ - "Content-Disposition" - ] = f'attachment; filename="{download_filename}"' + response.headers["Content-Disposition"] = ( + f'attachment; filename="{download_filename}"' + ) return response @@ -1005,7 +1005,19 @@ def main(global_config=None, **settings): config.add_jinja2_renderer(".j2") config.add_jinja2_search_path("templates", name=".j2") - # The Jinja2 filters are added via the event subscriber above + # Add settings for pyramid_openapi3 + config.add_settings( + { + "pyramid_openapi3.spec": os.path.join(APP_DIR, "openapi.yaml"), + "pyramid_openapi3.enable_request_validation": True, + "pyramid_openapi3.enable_response_validation": False, + "pyramid_openapi3.route": "/openapi.yaml", + "pyramid_openapi3.ui_route": "/docs/", + } + ) + + # Include pyramid_openapi3 + config.include("pyramid_openapi3") # Set up SQLAlchemy engine = create_engine( diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..367c5e5 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,53 @@ +openapi: 3.0.0 +info: + title: PyraFiles API + version: '1.0' +paths: + /auth/login: + post: + summary: User login + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + email: + type: string + responses: + '302': + description: Redirect to verification page + /auth/verify: + post: + summary: Verify user code + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + code: + type: string + responses: + '302': + description: Redirect to home page + /media/upload: + post: + summary: Upload media file + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + media_file: + type: string + format: binary + title: + type: string + responses: + '302': + description: Redirect to media details page diff --git a/requirements.txt b/requirements.txt index bd0af6c..bad1dc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ zope.sqlalchemy bcrypt sqlalchemy werkzeug + +pyramid_openapi3 From 7a2a2133cfff1147ecc70383608bcd216cbe4bd8 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 7 Jan 2025 08:23:53 -0500 Subject: [PATCH 02/22] openapi for the win! modified: README.rst modified: app.py new file: openapi.yaml --- README.rst | 9 +- app.py | 4 + openapi.yaml | 472 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 openapi.yaml diff --git a/README.rst b/README.rst index 1cac10c..cfec1e7 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ Configuration via Environment - ``PYRAFILES_SECRET`` The secret key for session signing. - If missing, ``pyrafiles`` automatically generates a **random 64-character** string at runtime & log all users out. + If missing, ``pyrafiles`` automatically generates a **random 64-character** string at runtime & will log out all users out. - ``PYRAFILES_DB_URL`` Connection string for the main database. Default: ``sqlite:///main.db``. @@ -87,7 +87,7 @@ Local Setup export PYRAFILES_SECRET="YOUR_OWN_LONG_RANDOM_STRING" python main.py - If ``PYRAFILES_SECRET`` is **not** set, the app automatically generates a 64-char secret at runtime & log all users out. + If ``PYRAFILES_SECRET`` is **not** set, the app automatically generates a 64-char secret at runtime & log out all users. 5. **Access** @@ -97,6 +97,11 @@ Local Setup Example: Agent Workflow Script ============================== +First of all, everything is documented as OpenAPI for agentic flows. + +* http://localhost:6544/docs +* http://localhost:6544/openapi.yaml + Below is a sample Bash script showing how an **agent** might: 1. Start the session (to get a cookie). diff --git a/app.py b/app.py index d351dd0..8991ce8 100644 --- a/app.py +++ b/app.py @@ -1001,6 +1001,10 @@ def main(global_config=None, **settings): config.include("pyramid_jinja2") config.include("pyramid_tm") # Include pyramid_tm for transaction management + config.include("pyramid_openapi3") + config.pyramid_openapi3_spec('openapi.yaml', route='/openapi.yaml') + config.pyramid_openapi3_add_explorer(route='/docs') + # Add .html.j2 extension for Jinja2 templates config.add_jinja2_renderer(".j2") config.add_jinja2_search_path("templates", name=".j2") diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..d3fc9ed --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,472 @@ +openapi: 3.0.3 +info: + title: PyraFiles API + 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. +servers: + - url: http://localhost:{port} + description: Local development server + variables: + port: + default: '6544' + - url: https://upload.unturf.com + description: prod for humans & agents to mingle. + +paths: + /: + get: + summary: Home Page + description: Displays the home page. + responses: + '200': + description: Successful response + content: + text/html: + schema: + type: string + + /auth/login: + get: + summary: Display Login Page + description: Renders the login page where users can enter their email. + responses: + '200': + description: Login page rendered + content: + text/html: + schema: + type: string + post: + summary: Process Login + description: Sends a verification code to the user's email. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + email: + type: string + format: email + required: + - email + responses: + '302': + description: Redirects to the verification page + '400': + description: Bad Request (e.g., email missing) + '500': + description: Internal Server Error + + /auth/verify: + get: + summary: Display Verification Page + description: Renders the verification page where users can enter their code. + responses: + '200': + description: Verification page rendered + content: + text/html: + schema: + type: string + post: + summary: Verify User + description: Verifies the user's code and logs them in. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + code: + type: string + required: + - code + responses: + '302': + description: Redirects to the home page upon successful verification + '400': + description: Bad Request (e.g., invalid code) + '500': + description: Internal Server Error + + /auth/logout: + get: + summary: Logout User + description: Logs out the current user. + responses: + '302': + description: Redirects to the home page + + /auth/profile: + get: + summary: Display User Profile + description: Shows the user's profile, including upload stats. + 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 + responses: + '302': + description: Redirects to the profile page + '400': + description: Bad Request (e.g., username already in use) + '403': + description: Unauthorized (user not logged in or guest mode) + + /auth/download_db: + get: + summary: Download User Database + description: Allows the user to download their personal database file. + 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 + content: + text/html: + schema: + type: string + '403': + description: Forbidden (user not admin) + post: + summary: Import User Record + description: Processes uploaded user record file and imports the user (Admin only). + security: + - sessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + user_record_file: + type: string + format: binary + required: + - user_record_file + responses: + '302': + description: Redirects to the home page after successful import + '400': + description: Bad Request (e.g., invalid file) + '403': + description: Forbidden (user not admin) + + /media/upload: + get: + summary: Display Media Upload Page + description: Renders the media upload form. + security: + - sessionAuth: [] + responses: + '200': + description: Upload media page rendered + content: + text/html: + schema: + type: string + '403': + description: Unauthorized (user not logged in or unverified) + post: + summary: Upload Media + description: Processes the uploaded media file. + security: + - sessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + media_file: + type: string + format: binary + title: + type: string + is_public: + type: string + enum: ['on'] + required: + - media_file + responses: + '302': + description: Redirects to the media details page + '400': + description: Bad Request (e.g., no file uploaded, unsupported media type) + '403': + description: Unauthorized (user not logged in or unverified) + + /media/list: + get: + summary: List Public Media + description: Displays a list of public media from all users. + responses: + '200': + description: Media list page rendered + content: + 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 + '404': + description: User not found + '500': + description: Internal Server Error + + /media/{user_short_id}/{media_short_id}/details: + get: + summary: Display Media Details + description: Shows details of a specific media item. + parameters: + - in: path + name: user_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 + content: + text/html: + schema: + type: string + '404': + description: Media or user not found + '403': + description: Forbidden (media not public and not owner) + + /media/{user_short_id}/{media_short_id}/edit: + get: + summary: Display Media Edit Page + description: Renders a form to edit media details (owner only). + security: + - sessionAuth: [] + parameters: + - in: path + name: user_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 + content: + text/html: + schema: + type: string + '403': + description: Forbidden (not owner or not logged in) + '404': + description: Media not found + post: + summary: Edit Media + description: Updates the media item (owner only). + security: + - sessionAuth: [] + parameters: + - in: path + name: user_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: + multipart/form-data: + schema: + type: object + properties: + title: + type: string + is_public: + type: string + enum: ['on'] + media_file: + type: string + format: binary + responses: + '302': + description: Redirects to the media details page + '400': + description: Bad Request (e.g., file too large) + '403': + description: Forbidden (not owner) + '404': + description: Media not found + + /media/{user_short_id}/{media_short_id}/delete: + post: + summary: Delete Media + description: Deletes the media item (owner only). + security: + - sessionAuth: [] + parameters: + - in: path + name: user_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: + '302': + description: Redirects to the user's media list + '403': + description: Forbidden (not owner) + '404': + description: Media not found + + /media/{user_short_id}/{media_short_id}: + get: + summary: View Media + description: Retrieves the media file for viewing or download. + parameters: + - in: path + name: user_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 + responses: + '200': + description: Media file retrieved + content: + '*/*': + schema: + type: string + format: binary + '403': + description: Forbidden (media not public and not owner) + '404': + description: Media not found + +components: + securitySchemes: + sessionAuth: + type: apiKey + in: cookie + name: session + description: Session cookie for authenticated users From c9f8df96c22390073c0a85ad27ae175c973093a1 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 11 Jan 2025 19:13:51 -0500 Subject: [PATCH 03/22] working, we forked pyrafiles into pyralogs modified: app.py modified: requirements.txt modified: templates/base.html.j2 new file: templates/create_namespace.html.j2 new file: templates/display_agent_jwt.html.j2 deleted: templates/edit_media.html.j2 modified: templates/home.html.j2 deleted: templates/import_user_record.html.j2 deleted: templates/list_media.html.j2 new file: templates/manage_namespace.html.j2 modified: templates/profile.html.j2 deleted: templates/upload_media.html.j2 deleted: templates/user_media.html.j2 deleted: templates/view_media.html.j2 deleted: templates/view_media_details.html.j2 new file: templates/view_namespace_logs.html.j2 --- app.py | 1056 ++++++++++++------------- requirements.txt | 2 + templates/base.html.j2 | 12 +- templates/create_namespace.html.j2 | 18 + templates/display_agent_jwt.html.j2 | 12 + templates/edit_media.html.j2 | 35 - templates/home.html.j2 | 38 +- templates/import_user_record.html.j2 | 12 - templates/list_media.html.j2 | 30 - templates/manage_namespace.html.j2 | 50 ++ templates/profile.html.j2 | 10 - templates/upload_media.html.j2 | 22 - templates/user_media.html.j2 | 33 - templates/view_media.html.j2 | 38 - templates/view_media_details.html.j2 | 24 - templates/view_namespace_logs.html.j2 | 33 + 16 files changed, 662 insertions(+), 763 deletions(-) create mode 100644 templates/create_namespace.html.j2 create mode 100644 templates/display_agent_jwt.html.j2 delete mode 100644 templates/edit_media.html.j2 delete mode 100644 templates/import_user_record.html.j2 delete mode 100644 templates/list_media.html.j2 create mode 100644 templates/manage_namespace.html.j2 delete mode 100644 templates/upload_media.html.j2 delete mode 100644 templates/user_media.html.j2 delete mode 100644 templates/view_media.html.j2 delete mode 100644 templates/view_media_details.html.j2 create mode 100644 templates/view_namespace_logs.html.j2 diff --git a/app.py b/app.py index 8991ce8..80b679a 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ ############################################################################### -# app.py - Full pyrafiles Application with Unicode-safe Content-Disposition +# app.py - PyraLogs Application with JWT-based Authentication and Refresh Mechanism ############################################################################### import os import base64 @@ -20,7 +20,7 @@ from email.mime.text import MIMEText from pyramid.config import Configurator from pyramid.view import view_config from pyramid.response import Response -from pyramid.httpexceptions import HTTPFound, HTTPForbidden +from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound from pyramid.session import SignedCookieSessionFactory from sqlalchemy import ( create_engine, @@ -30,10 +30,13 @@ from sqlalchemy import ( Boolean, Integer, Index, + ForeignKey, + Table, + Text, ) from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker -from sqlalchemy.orm import scoped_session +from sqlalchemy.orm import scoped_session, relationship from sqlalchemy.pool import StaticPool from waitress import serve @@ -45,6 +48,9 @@ from pyramid_jinja2 import IJinja2Environment import transaction # Import transaction management from zope.sqlalchemy import register # Import zope.sqlalchemy +import jwt # Import PyJWT library +from jwt import PyJWTError + ################################################################################ # Set up logging ################################################################################ @@ -55,26 +61,30 @@ log = logging.getLogger(__name__) # Environment Variables and Defaults ################################################################################ -# If PYRAFILES_SECRET is missing, generate a random 64-character secret. -pyrafiles_secret = os.environ.get("PYRAFILES_SECRET", "") -if not pyrafiles_secret: - pyrafiles_secret = "".join( +# If PYRALOGS_SECRET is missing, generate a random 64-character secret. +pyralogs_secret = os.environ.get("PYRALOGS_SECRET", "") +if not pyralogs_secret: + pyralogs_secret = "".join( random.choices(string.ascii_letters + string.digits, k=64) ) - log.info(f"Generated random PYRAFILES_SECRET: {pyrafiles_secret}") + log.info(f"Generated random PYRALOGS_SECRET: {pyralogs_secret}") + +# JWT Secret Key +JWT_SECRET = os.environ.get("PYRALOGS_JWT_SECRET", pyralogs_secret) +JWT_ALGORITHM = "HS256" # Database URL can be overridden by environment variable APP_DIR = os.path.dirname(os.path.abspath(__file__)) default_main_db_url = f"sqlite:///{os.path.join(APP_DIR, 'main.db')}" -DB_URL = os.environ.get("PYRAFILES_DB_URL", default_main_db_url) +DB_URL = os.environ.get("PYRALOGS_DB_URL", default_main_db_url) # Host and port for the application -HOST = os.environ.get("PYRAFILES_HOST", "0.0.0.0") -PORT = int(os.environ.get("PYRAFILES_PORT", "6544")) +HOST = os.environ.get("PYRALOGS_HOST", "0.0.0.0") +PORT = int(os.environ.get("PYRALOGS_PORT", "6544")) # SMTP host/port -smtp_host = os.environ.get("PYRAFILES_SMTP_HOST", "localhost") -smtp_port = int(os.environ.get("PYRAFILES_SMTP_PORT", "25")) +smtp_host = os.environ.get("PYRALOGS_SMTP_HOST", "localhost") +smtp_port = int(os.environ.get("PYRALOGS_SMTP_PORT", "25")) ################################################################################ # Helper Functions @@ -133,6 +143,7 @@ def get_current_user(request): # Try to get the user from the database user = s.query(User).filter_by(id=user_id).first() if user: + user.dbsession = s # Attach dbsession to user for permission checks return user else: # User ID in session does not exist in the database; remove it @@ -166,17 +177,10 @@ def get_current_user(request): # Store the user ID in the session request.session["user_id"] = guest_user.id + guest_user.dbsession = s # Attach dbsession to user return guest_user -def get_mime_type(filename): - # Guess the MIME type based on the file extension - mime_type, _ = mimetypes.guess_type(filename) - if not mime_type: - mime_type = "application/octet-stream" - return mime_type - - def uuid_to_short_id(u): """Encode UUID to a URL-safe base64 string without padding.""" return base64.urlsafe_b64encode(u.bytes).decode("ascii").rstrip("=") @@ -199,33 +203,17 @@ def short_id_to_uuid(sid): return None -def get_user_db_url(user_id): - """Return the database URL for the user's SQLite database, in the same directory.""" - db_file = os.path.join(APP_DIR, f"user_{user_id}.db") +def get_namespace_db_url(namespace_id): + """Return the database URL for the namespace's SQLite database.""" + db_file = os.path.join(APP_DIR, f"namespace_{namespace_id}.db") return f"sqlite:///{db_file}" -def filesizeformat(value): - """Returns the human-readable file size.""" - for unit in ["bytes", "KB", "MB", "GB", "TB"]: - if value < 1024.0: - return f"{value:.2f} {unit}" - value /= 1024.0 - return f"{value:.2f} PB" - - -def sanitize_filename_for_http_header(filename): - """ - Ensure that the filename is safe for Waitress (Latin-1 headers). - Converts to ASCII, replacing or removing characters that won't encode. - """ - normalized = unicodedata.normalize("NFKD", filename) - ascii_bytes = normalized.encode("ascii", "ignore") # drop non-ASCII - safe = ascii_bytes.decode("ascii") - # Replace any remaining bad chars with underscores - # e.g. keep alphanumerics, dots, underscores, hyphens, etc. - safe = re.sub(r"[^A-Za-z0-9._-]+", "_", safe) - return safe or "download" +def slugify_filename(filename): + """Sanitize and slugify filename to prevent path traversal.""" + filename = os.path.basename(filename) + filename = re.sub(r"[^\w\.-]", "_", filename) + return filename or "file" ################################################################################ @@ -236,6 +224,16 @@ log.debug(f"Using database URL: {DB_URL}") # For debugging Base = declarative_base() +# Association table for Namespace <-> User (with roles) +namespace_user_association = Table( + 'namespace_user_association', + Base.metadata, + Column('namespace_id', String, ForeignKey('namespaces.id')), + Column('user_id', String, ForeignKey('users.id')), + Column('role', String, nullable=False), # 'owner', 'editor', 'reader' + Index('ix_namespace_user', 'namespace_id', 'user_id', unique=True) +) + class User(Base): __tablename__ = "users" @@ -249,33 +247,37 @@ class User(Base): enable_gravatar = Column(Boolean, default=False) # Gravatar support is_admin = Column(Boolean, default=False) # Admin flag - __table_args__ = ( - Index("ix_users_id", "id"), - Index("ix_users_short_id", "short_id"), + # Namespaces the user is associated with + namespaces = relationship( + "Namespace", + secondary=namespace_user_association, + back_populates="users", ) + # Attribute to hold dbsession in permission checks + dbsession = None + def __repr__(self): return f"" -class Media(Base): - __tablename__ = "media" +class Namespace(Base): + __tablename__ = "namespaces" id = Column(String, primary_key=True) # UUID short_id = Column(String, unique=True, nullable=False) - user_id = Column(String, nullable=False) # Owner's UUID - filename = Column(String, nullable=False) - title = Column(String, nullable=True) # Optional title - media_type = Column(String, nullable=False) # 'image', 'audio', 'video' - media_b64 = Column(String, nullable=False) - upload_date = Column(DateTime, default=datetime.datetime.utcnow) - is_public = Column(Boolean, default=True) - size = Column(Integer, nullable=False) # Size in bytes + name = Column(String, unique=True, nullable=False) + is_public = Column(Boolean, default=False) - __table_args__ = ( - Index("ix_media_id", "id"), - Index("ix_media_short_id", "short_id"), + # Users associated with the namespace + users = relationship( + "User", + secondary=namespace_user_association, + back_populates="namespaces", ) + def __repr__(self): + return f"" + ################################################################################ # Jinja2 Environment and Custom Filters @@ -285,7 +287,7 @@ class Media(Base): @subscriber(IJinja2Environment) def add_jinja2_filters(event): env = event.environment - env.filters["filesizeformat"] = filesizeformat + # Add any custom Jinja2 filters here ################################################################################ @@ -293,46 +295,146 @@ def add_jinja2_filters(event): ################################################################################ -def add_user_dbsession(request): - """Adds user_dbsession to request for verified users.""" - if request.user and request.user.is_verified: - user_dbsession = get_user_dbsession_by_user_id(request.user.id, request) - return user_dbsession +def add_namespace_dbsession(request): + """Adds namespace_dbsession to request if namespace is set.""" + namespace = request.namespace + if namespace: + namespace_dbsession = get_namespace_dbsession_by_namespace_id( + namespace.id, request + ) + return namespace_dbsession else: - return None # Guests do not have user_dbsession + return None # No namespace selected -def get_user_dbsession_by_user_id(user_id, request): - """Helper function to get a user_dbsession for a given user_id.""" - user_db_url = get_user_db_url(user_id) - db_file = os.path.join(APP_DIR, f"user_{user_id}.db") +def get_namespace_dbsession_by_namespace_id(namespace_id, request): + """Helper function to get a namespace_dbsession for a given namespace_id.""" + namespace_db_url = get_namespace_db_url(namespace_id) + db_file = os.path.join(APP_DIR, f"namespace_{namespace_id}.db") if not os.path.exists(db_file): - return None # User database does not exist - user_engine = create_engine( - user_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool + # Create the namespace database if it doesn't exist + engine = create_engine( + namespace_db_url, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + NamespaceBase.metadata.create_all(engine) + engine.dispose() + + namespace_engine = create_engine( + namespace_db_url, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, ) - UserSessionFactory = sessionmaker(bind=user_engine) - user_dbsession = scoped_session(UserSessionFactory) - register(user_dbsession) # Register with zope.sqlalchemy + NamespaceSessionFactory = sessionmaker(bind=namespace_engine) + namespace_dbsession = scoped_session(NamespaceSessionFactory) + register(namespace_dbsession) # Register with zope.sqlalchemy # Attach cleanup callbacks def cleanup(_request): - user_dbsession.remove() - user_engine.dispose() + namespace_dbsession.remove() + namespace_engine.dispose() request.add_finished_callback(cleanup) - return user_dbsession + return namespace_dbsession + + +def get_current_namespace(request): + """Get the namespace from the route or query parameter.""" + namespace_short_id = request.matchdict.get("namespace_short_id") + if not namespace_short_id: + return None + s = request.dbsession + namespace = s.query(Namespace).filter_by(short_id=namespace_short_id).first() + return namespace + + +def check_namespace_permission(user, namespace, required_role): + """Check if the user has the required role in the namespace.""" + if not namespace: + return False + s = user.dbsession + result = s.execute( + namespace_user_association.select().where( + namespace_user_association.c.namespace_id == namespace.id, + namespace_user_association.c.user_id == user.id, + ) + ).first() + if not result: + if namespace.is_public and required_role == "reader": + return True + else: + return False + user_role = result.role + roles_hierarchy = {"owner": 3, "editor": 2, "reader": 1} + return roles_hierarchy.get(user_role, 0) >= roles_hierarchy.get(required_role, 0) + + +def owner_required(view_func): + def wrapper(request): + user = request.user + namespace = request.namespace + if not user or not check_namespace_permission(user, namespace, "owner"): + return HTTPForbidden("You must be an owner to access this page.") + return view_func(request) + + return wrapper + + +def editor_required(view_func): + def wrapper(request): + user = request.user + namespace = request.namespace + if not user or not check_namespace_permission(user, namespace, "editor"): + return HTTPForbidden("You must be an editor to access this page.") + return view_func(request) + + return wrapper + + +def reader_required(view_func): + def wrapper(request): + user = request.user + namespace = request.namespace + if not user or not check_namespace_permission(user, namespace, "reader"): + return HTTPForbidden("You must have access to this namespace.") + return view_func(request) + + return wrapper ################################################################################ -# Routes +# Namespace Database Models +################################################################################ + +NamespaceBase = declarative_base() + + +class LogEntry(NamespaceBase): + __tablename__ = "log_entries" + id = Column(Integer, primary_key=True, autoincrement=True) + timestamp = Column(DateTime, default=datetime.datetime.utcnow) + message = Column(Text, nullable=False) + level = Column(String, nullable=False) + log_metadata = Column(Text, nullable=True) + + def __repr__(self): + return f"" + + +################################################################################ +# Routes and Views ################################################################################ @view_config(route_name="home", renderer="home.html.j2") def home_view(request): + s = request.dbsession + # Show public namespaces + public_namespaces = s.query(Namespace).filter(Namespace.is_public == True).all() return { "request": request, + "public_namespaces": public_namespaces, } @@ -436,13 +538,7 @@ def verify_post_view(request): request.session["user_id"] = user.id - # Create user database upon verification - user_db_url = get_user_db_url(user.id) - user_engine = create_engine( - user_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool - ) - Base.metadata.create_all(user_engine) # Create tables in user's database - user_engine.dispose() + # Authentication cookies are used for other routes. return HTTPFound(location=request.route_url("home")) @@ -454,7 +550,51 @@ def logout_view(request): ################################################################################ -# Profile and Media Upload +# JWT Helper Functions +################################################################################ + +def generate_jwt_token(user, expires_delta=datetime.timedelta(days=365)): + """Generate a JWT for the given user.""" + payload = { + "user_id": user.id, + "username": user.username, + # Set 'iat' to issue time and 'nbf' to tomorrow to enforce daily refresh + "iat": datetime.datetime.utcnow(), + "exp": datetime.datetime.utcnow() + expires_delta, + } + token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + return token + + +def verify_jwt_token(token): + """Verify the JWT and return the payload if valid.""" + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload + except PyJWTError: + return None + + +def issue_new_jwt_if_needed(old_token, user): + """Issue a new JWT if the token was last used on a previous day.""" + payload = verify_jwt_token(old_token) + if not payload: + return None # Invalid token + last_used = datetime.datetime.utcfromtimestamp(payload.get("last_used", 0)) + now = datetime.datetime.utcnow() + + if last_used.date() < now.date(): + # Update 'last_used' to now and issue a new token + new_payload = payload.copy() + new_payload["last_used"] = now.timestamp() + token = jwt.encode(new_payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + return token + else: + return None # No need to issue a new token + + +################################################################################ +# Profile and Namespace Management ################################################################################ @@ -465,22 +605,24 @@ def profile_get_view(request): return Response("You must be logged in to access your profile.", status=403) gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else "" - # Initialize stats - total_uploads = 0 - total_size = 0 - - user_dbsession = request.user_dbsession - if user_dbsession: - media_items = user_dbsession.query(Media).all() - total_uploads = len(media_items) - total_size = sum(media.size for media in media_items) + # Get namespaces where the user is an owner + s = request.dbsession + owner_namespaces = [] + for ns in user.namespaces: + result = s.execute( + namespace_user_association.select().where( + namespace_user_association.c.namespace_id == ns.id, + namespace_user_association.c.user_id == user.id, + ) + ).first() + if result and result.role == 'owner': + owner_namespaces.append(ns) return { "request": request, "user": user, "gravatar_url": gravatar_url, - "total_uploads": total_uploads, - "total_size": total_size, + "owner_namespaces": owner_namespaces, } @@ -509,469 +651,287 @@ def profile_post_view(request): return HTTPFound(location=request.route_url("profile")) -@view_config(route_name="download_database") -def download_database_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to download your database.", status=403) - db_file = os.path.join(APP_DIR, f"user_{user.id}.db") - if not os.path.exists(db_file): - return Response("Database file not found.", status=404) - with open(db_file, "rb") as f: - data = f.read() - - # Safely build a filename - download_filename = sanitize_filename_for_http_header(f"user_{user.id}.db") - - response = Response(body=data, content_type="application/octet-stream") - response.headers[ - "Content-Disposition" - ] = f'attachment; filename="{download_filename}"' - return response - - -################################################################################ -# User Record Export (Users) and Import (Admins) -################################################################################ - - -@view_config(route_name="export_user_record") -def export_user_record_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to export your user record.", status=403) - - # Create a JSON representation of the user record - user_record = { - "id": user.id, - "short_id": user.short_id, - "email": user.email, - "username": user.username, - "enable_gravatar": user.enable_gravatar, - "is_admin": user.is_admin, - } - - # Convert to JSON string - user_json = json.dumps(user_record).encode("utf-8") - - response = Response(body=user_json, content_type="application/json") - # Plain ASCII filename is safe - response.headers["Content-Disposition"] = 'attachment; filename="user_record.json"' - return response - - -@view_config( - route_name="import_user_record", - request_method="GET", - renderer="import_user_record.html.j2", -) -@admin_required -def import_user_record_get_view(request): +@view_config(route_name="create_namespace", request_method="GET", renderer="create_namespace.html.j2") +def create_namespace_get_view(request): + if not request.user or not request.user.is_verified: + return Response("You must be logged in to create a namespace.", status=403) return {"request": request} -@view_config(route_name="import_user_record", request_method="POST") -@admin_required -def import_user_record_post_view(request): - user_record_file = request.POST.get("user_record_file") - if ( - user_record_file is None - or not getattr(user_record_file, "filename", "").strip() - ): - return Response("No user record file uploaded.", status=400) +@view_config(route_name="create_namespace", request_method="POST") +def create_namespace_post_view(request): + user = request.user + if not user or not user.is_verified: + return Response("You must be logged in to create a namespace.", status=403) - # Read and parse the JSON data - try: - user_record_data = user_record_file.file.read() - user_record = json.loads(user_record_data) - except Exception as e: - return Response(f"Invalid user record file: {e}", status=400) + name = request.POST.get("name", "").strip() + if not name: + return Response("Namespace name is required.", status=400) s = request.dbsession + existing_namespace = s.query(Namespace).filter(Namespace.name == name).first() + if existing_namespace: + return Response("Namespace name already exists.", status=400) - # Check if user already exists - existing_user = s.query(User).filter_by(id=user_record["id"]).first() - if existing_user: - return Response("User already exists in the database.", status=400) + # Generate UUID and short ID for the namespace + namespace_uuid = uuid.uuid4() + namespace_id = str(namespace_uuid) + namespace_short_id = uuid_to_short_id(namespace_uuid) - # Create new user with provided data - user = User( - id=user_record["id"], - short_id=user_record["short_id"], - email=user_record["email"], - username=user_record["username"], - enable_gravatar=user_record.get("enable_gravatar", False), - is_admin=user_record.get("is_admin", False), - is_verified=True, # Assume verified + is_public = request.POST.get("is_public") == "on" + + namespace = Namespace( + id=namespace_id, + short_id=namespace_short_id, + name=name, + is_public=is_public, ) - s.add(user) + s.add(namespace) s.flush() - # Do not create user database upon import - - request.session.flash(f"User {user.username} imported successfully.") - return HTTPFound(location=request.route_url("home")) - - -################################################################################ -# Media Upload, Listing, and Management -################################################################################ - - -@view_config( - route_name="upload_media", request_method="GET", renderer="upload_media.html.j2" -) -def upload_media_get_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to upload media.", status=403) - return {"request": request} - - -@view_config(route_name="upload_media", request_method="POST") -def upload_media_post_view(request): - user = request.user - if not user or not user.is_verified: - return Response("You must be logged in to upload media.", status=403) - - user_dbsession = request.user_dbsession # Assume this exists for verified users - - media_file = request.POST.get("media_file") - if media_file is None or not getattr(media_file, "filename", "").strip(): - return Response("No file uploaded.", status=400) - - raw_bytes = media_file.file.read() - max_size = 30 * 1024 * 1024 # 30 MB - if len(raw_bytes) > max_size: - return Response("File size exceeds the 30MB limit.", status=400) - - file_size = len(raw_bytes) - - # Determine media type based on MIME type - filename = media_file.filename - mime_type = get_mime_type(filename) - if not mime_type.startswith(("image/", "audio/", "video/")): - return Response("Unsupported media type.", status=400) - - media_type = mime_type.split("/")[0] - - # Get title from form - title = request.POST.get("title", "").strip() - - # If user did not enter a title, default to the original filename - if not title: - title = filename - - # Encode content to base64 - encoded_str = base64.b64encode(raw_bytes).decode("utf-8") - - # Generate UUID and short ID for the media - media_uuid = uuid.uuid4() - media_id = str(media_uuid) - media_short_id = uuid_to_short_id(media_uuid) - - is_public = request.POST.get("is_public") == "on" - - media = Media( - id=media_id, - short_id=media_short_id, - user_id=user.id, - filename=filename, - title=title, # now has a fallback of filename - media_type=media_type, - media_b64=encoded_str, - is_public=is_public, - size=file_size, - ) - user_dbsession.add(media) - user_dbsession.flush() - - return HTTPFound( - location=request.route_url( - "view_media_details", - user_short_id=user.short_id, - media_short_id=media.short_id, + # Add the user as an owner + s.execute( + namespace_user_association.insert().values( + namespace_id=namespace.id, + user_id=user.id, + role='owner', ) ) + s.flush() + + # Create namespace database + namespace_db_url = get_namespace_db_url(namespace.id) + engine = create_engine( + namespace_db_url, connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + NamespaceBase.metadata.create_all(engine) + engine.dispose() + + return HTTPFound(location=request.route_url("manage_namespace", namespace_short_id=namespace.short_id)) -@view_config(route_name="list_media", renderer="list_media.html.j2") -def list_media_view(request): - # Aggregate public media from all verified users +@view_config(route_name="manage_namespace", renderer="manage_namespace.html.j2") +@owner_required +def manage_namespace_view(request): + namespace = request.namespace + user = request.user + s = request.dbsession - users = s.query(User).filter(User.is_verified == True).all() - media_list = [] - for user in users: - user_dbsession = get_user_dbsession_by_user_id(user.id, request) - if not user_dbsession: - continue - media_items = user_dbsession.query(Media).filter(Media.is_public == True).all() - for m in media_items: - media_list.append( - { - "media": m, - "username": user.username, - "user_short_id": user.short_id, - } - ) - # Sort media by upload date (recent first) - media_list.sort(key=lambda x: x["media"].upload_date, reverse=True) + # Get users and their roles in the namespace + user_roles = s.execute( + namespace_user_association.select().where( + namespace_user_association.c.namespace_id == namespace.id + ) + ).fetchall() + users = [] + for ur in user_roles: + u = s.query(User).filter(User.id == ur.user_id).first() + users.append({ + 'user': u, + 'role': ur.role, + }) return { "request": request, - "media_list": media_list, + "namespace": namespace, + "users": users, } -@view_config(route_name="user_media", renderer="user_media.html.j2") -def user_media_view(request): - user_short_id = request.matchdict.get("user_short_id") - log.debug(f"Looking up user with short_id: {user_short_id}") - - try: - user_uuid = short_id_to_uuid(user_short_id) - if not user_uuid: - log.error(f"Could not convert short_id {user_short_id} to UUID") - return Response("User not found.", status=404) - - s = request.dbsession - user = s.query(User).filter_by(id=str(user_uuid)).first() - log.debug(f"User found: {user}") - - if not user: - return Response("User not found.", status=404) - - viewer = request.user - is_owner = viewer and viewer.id == user.id - - user_dbsession = get_user_dbsession_by_user_id(user.id, request) - if not user_dbsession: - return Response("User has no uploads.", status=404) - - if is_owner: - media_items = user_dbsession.query(Media).all() - else: - media_items = ( - user_dbsession.query(Media).filter(Media.is_public == True).all() - ) - - media_items.sort(key=lambda m: m.upload_date, reverse=True) - - return { - "request": request, - "media_items": media_items, - "user": user, - "is_owner": is_owner, - } - - except Exception as e: - log.exception(f"Error processing user_short_id {user_short_id}: {e}") - return Response("Error processing request.", status=500) - - -@view_config(route_name="view_media_details", renderer="view_media_details.html.j2") -def view_media_details_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - if not user_short_id or not media_short_id: - return Response("Invalid request.", status=400) - +@view_config(route_name="update_namespace", request_method="POST") +@owner_required +def update_namespace_view(request): + namespace = request.namespace s = request.dbsession - user = s.query(User).filter_by(short_id=user_short_id).first() + + # Update namespace properties + is_public = request.POST.get("is_public") == "on" + namespace.is_public = is_public + s.flush() + + return HTTPFound(location=request.route_url("manage_namespace", namespace_short_id=namespace.short_id)) + + +@view_config(route_name="invite_user", request_method="POST") +@owner_required +def invite_user_view(request): + namespace = request.namespace + s = request.dbsession + + email = request.POST.get("email", "").strip().lower() + role = request.POST.get("role", "").strip().lower() + if role not in ['owner', 'editor', 'reader']: + return Response("Invalid role.", status=400) + + # Find or create the user + user = s.query(User).filter(User.email == email).first() + if not user: + user_uuid = uuid.uuid4() + user_id = str(user_uuid) + short_id = uuid_to_short_id(user_uuid) + user = User( + id=user_id, + short_id=short_id, + email=email, + username=email.split("@")[0], + is_verified=False, + ) + s.add(user) + s.flush() + + # Add role + existing = s.execute( + namespace_user_association.select().where( + namespace_user_association.c.namespace_id == namespace.id, + namespace_user_association.c.user_id == user.id, + ) + ).first() + if existing: + return Response("User already has a role in this namespace.", status=400) + + s.execute( + namespace_user_association.insert().values( + namespace_id=namespace.id, + user_id=user.id, + role=role, + ) + ) + s.flush() + + # Send invitation email + email_body = f"You have been invited as a {role} to namespace '{namespace.name}'. Please log in to access it." + send_email(user.email, "Namespace Invitation", email_body) + + return HTTPFound(location=request.route_url("manage_namespace", namespace_short_id=namespace.short_id)) + + +@view_config(route_name="generate_agent_jwt", request_method="POST", renderer="display_agent_jwt.html.j2") +@owner_required +def generate_agent_jwt_view(request): + namespace = request.namespace + user = request.user + + agent_name = request.POST.get("agent_name", "").strip() + if not agent_name: + return Response("Agent name is required.", status=400) + + # Create a user entry for the agent without an email + agent_uuid = uuid.uuid4() + agent_id = str(agent_uuid) + agent_short_id = uuid_to_short_id(agent_uuid) + + agent_user = User( + id=agent_id, + short_id=agent_short_id, + email=None, + username=agent_name, + is_verified=True, # Agents are verified + ) + s = request.dbsession + s.add(agent_user) + s.flush() + + # Assign the agent to the namespace with the appropriate role + s.execute( + namespace_user_association.insert().values( + namespace_id=namespace.id, + user_id=agent_user.id, + role='editor', # Agents can be given 'editor' role + ) + ) + s.flush() + + # Generate JWT for the agent + jwt_token = generate_jwt_token(agent_user) + + return { + "request": request, + "namespace": namespace, + "agent_name": agent_name, + "jwt_token": jwt_token, + } + +################################################################################ +# Logging Views +################################################################################ + + +@view_config(route_name="log_webhook", request_method="POST") +def log_webhook_view(request): + s = request.dbsession + + # Attempt authentication via JWT + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return Response("Missing or invalid Authorization header.", status=401) + token = auth_header[len("Bearer "):].strip() + payload = verify_jwt_token(token) + if not payload: + return Response("Invalid or expired token.", status=401) + + # Get user and check permissions + user = s.query(User).filter(User.id == payload["user_id"]).first() if not user: return Response("User not found.", status=404) - user_id = user.id + request.user = user + user.dbsession = s # Attach dbsession to user - user_dbsession = get_user_dbsession_by_user_id(user_id, request) - if not user_dbsession: - return Response("User database not found.", status=404) + # Issue a new JWT if needed and include it in the response + new_token = issue_new_jwt_if_needed(token, user) + if new_token: + request.response.headers['X-New-JWT'] = new_token - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) + # Get namespace from header or parameter + namespace_short_id = request.headers.get('X-Namespace') or request.params.get('namespace') + if not namespace_short_id: + return Response("Namespace not specified.", status=400) + namespace = s.query(Namespace).filter(Namespace.short_id == namespace_short_id).first() + if not namespace: + return Response("Namespace not found.", status=404) - viewer = request.user - is_owner = viewer and viewer.id == user_id + # Check if user has sufficient permissions + if not check_namespace_permission(user, namespace, 'editor'): + return Response("You do not have permission to write to this namespace.", status=403) - if not media.is_public and not is_owner: - return Response("Media not available.", status=403) + request.namespace = namespace # Attach namespace to request + + # Access namespace_dbsession + namespace_dbsession = request.namespace_dbsession + + payload = request.json_body + message = payload.get('message', '') + level = payload.get('level', 'INFO') + meta_data = payload.get('metadata', None) + if meta_data: + meta_data = json.dumps(meta_data) + + log_entry = LogEntry( + message=message, + level=level, + log_metadata=meta_data, + ) + namespace_dbsession.add(log_entry) + namespace_dbsession.flush() + + return Response("Log entry created.", status=201) + + +@view_config(route_name="view_namespace_logs", renderer="view_namespace_logs.html.j2") +@reader_required +def view_namespace_logs_view(request): + namespace = request.namespace + + namespace_dbsession = request.namespace_dbsession + logs = namespace_dbsession.query(LogEntry).order_by(LogEntry.timestamp.desc()).all() return { "request": request, - "media": media, - "username": user.username, - "user_short_id": user.short_id, - "is_owner": is_owner, + "namespace": namespace, + "logs": logs, } -@view_config(route_name="delete_media", request_method="POST") -def delete_media_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to delete this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) - - user_dbsession.delete(media) - user_dbsession.flush() - - return HTTPFound( - location=request.route_url("user_media", user_short_id=viewer.short_id) - ) - - -@view_config( - route_name="edit_media", request_method="GET", renderer="edit_media.html.j2" -) -def edit_media_get_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to edit this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) - - return { - "request": request, - "media": media, - } - - -@view_config(route_name="edit_media", request_method="POST") -def edit_media_post_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - - viewer = request.user - if not viewer or not viewer.is_verified: - return Response("You must be logged in.", status=403) - - if viewer.short_id != user_short_id: - return Response("You are not authorized to edit this media.", status=403) - - user_dbsession = request.user_dbsession - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) - - # Handle title update - new_title = request.POST.get("title", "").strip() - media.title = new_title - - # Handle media file update - new_media_file = request.POST.get("media_file") - if new_media_file and getattr(new_media_file, "filename", "").strip(): - raw_bytes = new_media_file.file.read() - max_size = 30 * 1024 * 1024 - if len(raw_bytes) > max_size: - return Response("File size exceeds the 30MB limit.", status=400) - file_size = len(raw_bytes) - filename = new_media_file.filename - mime_type = get_mime_type(filename) - if not mime_type.startswith(("image/", "audio/", "video/")): - return Response("Unsupported media type.", status=400) - media_type = mime_type.split("/")[0] - encoded_str = base64.b64encode(raw_bytes).decode("utf-8") - - media.filename = filename - media.media_type = media_type - media.media_b64 = encoded_str - media.size = file_size - - # Handle public/private update - is_public = request.POST.get("is_public") == "on" - media.is_public = is_public - - user_dbsession.flush() - - return HTTPFound( - location=request.route_url( - "view_media_details", - user_short_id=viewer.short_id, - media_short_id=media.short_id, - ) - ) - - -################################################################################ -# Media Viewing and Downloading -################################################################################ - - -@view_config(route_name="view_media") -def view_media_view(request): - media_short_id = request.matchdict.get("media_short_id") - user_short_id = request.matchdict.get("user_short_id") - if not user_short_id or not media_short_id: - return Response("Invalid request.", status=400) - - s = request.dbsession - user = s.query(User).filter_by(short_id=user_short_id).first() - if not user: - return Response("User not found.", status=404) - user_id = user.id - - user_dbsession = get_user_dbsession_by_user_id(user_id, request) - if not user_dbsession: - return Response("User database not found.", status=404) - - media = user_dbsession.query(Media).filter_by(short_id=media_short_id).first() - if not media: - return Response("Media not found.", status=404) - - viewer = request.user - is_owner = viewer and viewer.id == user_id - - if not media.is_public and not is_owner: - return Response("Media not available.", status=403) - - media_data = base64.b64decode(media.media_b64) - mime_type = get_mime_type(media.filename) - - # Build a safe filename (avoid Unicode issues in the header) - if media.title: - file_extension = os.path.splitext(media.filename)[1] - raw_title = media.title - download_filename = sanitize_filename_for_http_header( - f"{raw_title}{file_extension}" - ) - else: - download_filename = sanitize_filename_for_http_header(media.filename) - - # Check if user wants attachment or inline - download = request.GET.get("download", "false").lower() == "true" - content_disposition = "attachment" if download else "inline" - - response = Response(body=media_data, content_type=mime_type) - response.headers.update( - { - "Access-Control-Allow-Origin": "*", - "Content-Disposition": f'{content_disposition}; filename="{download_filename}"', - } - ) - - return response - - ################################################################################ # Main ################################################################################ @@ -983,7 +943,7 @@ def main(global_config=None, **settings): # Set up the session factory session_factory = SignedCookieSessionFactory( - secret=pyrafiles_secret, + secret=pyralogs_secret, hashalg="sha512", timeout=31104000, # Approx. one year in seconds max_age=31104000, # Set Max-Age attribute on cookie @@ -1001,10 +961,6 @@ def main(global_config=None, **settings): config.include("pyramid_jinja2") config.include("pyramid_tm") # Include pyramid_tm for transaction management - config.include("pyramid_openapi3") - config.pyramid_openapi3_spec('openapi.yaml', route='/openapi.yaml') - config.pyramid_openapi3_add_explorer(route='/docs') - # Add .html.j2 extension for Jinja2 templates config.add_jinja2_renderer(".j2") config.add_jinja2_search_path("templates", name=".j2") @@ -1017,6 +973,7 @@ def main(global_config=None, **settings): ) session_factory_ = sessionmaker(bind=engine) Base.metadata.bind = engine + Base.metadata.create_all(engine) DBSession = scoped_session(session_factory_) register(DBSession) # Register with zope.sqlalchemy @@ -1030,8 +987,11 @@ def main(global_config=None, **settings): config.add_request_method(dbsession, "dbsession", reify=True) - # Add user_dbsession to requests for verified users - config.add_request_method(add_user_dbsession, "user_dbsession", reify=True) + # Add namespace to requests + config.add_request_method(get_current_namespace, "namespace", reify=True) + + # Add namespace_dbsession to requests if namespace is set + config.add_request_method(add_namespace_dbsession, "namespace_dbsession", reify=True) # Routes config.add_route("home", "/") @@ -1042,23 +1002,17 @@ def main(global_config=None, **settings): config.add_route("logout", "/auth/logout") config.add_route("profile", "/auth/profile") - # Export and Import User Record (Admin only for import) - config.add_route("export_user_record", "/auth/export_user_record") - config.add_route("import_user_record", "/admin/import_user_record") + # Namespace Management + config.add_route("create_namespace", "/namespace/create") + config.add_route("manage_namespace", "/namespace/{namespace_short_id}/manage") + config.add_route("update_namespace", "/namespace/{namespace_short_id}/update") + config.add_route("invite_user", "/namespace/{namespace_short_id}/invite") + # Route to generate JWTs for agents + config.add_route("generate_agent_jwt", "/namespace/{namespace_short_id}/generate_agent_jwt") - # Media - config.add_route("upload_media", "/media/upload") - config.add_route("list_media", "/media/list") - config.add_route("user_media", "/media/user/{user_short_id}") - config.add_route( - "view_media_details", "/media/{user_short_id}/{media_short_id}/details" - ) - config.add_route("edit_media", "/media/{user_short_id}/{media_short_id}/edit") - config.add_route("delete_media", "/media/{user_short_id}/{media_short_id}/delete") - config.add_route("view_media", "/media/{user_short_id}/{media_short_id}") - - # Database Download - config.add_route("download_database", "/auth/download_db") + # Logging + config.add_route("log_webhook", "/webhook") + config.add_route("view_namespace_logs", "/namespace/{namespace_short_id}/logs") config.scan() return config.make_wsgi_app() diff --git a/requirements.txt b/requirements.txt index bd0af6c..4e0ba57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,8 @@ pyramid_debugtoolbar waitress pyramid_retry +pyramid_jwt + pyramid_tm zope.sqlalchemy diff --git a/templates/base.html.j2 b/templates/base.html.j2 index 24ae59c..b959141 100644 --- a/templates/base.html.j2 +++ b/templates/base.html.j2 @@ -2,7 +2,7 @@ - {% block title %}Media Hosting App{% endblock %} + {% block title %}PyraLogs{% endblock %}