diff --git a/.env.example b/.env.example index cbe2130..d5e1cca 100644 --- a/.env.example +++ b/.env.example @@ -33,12 +33,12 @@ INITIAL_ADMIN_DISCORD_ID=your_discord_user_id EMAIL_ENABLED=true SMTP_HOST=mail-eu.smtp2go.com SMTP_PORT=587 -SMTP_USERNAME=no-reply@m.plexdev.live +SMTP_USERNAME=no-reply@m.plexdev.xyz SMTP_PASSWORD=your_smtp_password SMTP_USE_TLS=true -EMAIL_FROM_ADDRESS=no-reply@m.plexdev.live +EMAIL_FROM_ADDRESS=no-reply@m.plexdev.xyz EMAIL_FROM_NAME=PlexAddons -ADMIN_NOTIFICATION_EMAIL=contact@plexdev.live +ADMIN_NOTIFICATION_EMAIL=contact@plexdev.xyz # Discord Bot (separate from OAuth2 - for DM notifications) DISCORD_BOT_TOKEN=your_discord_bot_token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..caf070e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + backend-lint: + name: Backend Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: plexaddons-api + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - run: pip install -r requirements.txt + - name: Syntax check + run: | + python -c " + import ast, pathlib, sys + errors = [] + for p in pathlib.Path('app').rglob('*.py'): + try: + ast.parse(p.read_text()) + except SyntaxError as e: + errors.append(f'{p}: {e}') + if errors: + for e in errors: print(e) + sys.exit(1) + print(f'All {len(list(pathlib.Path(\"app\").rglob(\"*.py\")))} files OK') + " + + backend-test: + name: Backend Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: plexaddons-api + env: + SECRET_KEY: test-secret-key-ci + DATABASE_URL: sqlite+aiosqlite:///test.db + REDIS_URL: redis://localhost:6379/15 + DISCORD_CLIENT_ID: test + DISCORD_CLIENT_SECRET: test + DISCORD_REDIRECT_URI: http://localhost/callback + STRIPE_SECRET_KEY: sk_test_fake + STRIPE_WEBHOOK_SECRET: whsec_test + STRIPE_PRO_PRICE_ID: price_pro + STRIPE_PREMIUM_PRICE_ID: price_premium + PAYPAL_CLIENT_ID: test + PAYPAL_CLIENT_SECRET: test + PAYPAL_WEBHOOK_ID: test + PAYPAL_PRO_PLAN_ID: P-test + PAYPAL_PREMIUM_PLAN_ID: P-test + ENVIRONMENT: testing + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - run: pip install -r requirements.txt + - run: python -m pytest tests/ -v --tb=short + + frontend-lint: + name: Frontend Lint & Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: plexaddons-web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: plexaddons-web/package-lock.json + - run: npm ci + - run: npx tsc --noEmit + - run: npm run build + + frontend-test: + name: Frontend Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: plexaddons-web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: plexaddons-web/package-lock.json + - run: npm ci + - run: npm test + + docker-build: + name: Docker Build Check + runs-on: ubuntu-latest + needs: [backend-test, frontend-test] + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + - name: Build API image + run: docker build -t plexaddons-api:ci plexaddons-api/ + - name: Build Web image + run: docker build -t plexaddons-web:ci plexaddons-web/ diff --git a/.gitignore b/.gitignore index 5ee229b..7d61496 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,7 @@ secrets/ # Coverage .coverage htmlcov/ + +# Audit +AUDIT_FINDINGS.txt .pytest_cache/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..adf6ffa --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "version-checker/pavc"] + path = version-checker/pavc + url = https://github.com/Bali0531-RC/pavc diff --git a/docker-compose.yml b/docker-compose.yml index 9f265dd..036bd40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: api: build: @@ -26,7 +24,7 @@ services: - PAYPAL_PRO_PLAN_ID=${PAYPAL_PRO_PLAN_ID} - PAYPAL_PREMIUM_PLAN_ID=${PAYPAL_PREMIUM_PLAN_ID} - INITIAL_ADMIN_DISCORD_ID=${INITIAL_ADMIN_DISCORD_ID} - - FRONTEND_URL=${FRONTEND_URL:-https://addons.plexdev.live} + - FRONTEND_URL=${FRONTEND_URL:-https://addons.plexdev.xyz} - ENVIRONMENT=${ENVIRONMENT:-production} # Email (SMTP) - EMAIL_ENABLED=${EMAIL_ENABLED:-true} @@ -35,7 +33,7 @@ services: - SMTP_USERNAME=${SMTP_USERNAME} - SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_USE_TLS=${SMTP_USE_TLS:-true} - - EMAIL_FROM_ADDRESS=${EMAIL_FROM_ADDRESS:-no-reply@m.plexdev.live} + - EMAIL_FROM_ADDRESS=${EMAIL_FROM_ADDRESS:-no-reply@m.plexdev.xyz} - EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-PlexAddons} - ADMIN_NOTIFICATION_EMAIL=${ADMIN_NOTIFICATION_EMAIL} # Discord Bot (for DM notifications) @@ -67,7 +65,7 @@ services: ports: - "3310:80" environment: - - VITE_API_URL=${API_URL:-https://addons.plexdev.live/api} + - VITE_API_URL=${API_URL:-https://addons.plexdev.xyz/api} - VITE_DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - VITE_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} - VITE_PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} diff --git a/examples/.github/workflows/publish-addon.yml b/examples/.github/workflows/publish-addon.yml new file mode 100644 index 0000000..8be5d21 --- /dev/null +++ b/examples/.github/workflows/publish-addon.yml @@ -0,0 +1,167 @@ +# PlexAddons CI/CD - Publish Addon Version +# +# This workflow automatically publishes a new version of your addon +# to addons.plexdev.xyz when you create a GitHub release or push a tag. +# +# Prerequisites: +# 1. Create an API key at https://addons.plexdev.xyz/dashboard/api-keys +# - Required scope: "versions:write" +# - Pro tier: up to 3 keys | Premium tier: up to 10 keys +# 2. Add the API key as a repository secret named PLEXADDONS_API_KEY +# - Go to Settings → Secrets and variables → Actions → New repository secret +# 3. Your addon must already exist on addons.plexdev.xyz +# +# Usage: +# Option A: Triggered on GitHub Release +# - Create a release in GitHub with a tag like "v1.2.3" +# - The workflow extracts the version from the tag and publishes it +# +# Option B: Triggered on tag push +# - Push a tag: git tag v1.2.3 && git push origin v1.2.3 +# +# Customize the env variables below for your addon. + +name: Publish to PlexAddons + +on: + release: + types: [published] + push: + tags: + - 'v*' + +env: + # ─── CONFIGURE THESE ─────────────────────────────────────────── + ADDON_SLUG: "my-addon" # Your addon's slug on addons.plexdev.xyz + DOWNLOAD_URL_TEMPLATE: "https://github.com/${{ github.repository }}/releases/download/${TAG}/addon.zip" + # Optional: + CHANNEL: "stable" # stable | beta | alpha | canary (Pro+ feature) + ROLLOUT_PERCENTAGE: "100" # 0-100, gradual rollout (Premium feature) + # ──────────────────────────────────────────────────────────────── + +jobs: + publish: + name: Publish Version + runs-on: ubuntu-latest + # Only run once if both release and tag events fire + if: github.event_name == 'release' || !startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for changelog + + - name: Extract version from tag + id: version + run: | + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "📦 Publishing version: $VERSION" + + - name: Build download URL + id: url + run: | + TAG="${{ steps.version.outputs.tag }}" + # Replace ${TAG} placeholder in the template + URL=$(echo "$DOWNLOAD_URL_TEMPLATE" | sed "s/\${TAG}/$TAG/g") + echo "download_url=$URL" >> "$GITHUB_OUTPUT" + echo "🔗 Download URL: $URL" + + - name: Generate changelog from commits + id: changelog + run: | + # Get commits since the previous tag + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + CHANGES=$(git log --pretty=format:"- %s" "$PREV_TAG"..HEAD 2>/dev/null || echo "") + else + CHANGES=$(git log --pretty=format:"- %s" -20 2>/dev/null || echo "") + fi + + # Use release body if available, otherwise use commit log + if [ "${{ github.event_name }}" = "release" ] && [ -n "${{ github.event.release.body }}" ]; then + CHANGELOG="${{ github.event.release.body }}" + else + CHANGELOG="$CHANGES" + fi + + # Write to file to handle multiline + echo "$CHANGELOG" > /tmp/changelog.txt + echo "has_changelog=true" >> "$GITHUB_OUTPUT" + + - name: Publish to PlexAddons + env: + PLEXADDONS_API_KEY: ${{ secrets.PLEXADDONS_API_KEY }} + run: | + CHANGELOG=$(cat /tmp/changelog.txt) + + # Check if this is a breaking or urgent release + BREAKING=false + URGENT=false + if echo "${{ steps.version.outputs.tag }}" | grep -qi "breaking"; then + BREAKING=true + fi + # Mark pre-1.0 major bumps or releases labeled "urgent" as urgent + if [ "${{ github.event_name }}" = "release" ]; then + LABELS='${{ toJson(github.event.release.body) }}' + if echo "$LABELS" | grep -qi "urgent"; then + URGENT=true + fi + fi + + # Build the JSON payload + PAYLOAD=$(jq -n \ + --arg version "${{ steps.version.outputs.version }}" \ + --arg download_url "${{ steps.url.outputs.download_url }}" \ + --arg description "Release ${{ steps.version.outputs.tag }}" \ + --arg changelog_content "$CHANGELOG" \ + --argjson breaking "$BREAKING" \ + --argjson urgent "$URGENT" \ + --arg channel "$CHANNEL" \ + --argjson rollout_percentage "${ROLLOUT_PERCENTAGE}" \ + '{ + version: $version, + download_url: $download_url, + description: $description, + changelog_content: $changelog_content, + breaking: $breaking, + urgent: $urgent, + channel: $channel, + rollout_percentage: ($rollout_percentage | tonumber) + }') + + echo "📤 Publishing to PlexAddons API..." + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $PLEXADDONS_API_KEY" \ + -d "$PAYLOAD" \ + "https://addons.plexdev.xyz/api/v1/addons/${ADDON_SLUG}/versions") + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "✅ Successfully published version ${{ steps.version.outputs.version }}" + echo "$BODY" | jq . + else + echo "❌ Failed to publish (HTTP $HTTP_CODE)" + echo "$BODY" | jq . 2>/dev/null || echo "$BODY" + exit 1 + fi + + - name: Summary + if: success() + run: | + echo "### ✅ Published to PlexAddons" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| **Addon** | \`${ADDON_SLUG}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| **Channel** | \`${CHANNEL}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| **Rollout** | ${ROLLOUT_PERCENTAGE}% |" >> "$GITHUB_STEP_SUMMARY" + echo "| **Download** | ${{ steps.url.outputs.download_url }} |" >> "$GITHUB_STEP_SUMMARY" diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..ba717f3 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,98 @@ +# PlexAddons CI/CD Examples + +This folder contains example GitHub Actions workflows for automating addon publishing to [addons.plexdev.xyz](https://addons.plexdev.xyz). + +## Quick Start + +1. **Copy the workflow** into your addon's repository: + ```bash + mkdir -p .github/workflows + cp publish-addon.yml .github/workflows/ + ``` + +2. **Create an API key** at https://addons.plexdev.xyz/dashboard/api-keys + - Select the `versions:write` scope + - Requires **Pro** or **Premium** subscription + +3. **Add the secret** to your GitHub repository: + - Go to **Settings → Secrets and variables → Actions** + - Click **New repository secret** + - Name: `PLEXADDONS_API_KEY` + - Value: your API key (starts with `pa_`) + +4. **Configure the workflow** — edit the `env` section in the YAML: + ```yaml + env: + ADDON_SLUG: "your-addon-slug" # Must match addons.plexdev.xyz + DOWNLOAD_URL_TEMPLATE: "https://github.com/you/repo/releases/download/${TAG}/your-file.zip" + ``` + +5. **Create a release** or push a version tag: + ```bash + git tag v1.2.3 + git push origin v1.2.3 + ``` + +## Available Workflows + +### [`publish-addon.yml`](.github/workflows/publish-addon.yml) + +Publishes a new addon version when you create a GitHub release or push a `v*` tag. + +**Features:** +- Extracts version from git tag (strips `v` prefix) +- Auto-generates changelog from commit messages +- Uses release body as changelog when available +- Detects breaking/urgent releases +- Supports release channels (stable/beta/alpha/canary) — Pro+ +- Supports gradual rollouts (0-100%) — Premium +- Posts a summary to the GitHub Actions run + +**Triggers:** +- `release: published` — when you publish a GitHub release +- `push: tags: v*` — when you push a version tag + +## API Key Scopes + +| Scope | Description | Min Tier | +|-------|-------------|----------| +| `addons:read` | View addon info | Pro | +| `versions:read` | View version info | Pro | +| `versions:write` | **Publish new versions** | Pro | +| `analytics:read` | Access analytics | Pro | +| `addons:write` | Manage addons | Premium | +| `webhooks:manage` | Manage webhooks | Premium | + +For CI/CD publishing, you only need `versions:write`. + +## API Key Limits + +| Tier | Max Keys | +|------|----------| +| Free | 0 | +| Pro | 3 | +| Premium | 10 | + +## Security Notes + +- **Never commit your API key** to the repository. Always use GitHub Secrets. +- API keys are prefixed with `pa_` and are 67 characters long. +- You can revoke keys at any time from the dashboard. +- Each key can be scoped to specific permissions. + +## Example: Manual cURL Publish + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-API-Key: pa_your_key_here" \ + -d '{ + "version": "1.2.3", + "download_url": "https://example.com/addon-v1.2.3.zip", + "description": "Bug fixes and improvements", + "changelog_content": "- Fixed issue with startup\n- Improved performance", + "channel": "stable", + "rollout_percentage": 100 + }' \ + https://addons.plexdev.xyz/api/v1/addons/your-addon-slug/versions +``` diff --git a/plexaddons-api/alembic/versions/007_add_addon_readme_icon.py b/plexaddons-api/alembic/versions/007_add_addon_readme_icon.py new file mode 100644 index 0000000..60f91fa --- /dev/null +++ b/plexaddons-api/alembic/versions/007_add_addon_readme_icon.py @@ -0,0 +1,25 @@ +"""Add readme and icon_url fields to addons + +Revision ID: 007 +Revises: 006 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '007' +down_revision = '006_add_api_keys' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('addons', sa.Column('icon_url', sa.String(500), nullable=True)) + op.add_column('addons', sa.Column('readme', sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('addons', 'readme') + op.drop_column('addons', 'icon_url') diff --git a/plexaddons-api/alembic/versions/008_add_stars_and_reviews.py b/plexaddons-api/alembic/versions/008_add_stars_and_reviews.py new file mode 100644 index 0000000..b92840b --- /dev/null +++ b/plexaddons-api/alembic/versions/008_add_stars_and_reviews.py @@ -0,0 +1,49 @@ +"""Add addon stars and reviews tables + +Revision ID: 008 +Revises: 007 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '008' +down_revision = '007' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Stars/Favorites table + op.create_table( + 'addon_stars', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('addon_id', sa.Integer(), sa.ForeignKey('addons.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index('idx_addon_stars_user_addon', 'addon_stars', ['user_id', 'addon_id'], unique=True) + op.create_index('idx_addon_stars_addon', 'addon_stars', ['addon_id']) + + # Reviews/Ratings table + op.create_table( + 'addon_reviews', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('addon_id', sa.Integer(), sa.ForeignKey('addons.id', ondelete='CASCADE'), nullable=False), + sa.Column('rating', sa.Integer(), nullable=False), + sa.Column('title', sa.String(200), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('is_visible', sa.Boolean(), default=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index('idx_addon_reviews_user_addon', 'addon_reviews', ['user_id', 'addon_id'], unique=True) + op.create_index('idx_addon_reviews_addon', 'addon_reviews', ['addon_id']) + + +def downgrade() -> None: + op.drop_table('addon_reviews') + op.drop_table('addon_stars') diff --git a/plexaddons-api/alembic/versions/009_add_screenshots_and_notifications.py b/plexaddons-api/alembic/versions/009_add_screenshots_and_notifications.py new file mode 100644 index 0000000..af7a3b2 --- /dev/null +++ b/plexaddons-api/alembic/versions/009_add_screenshots_and_notifications.py @@ -0,0 +1,42 @@ +"""Add screenshots, banner_url to addons and notifications table + +Revision ID: 009 +Revises: 008 +Create Date: 2026-02-23 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '009' +down_revision = '008' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add media fields to addons + op.add_column('addons', sa.Column('banner_url', sa.String(500), nullable=True)) + op.add_column('addons', sa.Column('screenshots', sa.JSON(), server_default='[]', nullable=True)) + + # Notifications table + op.create_table( + 'notifications', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('type', sa.String(50), nullable=False), + sa.Column('title', sa.String(200), nullable=False), + sa.Column('message', sa.Text(), nullable=True), + sa.Column('link', sa.String(500), nullable=True), + sa.Column('is_read', sa.Boolean(), default=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index('idx_notifications_user_read', 'notifications', ['user_id', 'is_read']) + op.create_index('idx_notifications_user_created', 'notifications', ['user_id', 'created_at']) + + +def downgrade() -> None: + op.drop_table('notifications') + op.drop_column('addons', 'screenshots') + op.drop_column('addons', 'banner_url') diff --git a/plexaddons-api/alembic/versions/010_add_version_channels_deprecation.py b/plexaddons-api/alembic/versions/010_add_version_channels_deprecation.py new file mode 100644 index 0000000..dbfe7e5 --- /dev/null +++ b/plexaddons-api/alembic/versions/010_add_version_channels_deprecation.py @@ -0,0 +1,41 @@ +"""Add release channels and deprecation fields to versions + +Revision ID: 010 +Revises: 009 +Create Date: 2026-02-23 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '010' +down_revision = '009' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop leftover enum from any previous failed attempt, then create fresh + op.execute("DROP TYPE IF EXISTS releasechannel CASCADE") + op.execute("CREATE TYPE releasechannel AS ENUM ('stable', 'beta', 'alpha')") + op.execute("ALTER TABLE versions ADD COLUMN channel releasechannel NOT NULL DEFAULT 'stable'::releasechannel") + + # Add deprecation fields + op.add_column('versions', sa.Column('is_deprecated', sa.Boolean(), server_default='false', nullable=False)) + op.add_column('versions', sa.Column('deprecation_reason', sa.Text(), nullable=True)) + op.add_column('versions', sa.Column('deprecated_at', sa.DateTime(timezone=True), nullable=True)) + + # Index for channel-based lookups + op.create_index('idx_versions_channel', 'versions', ['addon_id', 'channel']) + + +def downgrade() -> None: + op.drop_index('idx_versions_channel') + op.drop_column('versions', 'deprecated_at') + op.drop_column('versions', 'deprecation_reason') + op.drop_column('versions', 'is_deprecated') + op.drop_column('versions', 'channel') + + # Drop enum type + sa.Enum(name='releasechannel').drop(op.get_bind(), checkfirst=True) diff --git a/plexaddons-api/alembic/versions/011_add_collaborators.py b/plexaddons-api/alembic/versions/011_add_collaborators.py new file mode 100644 index 0000000..5efedba --- /dev/null +++ b/plexaddons-api/alembic/versions/011_add_collaborators.py @@ -0,0 +1,52 @@ +"""Add addon collaborators table + +Revision ID: 010_add_collaborators +Revises: 009_add_screenshots_and_notifications +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '011' +down_revision = '010' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop leftover table/enum from any previous failed attempt + op.execute("DROP TABLE IF EXISTS addon_collaborators CASCADE") + op.execute("DROP TYPE IF EXISTS collaboratorrole CASCADE") + + # Create CollaboratorRole enum + collaboratorrole = postgresql.ENUM('admin', 'editor', 'viewer', name='collaboratorrole', create_type=False) + collaboratorrole.create(op.get_bind(), checkfirst=True) + + op.create_table( + 'addon_collaborators', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('addon_id', sa.Integer(), sa.ForeignKey('addons.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('role', collaboratorrole, nullable=False, server_default='editor'), + sa.Column('invited_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('accepted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('accepted_at', sa.DateTime(timezone=True), nullable=True), + ) + + op.create_index('idx_addon_collaborators_addon', 'addon_collaborators', ['addon_id']) + op.create_index('idx_addon_collaborators_user', 'addon_collaborators', ['user_id']) + op.create_index('idx_addon_collaborators_unique', 'addon_collaborators', ['addon_id', 'user_id'], unique=True) + + +def downgrade() -> None: + op.drop_index('idx_addon_collaborators_unique', table_name='addon_collaborators') + op.drop_index('idx_addon_collaborators_user', table_name='addon_collaborators') + op.drop_index('idx_addon_collaborators_addon', table_name='addon_collaborators') + op.drop_table('addon_collaborators') + + collaboratorrole = postgresql.ENUM('admin', 'editor', 'viewer', name='collaboratorrole', create_type=False) + collaboratorrole.drop(op.get_bind(), checkfirst=True) diff --git a/plexaddons-api/alembic/versions/012_add_addon_theme_and_verified_dev.py b/plexaddons-api/alembic/versions/012_add_addon_theme_and_verified_dev.py new file mode 100644 index 0000000..08d4205 --- /dev/null +++ b/plexaddons-api/alembic/versions/012_add_addon_theme_and_verified_dev.py @@ -0,0 +1,32 @@ +"""Add addon theme customization and verified developer badge + +Revision ID: 010 +Revises: 009 +Create Date: 2026-02-24 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '012' +down_revision = '011' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # PRO-10: Addon page theme customization + op.execute("ALTER TABLE addons ADD COLUMN IF NOT EXISTS theme_accent_color VARCHAR(7)") + op.execute("ALTER TABLE addons ADD COLUMN IF NOT EXISTS theme_header_url VARCHAR(500)") + + # PRO-11: Verified developer badge + op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_verified_developer BOOLEAN NOT NULL DEFAULT false") + op.execute("CREATE INDEX IF NOT EXISTS idx_users_verified_developer ON users (is_verified_developer)") + + +def downgrade() -> None: + op.drop_index('idx_users_verified_developer', table_name='users') + op.drop_column('users', 'is_verified_developer') + op.drop_column('addons', 'theme_header_url') + op.drop_column('addons', 'theme_accent_color') diff --git a/plexaddons-api/alembic/versions/013_add_webhook_endpoints_deliveries.py b/plexaddons-api/alembic/versions/013_add_webhook_endpoints_deliveries.py new file mode 100644 index 0000000..ec09801 --- /dev/null +++ b/plexaddons-api/alembic/versions/013_add_webhook_endpoints_deliveries.py @@ -0,0 +1,60 @@ +"""Add webhook endpoints and delivery log tables + +Revision ID: 013 +Revises: 012 +Create Date: 2026-02-23 17:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '013' +down_revision = '012' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS webhook_deliveries CASCADE") + op.execute("DROP TABLE IF EXISTS webhook_endpoints CASCADE") + + op.create_table( + 'webhook_endpoints', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('url', sa.String(500), nullable=False), + sa.Column('secret', sa.String(64), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('event_filter', sa.JSON(), nullable=True), + sa.Column('payload_template', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index('idx_webhook_endpoints_user', 'webhook_endpoints', ['user_id']) + + op.create_table( + 'webhook_deliveries', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('endpoint_id', sa.Integer(), sa.ForeignKey('webhook_endpoints.id', ondelete='CASCADE'), nullable=False), + sa.Column('event_type', sa.String(50), nullable=False), + sa.Column('payload', sa.Text(), nullable=False), + sa.Column('status', sa.String(20), server_default='pending', nullable=False), + sa.Column('status_code', sa.Integer(), nullable=True), + sa.Column('response_body', sa.Text(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('attempt', sa.SmallInteger(), server_default='1'), + sa.Column('max_attempts', sa.SmallInteger(), server_default='6'), + sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True), + ) + op.create_index('idx_webhook_deliveries_endpoint', 'webhook_deliveries', ['endpoint_id']) + op.create_index('idx_webhook_deliveries_status', 'webhook_deliveries', ['status']) + op.create_index('idx_webhook_deliveries_retry', 'webhook_deliveries', ['status', 'next_retry_at']) + + +def downgrade() -> None: + op.drop_table('webhook_deliveries') + op.drop_table('webhook_endpoints') diff --git a/plexaddons-api/alembic/versions/014_add_org_enhancements.py b/plexaddons-api/alembic/versions/014_add_org_enhancements.py new file mode 100644 index 0000000..d5cc1eb --- /dev/null +++ b/plexaddons-api/alembic/versions/014_add_org_enhancements.py @@ -0,0 +1,63 @@ +"""Add org enhancements: banner, permissions, audit log, org API keys. + +Revision ID: 014_add_org_enhancements +Revises: 013 +Create Date: 2025-01-01 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = '014_add_org_enhancements' +down_revision = '013' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add banner_url to organizations + op.execute("ALTER TABLE organizations ADD COLUMN IF NOT EXISTS banner_url VARCHAR(500)") + + # Add permissions JSON to organization_members + op.execute("ALTER TABLE organization_members ADD COLUMN IF NOT EXISTS permissions JSON") + + # Create org_audit_logs table + op.execute("DROP TABLE IF EXISTS org_audit_logs CASCADE") + op.create_table( + 'org_audit_logs', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('organization_id', sa.Integer(), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('action', sa.String(100), nullable=False), + sa.Column('details', sa.JSON(), nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_org_audit_logs_org ON org_audit_logs (organization_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_org_audit_logs_created ON org_audit_logs (organization_id, created_at)") + + # Create org_api_keys table + op.execute("DROP TABLE IF EXISTS org_api_keys CASCADE") + op.create_table( + 'org_api_keys', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('organization_id', sa.Integer(), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('key_hash', sa.String(128), nullable=False, unique=True), + sa.Column('key_prefix', sa.String(12), nullable=False), + sa.Column('scopes', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true'), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_org_api_keys_org ON org_api_keys (organization_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_org_api_keys_hash ON org_api_keys (key_hash)") + + +def downgrade() -> None: + op.drop_table('org_api_keys') + op.drop_table('org_audit_logs') + op.execute("ALTER TABLE organization_members DROP COLUMN IF EXISTS permissions") + op.execute("ALTER TABLE organizations DROP COLUMN IF EXISTS banner_url") diff --git a/plexaddons-api/alembic/versions/015_add_rollouts_flags.py b/plexaddons-api/alembic/versions/015_add_rollouts_flags.py new file mode 100644 index 0000000..7cf5aaf --- /dev/null +++ b/plexaddons-api/alembic/versions/015_add_rollouts_flags.py @@ -0,0 +1,88 @@ +"""Add staged rollouts and feature flags + +Revision ID: 015_add_rollouts_flags +Revises: 014_add_org_enhancements +Create Date: 2025-01-01 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +revision = '015_add_rollouts_flags' +down_revision = '014_add_org_enhancements' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Clean up any partial previous runs + op.execute("DROP TABLE IF EXISTS staged_rollouts CASCADE") + op.execute("DROP TYPE IF EXISTS rolloutstage CASCADE") + op.execute("DROP TYPE IF EXISTS rolloutstatus CASCADE") + + # Staged rollouts table + op.create_table( + 'staged_rollouts', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('addon_id', sa.Integer(), sa.ForeignKey('addons.id', ondelete='CASCADE'), nullable=False), + sa.Column('version_id', sa.Integer(), sa.ForeignKey('versions.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('stage', sa.Enum('canary', 'early', 'partial', 'majority', 'full', 'paused', + name='rolloutstage'), nullable=False, server_default='canary'), + sa.Column('percentage', sa.Integer(), nullable=False, server_default='1'), + sa.Column('status', sa.Enum('draft', 'active', 'paused', 'completed', 'cancelled', + name='rolloutstatus'), nullable=False, server_default='draft'), + sa.Column('targeting_rules', sa.JSON(), nullable=True), + sa.Column('auto_promote', sa.Boolean(), server_default='false'), + sa.Column('auto_promote_after_hours', sa.Integer(), server_default='24'), + sa.Column('total_checks', sa.Integer(), server_default='0'), + sa.Column('error_reports', sa.Integer(), server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('promoted_at', sa.DateTime(timezone=True), nullable=True), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_staged_rollouts_addon ON staged_rollouts (addon_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_staged_rollouts_version ON staged_rollouts (version_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_staged_rollouts_addon_status ON staged_rollouts (addon_id, status)") + + # Rollout events table + op.execute("DROP TABLE IF EXISTS rollout_events CASCADE") + op.create_table( + 'rollout_events', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('rollout_id', sa.Integer(), sa.ForeignKey('staged_rollouts.id', ondelete='CASCADE'), nullable=False), + sa.Column('from_stage', sa.String(20), nullable=True), + sa.Column('to_stage', sa.String(20), nullable=False), + sa.Column('from_percentage', sa.Integer(), nullable=True), + sa.Column('to_percentage', sa.Integer(), nullable=False), + sa.Column('triggered_by', sa.String(20), nullable=False, server_default='manual'), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_rollout_events_rollout ON rollout_events (rollout_id)") + + # Feature flags table + op.execute("DROP TABLE IF EXISTS feature_flags CASCADE") + op.create_table( + 'feature_flags', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('addon_id', sa.Integer(), sa.ForeignKey('addons.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('key', sa.String(100), nullable=False), + sa.Column('name', sa.String(200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('enabled', sa.Boolean(), server_default='false'), + sa.Column('percentage', sa.Integer(), server_default='100'), + sa.Column('targeting', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_feature_flags_addon ON feature_flags (addon_id)") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_feature_flags_addon_key ON feature_flags (addon_id, key)") + + +def downgrade() -> None: + op.drop_table('feature_flags') + op.drop_table('rollout_events') + op.drop_table('staged_rollouts') + op.execute("DROP TYPE IF EXISTS rolloutstatus CASCADE") + op.execute("DROP TYPE IF EXISTS rolloutstage CASCADE") diff --git a/plexaddons-api/alembic/versions/016_add_security_suite.py b/plexaddons-api/alembic/versions/016_add_security_suite.py new file mode 100644 index 0000000..3ac16a1 --- /dev/null +++ b/plexaddons-api/alembic/versions/016_add_security_suite.py @@ -0,0 +1,126 @@ +"""Add security suite tables + +Revision ID: 016_add_security_suite +Revises: 015_add_rollouts_flags +Create Date: 2025-01-01 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +revision = '016_add_security_suite' +down_revision = '015_add_rollouts_flags' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Clean up any partial previous runs + op.execute("DROP TABLE IF EXISTS addon_signing_keys CASCADE") + op.execute("DROP TYPE IF EXISTS scanstatus CASCADE") + op.execute("DROP TYPE IF EXISTS vulnerabilityseverity CASCADE") + + # PREM-8: Add ip_allowlist to API keys + op.execute("ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS ip_allowlist JSON DEFAULT NULL") + + # PREM-5: Addon signing keys + op.create_table( + 'addon_signing_keys', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('addon_id', sa.Integer(), sa.ForeignKey('addons.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('public_key', sa.Text(), nullable=False), + sa.Column('key_fingerprint', sa.String(64), nullable=False), + sa.Column('algorithm', sa.String(20), nullable=False, server_default='ed25519'), + sa.Column('is_active', sa.Boolean(), server_default='true'), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_signing_keys_addon ON addon_signing_keys (addon_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_signing_keys_fingerprint ON addon_signing_keys (key_fingerprint)") + + # PREM-5: Version signatures + op.execute("DROP TABLE IF EXISTS version_signatures CASCADE") + op.create_table( + 'version_signatures', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('version_id', sa.Integer(), sa.ForeignKey('versions.id', ondelete='CASCADE'), nullable=False), + sa.Column('signing_key_id', sa.Integer(), sa.ForeignKey('addon_signing_keys.id', ondelete='SET NULL'), nullable=True), + sa.Column('signature', sa.Text(), nullable=False), + sa.Column('signed_hash', sa.String(128), nullable=False), + sa.Column('verified', sa.Boolean(), server_default='false'), + sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_version_signatures_version ON version_signatures (version_id)") + + # PREM-6: Vulnerability scans + op.execute("DROP TABLE IF EXISTS vulnerability_scans CASCADE") + op.create_table( + 'vulnerability_scans', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('version_id', sa.Integer(), sa.ForeignKey('versions.id', ondelete='CASCADE'), nullable=False), + sa.Column('initiated_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('status', sa.Enum('pending', 'scanning', 'completed', 'failed', + name='scanstatus'), nullable=False, server_default='pending'), + sa.Column('vulnerabilities', sa.JSON(), nullable=True), + sa.Column('total_vulnerabilities', sa.Integer(), server_default='0'), + sa.Column('critical_count', sa.Integer(), server_default='0'), + sa.Column('high_count', sa.Integer(), server_default='0'), + sa.Column('medium_count', sa.Integer(), server_default='0'), + sa.Column('low_count', sa.Integer(), server_default='0'), + sa.Column('scan_started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('scan_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_vuln_scans_version ON vulnerability_scans (version_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_vuln_scans_status ON vulnerability_scans (status)") + + # PREM-7: SBOM + op.execute("DROP TABLE IF EXISTS version_sboms CASCADE") + op.create_table( + 'version_sboms', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('version_id', sa.Integer(), sa.ForeignKey('versions.id', ondelete='CASCADE'), nullable=False), + sa.Column('uploaded_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('format', sa.String(50), nullable=False, server_default='npm'), + sa.Column('raw_content', sa.Text(), nullable=True), + sa.Column('dependencies', sa.JSON(), nullable=True), + sa.Column('total_dependencies', sa.Integer(), server_default='0'), + sa.Column('direct_dependencies', sa.Integer(), server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_sboms_version ON version_sboms (version_id)") + + # PREM-9: 2FA challenges + op.execute("DROP TABLE IF EXISTS two_factor_challenges CASCADE") + op.create_table( + 'two_factor_challenges', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('action', sa.String(50), nullable=False), + sa.Column('challenge_code_hash', sa.String(128), nullable=False), + sa.Column('is_verified', sa.Boolean(), server_default='false'), + sa.Column('is_used', sa.Boolean(), server_default='false'), + sa.Column('attempts', sa.Integer(), server_default='0'), + sa.Column('max_attempts', sa.Integer(), server_default='5'), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True), + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_2fa_challenges_user ON two_factor_challenges (user_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_2fa_challenges_expires ON two_factor_challenges (expires_at)") + + +def downgrade() -> None: + op.drop_table('two_factor_challenges') + op.drop_table('version_sboms') + op.drop_table('vulnerability_scans') + op.drop_table('version_signatures') + op.drop_table('addon_signing_keys') + op.execute("ALTER TABLE api_keys DROP COLUMN IF EXISTS ip_allowlist") + op.execute("DROP TYPE IF EXISTS scanstatus CASCADE") + op.execute("DROP TYPE IF EXISTS vulnerabilityseverity CASCADE") diff --git a/plexaddons-api/alembic/versions/017_add_analytics_suite.py b/plexaddons-api/alembic/versions/017_add_analytics_suite.py new file mode 100644 index 0000000..ff60572 --- /dev/null +++ b/plexaddons-api/alembic/versions/017_add_analytics_suite.py @@ -0,0 +1,90 @@ +"""Add premium analytics suite tables (self-hosted config, alerts, cohort entries) + +Revision ID: 017_add_analytics_suite +Revises: 016_add_security_suite +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + +# revision identifiers, used by Alembic. +revision = '017_add_analytics_suite' +down_revision = '016_add_security_suite' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create enum types + alertnotificationchannel = sa.Enum('webhook', 'email', 'discord', name='alertnotificationchannel') + alertnotificationchannel.create(op.get_bind(), checkfirst=True) + + alertcomparison = sa.Enum('below', 'above', name='alertcomparison') + alertcomparison.create(op.get_bind(), checkfirst=True) + + # Self-hosted version checker configs (PREM-15) + op.execute(""" + CREATE TABLE IF NOT EXISTS self_hosted_configs ( + id SERIAL PRIMARY KEY, + addon_id INTEGER NOT NULL REFERENCES addons(id) ON DELETE CASCADE UNIQUE, + custom_domain VARCHAR(255) UNIQUE, + domain_verified BOOLEAN DEFAULT FALSE, + verification_token VARCHAR(64), + private_endpoint_enabled BOOLEAN DEFAULT TRUE, + api_key_required BOOLEAN DEFAULT TRUE, + rate_limit_per_minute INTEGER DEFAULT 60, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_self_hosted_configs_addon ON self_hosted_configs(addon_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_self_hosted_configs_domain ON self_hosted_configs(custom_domain)") + + # Analytics alerts (PREM-17) + op.execute(""" + CREATE TABLE IF NOT EXISTS analytics_alerts ( + id SERIAL PRIMARY KEY, + addon_id INTEGER NOT NULL REFERENCES addons(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + metric VARCHAR(50) NOT NULL, + comparison alertcomparison NOT NULL, + threshold INTEGER NOT NULL, + notification_channel alertnotificationchannel NOT NULL, + webhook_url VARCHAR(500), + email VARCHAR(320), + discord_webhook_url VARCHAR(500), + is_active BOOLEAN DEFAULT TRUE, + last_triggered_at TIMESTAMPTZ, + trigger_count INTEGER DEFAULT 0, + cooldown_minutes INTEGER DEFAULT 60, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_analytics_alerts_addon ON analytics_alerts(addon_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_analytics_alerts_active ON analytics_alerts(is_active)") + + # Cohort entries for version upgrade tracking (PREM-18) + op.execute(""" + CREATE TABLE IF NOT EXISTS cohort_entries ( + id SERIAL PRIMARY KEY, + addon_id INTEGER NOT NULL REFERENCES addons(id) ON DELETE CASCADE, + from_version VARCHAR(50) NOT NULL, + to_version VARCHAR(50) NOT NULL, + client_ip_hash VARCHAR(64) NOT NULL, + transitioned_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_cohort_entries_addon ON cohort_entries(addon_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_cohort_entries_addon_versions ON cohort_entries(addon_id, from_version, to_version)") + op.execute("CREATE INDEX IF NOT EXISTS idx_cohort_entries_transitioned ON cohort_entries(transitioned_at)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS cohort_entries CASCADE") + op.execute("DROP TABLE IF EXISTS analytics_alerts CASCADE") + op.execute("DROP TABLE IF EXISTS self_hosted_configs CASCADE") + op.execute("DROP TYPE IF EXISTS alertcomparison CASCADE") + op.execute("DROP TYPE IF EXISTS alertnotificationchannel CASCADE") diff --git a/plexaddons-api/alembic/versions/018_add_marketplace.py b/plexaddons-api/alembic/versions/018_add_marketplace.py new file mode 100644 index 0000000..1acf749 --- /dev/null +++ b/plexaddons-api/alembic/versions/018_add_marketplace.py @@ -0,0 +1,63 @@ +"""Add marketplace and sponsorship (addon licenses, paid addon fields, stripe connect) + +Revision ID: 018_add_marketplace +Revises: 017_add_analytics_suite +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '018_add_marketplace' +down_revision = '017_add_analytics_suite' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create license status enum + licensestatus = sa.Enum('active', 'expired', 'revoked', 'suspended', name='licensestatus') + licensestatus.create(op.get_bind(), checkfirst=True) + + # Add marketplace columns to addons table + op.execute("ALTER TABLE addons ADD COLUMN IF NOT EXISTS is_paid BOOLEAN DEFAULT FALSE") + op.execute("ALTER TABLE addons ADD COLUMN IF NOT EXISTS price_cents INTEGER") + op.execute("ALTER TABLE addons ADD COLUMN IF NOT EXISTS revenue_split_percent INTEGER DEFAULT 90") + op.execute("ALTER TABLE addons ADD COLUMN IF NOT EXISTS sponsor_url VARCHAR(500)") + + # Add Stripe Connect account ID to users table + op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_connect_account_id VARCHAR(255) UNIQUE") + + # Create addon_licenses table + op.execute(""" + CREATE TABLE IF NOT EXISTS addon_licenses ( + id SERIAL PRIMARY KEY, + addon_id INTEGER NOT NULL REFERENCES addons(id) ON DELETE CASCADE, + buyer_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + license_key VARCHAR(64) NOT NULL UNIQUE, + stripe_payment_intent_id VARCHAR(255), + amount_cents INTEGER NOT NULL, + developer_amount_cents INTEGER NOT NULL, + platform_amount_cents INTEGER NOT NULL, + status licensestatus NOT NULL DEFAULT 'active', + server_id VARCHAR(100), + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + revoked_at TIMESTAMPTZ + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_addon_licenses_addon ON addon_licenses(addon_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_addon_licenses_buyer ON addon_licenses(buyer_id)") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_addon_licenses_key ON addon_licenses(license_key)") + op.execute("CREATE INDEX IF NOT EXISTS idx_addon_licenses_status ON addon_licenses(status)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS addon_licenses CASCADE") + op.execute("ALTER TABLE addons DROP COLUMN IF EXISTS is_paid") + op.execute("ALTER TABLE addons DROP COLUMN IF EXISTS price_cents") + op.execute("ALTER TABLE addons DROP COLUMN IF EXISTS revenue_split_percent") + op.execute("ALTER TABLE addons DROP COLUMN IF EXISTS sponsor_url") + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS stripe_connect_account_id") + op.execute("DROP TYPE IF EXISTS licensestatus CASCADE") diff --git a/plexaddons-api/alembic/versions/019_add_db_indexes.py b/plexaddons-api/alembic/versions/019_add_db_indexes.py new file mode 100644 index 0000000..0237992 --- /dev/null +++ b/plexaddons-api/alembic/versions/019_add_db_indexes.py @@ -0,0 +1,29 @@ +"""Add missing database indexes for query optimization + +Revision ID: 019_add_db_indexes +Revises: 018_add_marketplace +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "019_add_db_indexes" +down_revision = "018_add_marketplace" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Addon indexes for common query patterns + op.execute("CREATE INDEX IF NOT EXISTS idx_addons_public_active ON addons (is_public, is_active)") + op.execute("CREATE INDEX IF NOT EXISTS idx_addons_updated_at ON addons (updated_at)") + + # Version indexes for sorting within addon + op.execute("CREATE INDEX IF NOT EXISTS idx_versions_addon_created_at ON versions (addon_id, created_at)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_versions_addon_created_at") + op.execute("DROP INDEX IF EXISTS idx_addons_updated_at") + op.execute("DROP INDEX IF EXISTS idx_addons_public_active") diff --git a/plexaddons-api/alembic/versions/020_drop_legacy_api_key.py b/plexaddons-api/alembic/versions/020_drop_legacy_api_key.py new file mode 100644 index 0000000..9030443 --- /dev/null +++ b/plexaddons-api/alembic/versions/020_drop_legacy_api_key.py @@ -0,0 +1,29 @@ +"""Drop legacy User.api_key and api_key_created_at columns + +Replaced by the multi-key ApiKey table (see 006_add_api_keys). + +Revision ID: 020_drop_legacy_api_key +Revises: 019_add_db_indexes +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "020_drop_legacy_api_key" +down_revision = "019_add_db_indexes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_index("ix_users_api_key", table_name="users", if_exists=True) + op.drop_column("users", "api_key") + op.drop_column("users", "api_key_created_at") + + +def downgrade() -> None: + op.add_column("users", sa.Column("api_key_created_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("api_key", sa.String(67), nullable=True)) + op.create_index("ix_users_api_key", "users", ["api_key"], unique=True) diff --git a/plexaddons-api/alembic/versions/021_add_trgm_indexes.py b/plexaddons-api/alembic/versions/021_add_trgm_indexes.py new file mode 100644 index 0000000..21d084f --- /dev/null +++ b/plexaddons-api/alembic/versions/021_add_trgm_indexes.py @@ -0,0 +1,39 @@ +"""Add GIN trigram indexes for full-text search + +pg_trgm extension is already enabled in init-db.sql. +These indexes speed up ILIKE queries on addon name and description. + +Revision ID: 021_add_trgm_indexes +Revises: 020_drop_legacy_api_key +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "021_add_trgm_indexes" +down_revision = "020_drop_legacy_api_key" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # GIN trigram indexes for fast ILIKE pattern matching + op.execute( + "CREATE INDEX IF NOT EXISTS idx_addons_name_trgm " + "ON addons USING gin (name gin_trgm_ops)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_addons_description_trgm " + "ON addons USING gin (description gin_trgm_ops)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_addons_slug_trgm " + "ON addons USING gin (slug gin_trgm_ops)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_addons_slug_trgm") + op.execute("DROP INDEX IF EXISTS idx_addons_description_trgm") + op.execute("DROP INDEX IF EXISTS idx_addons_name_trgm") diff --git a/plexaddons-api/alembic/versions/022_add_canary_channel.py b/plexaddons-api/alembic/versions/022_add_canary_channel.py new file mode 100644 index 0000000..f69f7ac --- /dev/null +++ b/plexaddons-api/alembic/versions/022_add_canary_channel.py @@ -0,0 +1,25 @@ +"""Add canary release channel to ReleaseChannel enum + +Revision ID: 022_add_canary_channel +Revises: 021_add_trgm_indexes +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "022_add_canary_channel" +down_revision = "021_add_trgm_indexes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add 'canary' value to the releasechannel enum type + op.execute("ALTER TYPE releasechannel ADD VALUE IF NOT EXISTS 'canary'") + + +def downgrade() -> None: + # PostgreSQL does not support removing enum values directly. + # To fully revert, would need to recreate the enum type. + pass diff --git a/plexaddons-api/alembic/versions/023_add_ab_experiments.py b/plexaddons-api/alembic/versions/023_add_ab_experiments.py new file mode 100644 index 0000000..54d19a9 --- /dev/null +++ b/plexaddons-api/alembic/versions/023_add_ab_experiments.py @@ -0,0 +1,66 @@ +"""Add A/B experiment tables + +Revision ID: 023_add_ab_experiments +Revises: 022_add_canary_channel +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "023_add_ab_experiments" +down_revision = "022_add_canary_channel" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Use pure SQL to avoid SQLAlchemy trying to CREATE TYPE for the enum + op.execute( + "DO $$ BEGIN " + "CREATE TYPE experimentstatus AS ENUM ('draft', 'running', 'paused', 'completed'); " + "EXCEPTION WHEN duplicate_object THEN NULL; " + "END $$" + ) + + op.execute(""" + CREATE TABLE IF NOT EXISTS ab_experiments ( + id SERIAL PRIMARY KEY, + addon_id INTEGER NOT NULL REFERENCES addons(id) ON DELETE CASCADE, + created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + status experimentstatus NOT NULL DEFAULT 'draft', + targeting_rules JSONB, + auto_promote BOOLEAN DEFAULT FALSE, + auto_promote_after_hours INTEGER DEFAULT 24, + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_ab_experiments_addon ON ab_experiments (addon_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_ab_experiments_status ON ab_experiments (addon_id, status)") + + op.execute(""" + CREATE TABLE IF NOT EXISTS ab_variants ( + id SERIAL PRIMARY KEY, + experiment_id INTEGER NOT NULL REFERENCES ab_experiments(id) ON DELETE CASCADE, + version_id INTEGER REFERENCES versions(id) ON DELETE SET NULL, + name VARCHAR(100) NOT NULL, + percentage INTEGER NOT NULL, + is_control BOOLEAN DEFAULT FALSE, + total_checks INTEGER DEFAULT 0, + error_reports INTEGER DEFAULT 0, + unique_users INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS idx_ab_variants_experiment ON ab_variants (experiment_id)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS ab_variants") + op.execute("DROP TABLE IF EXISTS ab_experiments") + op.execute("DROP TYPE IF EXISTS experimentstatus") diff --git a/plexaddons-api/app/api/deps.py b/plexaddons-api/app/api/deps.py index 11d95c9..01b5aa0 100644 --- a/plexaddons-api/app/api/deps.py +++ b/plexaddons-api/app/api/deps.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.database import get_db -from app.models import User, ApiKey +from app.models import User, ApiKey, Addon, AddonLicense, LicenseStatus from app.core.security import decode_access_token from app.core.exceptions import UnauthorizedError, ForbiddenError from app.core.rate_limit import get_rate_limiter @@ -79,6 +79,33 @@ def get_effective_tier(user: User): return user.subscription_tier +async def check_paid_addon_access( + db: AsyncSession, addon: Addon, user: Optional[User] +) -> bool: + """ + Check if a user has download access to a paid addon. + Returns True if: + - The addon is not paid + - The user is the addon owner + - The user is an admin + - The user has an active license + """ + if not addon.is_paid: + return True + if not user: + return False + if addon.owner_id == user.id or user.is_admin: + return True + result = await db.execute( + select(AddonLicense.id).where( + AddonLicense.addon_id == addon.id, + AddonLicense.buyer_id == user.id, + AddonLicense.status == LicenseStatus.ACTIVE, + ).limit(1) + ) + return result.scalar_one_or_none() is not None + + async def require_pro( user: User = Depends(get_current_user), ) -> User: diff --git a/plexaddons-api/app/api/public/__init__.py b/plexaddons-api/app/api/public/__init__.py index 2ec2270..3a7c125 100644 --- a/plexaddons-api/app/api/public/__init__.py +++ b/plexaddons-api/app/api/public/__init__.py @@ -74,6 +74,7 @@ async def public_addon_latest( db: AsyncSession = Depends(get_db), _: None = Depends(rate_limit_check), x_current_version: Optional[str] = Header(None, alias="X-Current-Version"), + x_client_id: Optional[str] = Header(None, alias="X-Client-Id"), ): """ Get latest version info for a specific addon by name or slug. @@ -106,8 +107,13 @@ async def public_addon_latest( # Log version check for analytics if version header provided if x_current_version: try: - # Get client IP + # Prefer X-Client-Id (stable per-instance) over IP (can change) client_ip = request.client.host if request.client else "unknown" + if x_client_id and len(x_client_id) >= 16: + # Use the client-provided instance ID directly (already opaque) + ip_hash = x_client_id[:64] + else: + ip_hash = AnalyticsService.hash_ip(client_ip) # Find addon in database by slug or name addon_result = await db.execute( @@ -129,17 +135,17 @@ async def public_addon_latest( version = version_result.scalar_one_or_none() version_id = version.id if version else None - # Log the version check + # Log the version check (pass pre-computed hash) await AnalyticsService.log_version_check( db, addon_id=db_addon.id, version_id=version_id, checked_version=x_current_version, client_ip=client_ip, + client_id=ip_hash if x_client_id and len(x_client_id) >= 16 else None, ) # Update daily stats - ip_hash = AnalyticsService.hash_ip(client_ip) await AnalyticsService.update_daily_stats( db, addon_id=db_addon.id, diff --git a/plexaddons-api/app/api/v1/__init__.py b/plexaddons-api/app/api/v1/__init__.py index b288091..dfa08d8 100644 --- a/plexaddons-api/app/api/v1/__init__.py +++ b/plexaddons-api/app/api/v1/__init__.py @@ -1,5 +1,10 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, addons, versions, payments, admin, tickets, profiles, analytics, tags, organizations, automation, api_keys +from app.api.v1 import ( + auth, users, addons, versions, payments, admin, tickets, profiles, + analytics, tags, organizations, automation, api_keys, stars, reviews, + notifications, collaborators, marketplace, premium_analytics, security, + rollouts, feature_flags, webhooks, experiments, +) router = APIRouter(prefix="/v1") @@ -16,3 +21,27 @@ router.include_router(organizations.router) router.include_router(automation.router) router.include_router(api_keys.router) +router.include_router(stars.router) +router.include_router(reviews.router) +router.include_router(notifications.router) +router.include_router(collaborators.router) +router.include_router(collaborators.transfer_router) +router.include_router(collaborators.invitation_router) +router.include_router(marketplace.marketplace_router) +router.include_router(marketplace.connect_router) +router.include_router(marketplace.sponsorship_router) +router.include_router(premium_analytics.selfhosted_router) +router.include_router(premium_analytics.alerts_router) +router.include_router(premium_analytics.cohort_router) +router.include_router(premium_analytics.predictive_router) +router.include_router(premium_analytics.realtime_router) +router.include_router(security.signing_router) +router.include_router(security.sig_router) +router.include_router(security.scan_router) +router.include_router(security.sbom_router) +router.include_router(security.ip_router) +router.include_router(security.twofa_router) +router.include_router(rollouts.router) +router.include_router(feature_flags.router) +router.include_router(webhooks.router) +router.include_router(experiments.router) diff --git a/plexaddons-api/app/api/v1/addons.py b/plexaddons-api/app/api/v1/addons.py index 7b70e26..1e276ca 100644 --- a/plexaddons-api/app/api/v1/addons.py +++ b/plexaddons-api/app/api/v1/addons.py @@ -1,8 +1,10 @@ from fastapi import APIRouter, Depends, Query, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession -from typing import Optional, List +from sqlalchemy import select, func, and_, case, desc +from typing import Optional, List, Literal +from datetime import date, timedelta from app.database import get_db -from app.models import User, Addon +from app.models import User, Addon, AddonUsageStats, AddonStar, AddonReview, Version, ReleaseChannel from app.schemas import ( AddonCreate, AddonUpdate, @@ -12,8 +14,10 @@ VersionListResponse, ) from app.services import AddonService, VersionService -from app.api.deps import get_current_user, get_current_user_optional, rate_limit_check, rate_limit_check_authenticated +from app.api.deps import get_current_user, get_current_user_optional, get_effective_tier, rate_limit_check, rate_limit_check_authenticated, check_paid_addon_access from app.core.exceptions import NotFoundError, ForbiddenError +from app.core.cache import cache +from app.models import SubscriptionTier router = APIRouter(prefix="/addons", tags=["Addons"]) @@ -23,26 +27,44 @@ async def list_addons( page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100), search: Optional[str] = None, + tag: Optional[str] = None, + sort_by: Literal["newest", "oldest", "name_asc", "name_desc", "updated"] = "updated", db: AsyncSession = Depends(get_db), user: Optional[User] = Depends(get_current_user_optional), _: None = Depends(rate_limit_check), ): - """List all public addons.""" + """List all public addons with server-side search, tag filter, and sorting.""" skip = (page - 1) * per_page + + # Try cache for unauthenticated, unfiltered requests + cache_key = None + if not search and not tag: + cache_key = f"addon_list:{sort_by}:{page}:{per_page}" + cached = await cache.get(cache_key) + if cached: + return AddonListResponse(**cached) + addons, total = await AddonService.list_addons( db, skip=skip, limit=per_page, search=search, + tag=tag, + sort_by=sort_by, public_only=True, ) - return AddonListResponse( + result = AddonListResponse( addons=[AddonResponse(**addon) for addon in addons], total=total, page=page, per_page=per_page, ) + + if cache_key: + await cache.set(cache_key, result.model_dump(), cache.TTL_ADDON_LIST) + + return result @router.get("/mine", response_model=AddonListResponse) @@ -71,6 +93,135 @@ async def list_my_addons( ) +@router.get("/trending") +async def get_trending_addons( + limit: int = Query(10, ge=1, le=50), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check), +): + """Get trending addons based on recent activity growth, stars, and reviews.""" + today = date.today() + recent_start = today - timedelta(days=7) + older_start = today - timedelta(days=14) + + # Subquery: recent 7 days check count per addon + recent_checks = ( + select( + AddonUsageStats.addon_id, + func.coalesce(func.sum(AddonUsageStats.check_count), 0).label("recent_checks"), + func.coalesce(func.sum(AddonUsageStats.unique_users), 0).label("recent_unique"), + ) + .where(AddonUsageStats.date >= recent_start) + .group_by(AddonUsageStats.addon_id) + .subquery() + ) + + # Subquery: older 7 days check count per addon (for growth calculation) + older_checks = ( + select( + AddonUsageStats.addon_id, + func.coalesce(func.sum(AddonUsageStats.check_count), 0).label("older_checks"), + ) + .where(and_(AddonUsageStats.date >= older_start, AddonUsageStats.date < recent_start)) + .group_by(AddonUsageStats.addon_id) + .subquery() + ) + + # Subquery: star count per addon + star_counts = ( + select( + AddonStar.addon_id, + func.count(AddonStar.id).label("star_count"), + ) + .group_by(AddonStar.addon_id) + .subquery() + ) + + # Subquery: average rating per addon + review_stats = ( + select( + AddonReview.addon_id, + func.avg(AddonReview.rating).label("avg_rating"), + func.count(AddonReview.id).label("review_count"), + ) + .where(AddonReview.is_visible == True) + .group_by(AddonReview.addon_id) + .subquery() + ) + + # Build composite trending score: + # score = recent_unique_users * 2 + growth_factor + stars * 3 + (avg_rating * review_count) + query = ( + select( + Addon, + func.coalesce(recent_checks.c.recent_checks, 0).label("recent_checks"), + func.coalesce(recent_checks.c.recent_unique, 0).label("recent_unique"), + func.coalesce(older_checks.c.older_checks, 0).label("older_checks"), + func.coalesce(star_counts.c.star_count, 0).label("star_count"), + func.coalesce(review_stats.c.avg_rating, 0).label("avg_rating"), + func.coalesce(review_stats.c.review_count, 0).label("review_count"), + ) + .outerjoin(recent_checks, Addon.id == recent_checks.c.addon_id) + .outerjoin(older_checks, Addon.id == older_checks.c.addon_id) + .outerjoin(star_counts, Addon.id == star_counts.c.addon_id) + .outerjoin(review_stats, Addon.id == review_stats.c.addon_id) + .where(Addon.is_public == True, Addon.is_active == True) + .order_by( + desc( + func.coalesce(recent_checks.c.recent_unique, 0) * 2 + + func.coalesce(star_counts.c.star_count, 0) * 3 + + func.coalesce(review_stats.c.avg_rating, 0) + * func.coalesce(review_stats.c.review_count, 0) + + case( + (func.coalesce(older_checks.c.older_checks, 0) > 0, + func.coalesce(recent_checks.c.recent_checks, 0) * 100 + / func.coalesce(older_checks.c.older_checks, 1)), + else_=func.coalesce(recent_checks.c.recent_checks, 0), + ) + ) + ) + .limit(limit) + ) + + result = await db.execute(query) + rows = result.all() + + trending = [] + for row in rows: + addon = row[0] + # Get owner + owner_result = await db.execute(select(User).where(User.id == addon.owner_id)) + owner = owner_result.scalar_one_or_none() + + # Get latest version + latest_result = await db.execute( + select(Version) + .where(Version.addon_id == addon.id) + .order_by(Version.release_date.desc(), Version.created_at.desc()) + .limit(1) + ) + latest = latest_result.scalar_one_or_none() + + trending.append({ + "id": addon.id, + "slug": addon.slug, + "name": addon.name, + "description": addon.description, + "verified": addon.verified, + "external": addon.external, + "tags": addon.tags or [], + "owner_username": owner.discord_username if owner else None, + "owner_discord_id": owner.discord_id if owner else None, + "latest_version": latest.version if latest else None, + "star_count": row.star_count or 0, + "avg_rating": round(float(row.avg_rating), 1) if row.avg_rating else None, + "review_count": row.review_count or 0, + "recent_unique_users": row.recent_unique or 0, + }) + + return {"trending": trending} + + @router.post("", response_model=AddonResponse) async def create_addon( data: AddonCreate, @@ -88,6 +239,8 @@ async def create_addon( if addon.is_public: await UserService.check_and_add_creator_badge(db, user) + await cache.invalidate_addon(addon_id=addon.id, addon_slug=addon.slug) + return AddonResponse( id=addon.id, slug=addon.slug, @@ -101,11 +254,23 @@ async def create_addon( owner_id=addon.owner_id, organization_id=addon.organization_id, tags=addon.tags or [], + icon_url=addon.icon_url, + readme=addon.readme, + banner_url=addon.banner_url, + screenshots=addon.screenshots or [], + theme_accent_color=addon.theme_accent_color, + theme_header_url=addon.theme_header_url, + is_paid=addon.is_paid, + price_cents=addon.price_cents, + revenue_split_percent=addon.revenue_split_percent, + sponsor_url=addon.sponsor_url, owner_username=user.discord_username, owner_discord_id=user.discord_id, + owner_verified_developer=user.is_verified_developer, latest_version=None, latest_release_date=None, version_count=0, + download_count=0, created_at=addon.created_at, updated_at=addon.updated_at, ) @@ -129,7 +294,7 @@ async def get_addon( raise NotFoundError("Addon not found") # Get enriched data - from app.models import Version + from app.models import Version, AddonUsageStats from sqlalchemy import select, func # Get owner @@ -151,6 +316,13 @@ async def get_addon( ) version_count = count_result.scalar() or 0 + # Get total download/check count + download_result = await db.execute( + select(func.coalesce(func.sum(AddonUsageStats.check_count), 0)) + .where(AddonUsageStats.addon_id == addon.id) + ) + download_count = download_result.scalar() or 0 + return AddonResponse( id=addon.id, slug=addon.slug, @@ -164,11 +336,23 @@ async def get_addon( owner_id=addon.owner_id, organization_id=addon.organization_id, tags=addon.tags or [], + icon_url=addon.icon_url, + readme=addon.readme, + banner_url=addon.banner_url, + screenshots=addon.screenshots or [], + theme_accent_color=addon.theme_accent_color, + theme_header_url=addon.theme_header_url, + is_paid=addon.is_paid, + price_cents=addon.price_cents, + revenue_split_percent=addon.revenue_split_percent, + sponsor_url=addon.sponsor_url, owner_username=owner.discord_username if owner else None, owner_discord_id=owner.discord_id if owner else None, + owner_verified_developer=owner.is_verified_developer if owner else False, latest_version=latest.version if latest else None, latest_release_date=latest.release_date if latest else None, version_count=version_count, + download_count=download_count, created_at=addon.created_at, updated_at=addon.updated_at, ) @@ -187,7 +371,14 @@ async def update_addon( if not addon: raise NotFoundError("Addon not found") + # Theme customization requires Pro+ tier + effective_tier = get_effective_tier(user) + if effective_tier == SubscriptionTier.FREE: + data.theme_accent_color = None + data.theme_header_url = None + updated = await AddonService.update_addon(db, addon, user, data) + await cache.invalidate_addon(addon_id=updated.id, addon_slug=updated.slug) return await get_addon(updated.slug, db, user) @@ -204,6 +395,7 @@ async def delete_addon( raise NotFoundError("Addon not found") await AddonService.delete_addon(db, addon, user) + await cache.invalidate_addon(addon_id=addon.id, addon_slug=addon.slug) return {"status": "deleted"} @@ -213,11 +405,12 @@ async def list_addon_versions( slug: str, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), + channel: Optional[ReleaseChannel] = Query(None, description="Filter by release channel"), db: AsyncSession = Depends(get_db), user: Optional[User] = Depends(get_current_user_optional), _: None = Depends(rate_limit_check), ): - """List versions for an addon.""" + """List versions for an addon, optionally filtered by release channel.""" addon = await AddonService.get_addon_by_slug(db, slug) if not addon: raise NotFoundError("Addon not found") @@ -227,9 +420,17 @@ async def list_addon_versions( if not user or (addon.owner_id != user.id and not user.is_admin): raise NotFoundError("Addon not found") - versions, total = await VersionService.list_versions(db, addon.id, skip=skip, limit=limit) + versions, total = await VersionService.list_versions(db, addon.id, skip=skip, limit=limit, channel=channel) + + has_access = await check_paid_addon_access(db, addon, user) + version_responses = [] + for v in versions: + resp = VersionResponse.model_validate(v) + if not has_access: + resp.download_url = "" + version_responses.append(resp) return VersionListResponse( - versions=[VersionResponse.model_validate(v) for v in versions], + versions=version_responses, total=total, ) diff --git a/plexaddons-api/app/api/v1/admin.py b/plexaddons-api/app/api/v1/admin.py index 3b322b0..3ebe757 100644 --- a/plexaddons-api/app/api/v1/admin.py +++ b/plexaddons-api/app/api/v1/admin.py @@ -258,6 +258,17 @@ async def promote_to_admin( raise BadRequestError("User is already an admin") user.is_admin = True + # Add 'staff' badge so profile displays Admin role + import json + badges = [] + if user.badges: + try: + badges = json.loads(user.badges) if isinstance(user.badges, str) else (user.badges or []) + except Exception: + badges = [] + if 'staff' not in badges: + badges.insert(0, 'staff') + user.badges = json.dumps(badges) await db.commit() await log_admin_action( @@ -290,6 +301,17 @@ async def demote_from_admin( raise BadRequestError("User is not an admin") user.is_admin = False + # Remove 'staff' badge + import json + badges = [] + if user.badges: + try: + badges = json.loads(user.badges) if isinstance(user.badges, str) else (user.badges or []) + except Exception: + badges = [] + if 'staff' in badges: + badges.remove('staff') + user.badges = json.dumps(badges) await db.commit() await log_admin_action( @@ -482,6 +504,34 @@ async def remove_user_badge( return {"status": "removed", "user_id": user_id, "badge": badge, "badges": badges} +@router.patch("/users/{user_id}/verified-developer") +async def set_verified_developer( + user_id: int, + verified: bool = Query(..., description="Set verified developer status"), + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Set or unset verified developer status for a user.""" + user = await UserService.get_user_by_id(db, user_id) + if not user: + raise NotFoundError("User not found") + + user.is_verified_developer = verified + await db.commit() + await db.refresh(user) + + await log_admin_action( + db, admin, + action="set_verified_developer", + target_type="user", + target_id=user_id, + details={"username": user.discord_username, "verified": verified}, + ) + + return {"status": "updated", "user_id": user_id, "is_verified_developer": verified} + + @router.post("/badges/sync-all") async def sync_all_user_badges( admin: User = Depends(get_admin_user), diff --git a/plexaddons-api/app/api/v1/analytics.py b/plexaddons-api/app/api/v1/analytics.py index b334ea4..c65f62e 100644 --- a/plexaddons-api/app/api/v1/analytics.py +++ b/plexaddons-api/app/api/v1/analytics.py @@ -1,10 +1,15 @@ """Analytics endpoints for addon usage statistics.""" -from fastapi import APIRouter, Depends, HTTPException, status +import csv +import io +import json +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func +from datetime import date, timedelta from app.database import get_db -from app.models import User, Addon, SubscriptionTier +from app.models import User, Addon, SubscriptionTier, ApiKey, ApiRequestLog from app.schemas import AddonAnalyticsResponse, AnalyticsSummary from app.services import AnalyticsService from app.api.deps import get_current_user, rate_limit_check_authenticated, get_effective_tier @@ -12,8 +17,13 @@ router = APIRouter(prefix="/analytics", tags=["Analytics"]) +def _get_max_days(effective_tier: SubscriptionTier) -> int: + return 90 if effective_tier == SubscriptionTier.PREMIUM else 30 + + @router.get("/summary", response_model=AnalyticsSummary) async def get_my_analytics_summary( + days: int = Query(None, ge=1, le=90, description="Custom date range (days). Capped by tier."), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), _: None = Depends(rate_limit_check_authenticated), @@ -21,10 +31,7 @@ async def get_my_analytics_summary( """ Get analytics summary for all of the current user's addons. - Tier restrictions: - - Pro: 30 days of data - - Premium: 90 days of data - - Free: Not available + Supports custom date range via `days` parameter (capped by tier limit). """ effective_tier = get_effective_tier(user) if effective_tier == SubscriptionTier.FREE: @@ -33,11 +40,11 @@ async def get_my_analytics_summary( detail="Analytics require Pro or Premium subscription" ) - # Determine days based on tier - days = 90 if effective_tier == SubscriptionTier.PREMIUM else 30 + max_days = _get_max_days(effective_tier) + actual_days = min(days, max_days) if days else max_days summary = await AnalyticsService.get_user_analytics_summary( - db, user.id, days + db, user.id, actual_days ) return summary @@ -46,6 +53,7 @@ async def get_my_analytics_summary( @router.get("/addons/{addon_id}", response_model=AddonAnalyticsResponse) async def get_addon_analytics( addon_id: int, + days: int = Query(None, ge=1, le=90, description="Custom date range (days). Capped by tier."), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), _: None = Depends(rate_limit_check_authenticated), @@ -53,11 +61,7 @@ async def get_addon_analytics( """ Get detailed analytics for a specific addon. - Tier restrictions: - - Pro: 30 days of data - - Premium: 90 days of data - - Free: Not available - + Supports custom date range via `days` parameter (capped by tier limit). Only the addon owner can view analytics. """ effective_tier = get_effective_tier(user) @@ -85,11 +89,154 @@ async def get_addon_analytics( detail="You can only view analytics for your own addons" ) - # Determine days based on effective tier - days = 90 if effective_tier == SubscriptionTier.PREMIUM else 30 + max_days = _get_max_days(effective_tier) + actual_days = min(days, max_days) if days else max_days analytics = await AnalyticsService.get_addon_analytics( - db, addon_id, days + db, addon_id, actual_days ) return analytics + + +@router.get("/addons/{addon_id}/export") +async def export_addon_analytics( + addon_id: int, + format: str = Query("csv", regex="^(csv|json)$"), + days: int = Query(None, ge=1, le=90), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Export addon analytics data as CSV or JSON (Pro+ feature).""" + effective_tier = get_effective_tier(user) + if effective_tier == SubscriptionTier.FREE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Analytics export requires Pro or Premium subscription" + ) + + addon_result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = addon_result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not your addon") + + max_days = _get_max_days(effective_tier) + actual_days = min(days, max_days) if days else max_days + + analytics = await AnalyticsService.get_addon_analytics(db, addon_id, actual_days) + + if format == "json": + export_data = { + "addon": analytics.addon_name, + "period_days": analytics.period_days, + "total_checks": analytics.total_checks, + "total_unique_users": analytics.total_unique_users, + "daily_stats": [ + {"date": str(d.date), "check_count": d.check_count, "unique_users": d.unique_users} + for d in analytics.daily_stats + ], + "version_distribution": [ + { + "version": v.version, "check_count": v.check_count, + "unique_users": v.unique_users, "percentage": v.percentage + } + for v in analytics.version_distribution + ], + } + content = json.dumps(export_data, indent=2) + return StreamingResponse( + io.BytesIO(content.encode()), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={analytics.addon_slug}-analytics.json"}, + ) + else: + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["date", "check_count", "unique_users"]) + for d in analytics.daily_stats: + writer.writerow([str(d.date), d.check_count, d.unique_users]) + content = output.getvalue() + return StreamingResponse( + io.BytesIO(content.encode()), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={analytics.addon_slug}-analytics.csv"}, + ) + + +@router.get("/api-usage") +async def get_api_key_usage( + days: int = Query(7, ge=1, le=30), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Get API key usage analytics (Pro+ feature). + + Returns per-key request counts and top endpoints. + """ + effective_tier = get_effective_tier(user) + if effective_tier == SubscriptionTier.FREE: + raise HTTPException(status_code=403, detail="API usage analytics require Pro or Premium subscription") + + # Get user's API keys + keys_result = await db.execute( + select(ApiKey).where(ApiKey.user_id == user.id, ApiKey.is_active == True) + ) + keys = keys_result.scalars().all() + + cutoff = date.today() - timedelta(days=days) + + # Get per-endpoint breakdown for user's requests + endpoint_stats = await db.execute( + select( + ApiRequestLog.endpoint, + ApiRequestLog.method, + func.count(ApiRequestLog.id).label("request_count"), + ).where( + ApiRequestLog.user_id == user.id, + ApiRequestLog.timestamp >= cutoff, + ).group_by(ApiRequestLog.endpoint, ApiRequestLog.method) + .order_by(func.count(ApiRequestLog.id).desc()) + .limit(20) + ) + + top_endpoints = [ + {"endpoint": row.endpoint, "method": row.method, "count": row.request_count} + for row in endpoint_stats.all() + ] + + # Daily request counts + daily_stats = await db.execute( + select( + func.date(ApiRequestLog.timestamp).label("date"), + func.count(ApiRequestLog.id).label("count"), + ).where( + ApiRequestLog.user_id == user.id, + ApiRequestLog.timestamp >= cutoff, + ).group_by(func.date(ApiRequestLog.timestamp)) + .order_by(func.date(ApiRequestLog.timestamp)) + ) + + daily = [{"date": str(row.date), "count": row.count} for row in daily_stats.all()] + + key_summaries = [ + { + "name": k.name, + "key_prefix": k.key_prefix, + "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None, + "usage_count": k.usage_count, + "is_active": k.is_active, + } + for k in keys + ] + + return { + "keys": key_summaries, + "top_endpoints": top_endpoints, + "daily_requests": daily, + "period_days": days, + } diff --git a/plexaddons-api/app/api/v1/auth.py b/plexaddons-api/app/api/v1/auth.py index 587902c..10febc7 100644 --- a/plexaddons-api/app/api/v1/auth.py +++ b/plexaddons-api/app/api/v1/auth.py @@ -76,8 +76,8 @@ async def discord_callback( # Create JWT jwt_token = AuthService.create_jwt_token(user) - # Redirect to frontend with token - frontend_callback = f"{settings.frontend_url}/auth/callback?token={jwt_token}" + # Redirect to frontend with token and state for client-side CSRF verification + frontend_callback = f"{settings.frontend_url}/auth/callback?token={jwt_token}&state={state}" return RedirectResponse(url=frontend_callback) diff --git a/plexaddons-api/app/api/v1/collaborators.py b/plexaddons-api/app/api/v1/collaborators.py new file mode 100644 index 0000000..0f20312 --- /dev/null +++ b/plexaddons-api/app/api/v1/collaborators.py @@ -0,0 +1,136 @@ +"""Collaborator management endpoints for addons (Pro+ feature).""" + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.models import User, CollaboratorRole +from app.schemas import ( + CollaboratorInvite, + CollaboratorUpdate, + CollaboratorResponse, + TransferOwnershipRequest, +) +from app.services import CollaboratorService +from app.api.deps import get_current_user, require_pro, rate_limit_check_authenticated, get_effective_tier +from app.core.exceptions import ForbiddenError +from app.models import SubscriptionTier + +router = APIRouter(prefix="/addons/{addon_id}/collaborators", tags=["Collaborators"]) + + +@router.get("", response_model=list[CollaboratorResponse]) +async def list_collaborators( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List all collaborators for an addon. Requires ownership or admin collaborator role.""" + can = await CollaboratorService.can_manage_addon(db, addon_id, user) + if not can: + raise ForbiddenError("You don't have access to this addon's collaborators") + return await CollaboratorService.list_collaborators(db, addon_id) + + +@router.post("", response_model=CollaboratorResponse) +async def invite_collaborator( + addon_id: int, + data: CollaboratorInvite, + user: User = Depends(require_pro), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Invite a user as a collaborator (Pro+ feature). Only addon owner can invite.""" + addon = await CollaboratorService.get_addon_with_owner_check(db, addon_id, user) + collab = await CollaboratorService.invite_collaborator( + db, addon.id, data.user_id, data.role, user + ) + # Return with user info + collaborators = await CollaboratorService.list_collaborators(db, addon_id) + return next(c for c in collaborators if c["id"] == collab.id) + + +@router.patch("/{collaborator_id}", response_model=CollaboratorResponse) +async def update_collaborator( + addon_id: int, + collaborator_id: int, + data: CollaboratorUpdate, + user: User = Depends(require_pro), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Update a collaborator's role. Only addon owner can update.""" + await CollaboratorService.get_addon_with_owner_check(db, addon_id, user) + await CollaboratorService.update_collaborator_role(db, collaborator_id, data.role) + collaborators = await CollaboratorService.list_collaborators(db, addon_id) + return next(c for c in collaborators if c["id"] == collaborator_id) + + +@router.delete("/{collaborator_id}", status_code=204) +async def remove_collaborator( + addon_id: int, + collaborator_id: int, + user: User = Depends(require_pro), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Remove a collaborator from an addon. Only addon owner can remove.""" + await CollaboratorService.get_addon_with_owner_check(db, addon_id, user) + await CollaboratorService.remove_collaborator(db, collaborator_id) + + +# Transfer ownership endpoint +transfer_router = APIRouter(prefix="/addons/{addon_id}/transfer", tags=["Addons"]) + + +@transfer_router.post("") +async def transfer_addon_ownership( + addon_id: int, + data: TransferOwnershipRequest, + user: User = Depends(require_pro), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Transfer addon ownership to another user (Pro+ feature).""" + addon = await CollaboratorService.transfer_ownership( + db, addon_id, data.new_owner_id, user + ) + return {"message": "Ownership transferred successfully", "new_owner_id": addon.owner_id} + + +# Invitation management (user-facing, not addon-specific) +invitation_router = APIRouter(prefix="/collaborations", tags=["Collaborators"]) + + +@invitation_router.get("/invitations") +async def get_my_invitations( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get pending collaboration invitations for the current user.""" + return await CollaboratorService.get_my_invitations(db, user) + + +@invitation_router.post("/invitations/{collaborator_id}/accept") +async def accept_invitation( + collaborator_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Accept a collaboration invitation.""" + collab = await CollaboratorService.accept_invitation(db, collaborator_id, user) + return {"message": "Invitation accepted", "addon_id": collab.addon_id} + + +@invitation_router.delete("/invitations/{collaborator_id}") +async def decline_invitation( + collaborator_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Decline a collaboration invitation.""" + await CollaboratorService.decline_invitation(db, collaborator_id, user) + return {"message": "Invitation declined"} diff --git a/plexaddons-api/app/api/v1/experiments.py b/plexaddons-api/app/api/v1/experiments.py new file mode 100644 index 0000000..b6cfdd4 --- /dev/null +++ b/plexaddons-api/app/api/v1/experiments.py @@ -0,0 +1,269 @@ +"""A/B Testing endpoints (PREM-2).""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from datetime import datetime, timezone + +from app.database import get_db +from app.api.deps import get_current_user, rate_limit_check_authenticated, get_effective_tier +from app.models import ( + User, Addon, ABExperiment, ABVariant, ExperimentStatus, SubscriptionTier, +) +from app.schemas import ( + ABExperimentCreate, ABExperimentUpdate, ABExperimentResponse, + ABExperimentListResponse, +) + +router = APIRouter( + prefix="/addons/{addon_id}/experiments", + tags=["A/B Testing"], +) + + +async def _verify_premium_owner(addon_id: int, user: User, db: AsyncSession) -> Addon: + """Verify user is Premium and owns the addon.""" + addon = (await db.execute(select(Addon).where(Addon.id == addon_id))).scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not the addon owner") + effective_tier = get_effective_tier(user) + if effective_tier != SubscriptionTier.PREMIUM and not user.is_admin: + raise HTTPException(status_code=403, detail="A/B testing requires Premium subscription") + return addon + + +@router.get("", response_model=ABExperimentListResponse) +async def list_experiments( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List all A/B experiments for an addon.""" + await _verify_premium_owner(addon_id, user, db) + + result = await db.execute( + select(ABExperiment) + .where(ABExperiment.addon_id == addon_id) + .order_by(ABExperiment.created_at.desc()) + ) + experiments = result.scalars().all() + + # Eagerly load variants + for exp in experiments: + await db.refresh(exp, ["variants"]) + + return ABExperimentListResponse( + experiments=[ABExperimentResponse.model_validate(e) for e in experiments], + total=len(experiments), + ) + + +@router.post("", response_model=ABExperimentResponse, status_code=status.HTTP_201_CREATED) +async def create_experiment( + addon_id: int, + body: ABExperimentCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Create a new A/B experiment.""" + await _verify_premium_owner(addon_id, user, db) + + # Validate variant percentages sum to 100 + total_pct = sum(v.percentage for v in body.variants) + if total_pct != 100: + raise HTTPException( + status_code=400, + detail=f"Variant percentages must sum to 100 (got {total_pct})", + ) + + # Ensure exactly one control + controls = [v for v in body.variants if v.is_control] + if len(controls) != 1: + raise HTTPException(status_code=400, detail="Exactly one variant must be marked as control") + + experiment = ABExperiment( + addon_id=addon_id, + created_by_id=user.id, + name=body.name, + description=body.description, + targeting_rules=body.targeting_rules, + auto_promote=body.auto_promote, + auto_promote_after_hours=body.auto_promote_after_hours, + ) + db.add(experiment) + await db.flush() + + for v in body.variants: + variant = ABVariant( + experiment_id=experiment.id, + version_id=v.version_id, + name=v.name, + percentage=v.percentage, + is_control=v.is_control, + ) + db.add(variant) + + await db.commit() + await db.refresh(experiment, ["variants"]) + return ABExperimentResponse.model_validate(experiment) + + +@router.get("/{experiment_id}", response_model=ABExperimentResponse) +async def get_experiment( + addon_id: int, + experiment_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get a specific experiment with its variants.""" + await _verify_premium_owner(addon_id, user, db) + + result = await db.execute( + select(ABExperiment).where( + ABExperiment.id == experiment_id, + ABExperiment.addon_id == addon_id, + ) + ) + experiment = result.scalar_one_or_none() + if not experiment: + raise HTTPException(status_code=404, detail="Experiment not found") + + await db.refresh(experiment, ["variants"]) + return ABExperimentResponse.model_validate(experiment) + + +@router.patch("/{experiment_id}", response_model=ABExperimentResponse) +async def update_experiment( + addon_id: int, + experiment_id: int, + body: ABExperimentUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Update an experiment (only allowed in DRAFT status).""" + await _verify_premium_owner(addon_id, user, db) + + result = await db.execute( + select(ABExperiment).where( + ABExperiment.id == experiment_id, + ABExperiment.addon_id == addon_id, + ) + ) + experiment = result.scalar_one_or_none() + if not experiment: + raise HTTPException(status_code=404, detail="Experiment not found") + + if experiment.status != ExperimentStatus.DRAFT: + raise HTTPException(status_code=400, detail="Can only edit experiments in DRAFT status") + + for key, value in body.model_dump(exclude_unset=True).items(): + setattr(experiment, key, value) + + await db.commit() + await db.refresh(experiment, ["variants"]) + return ABExperimentResponse.model_validate(experiment) + + +@router.post("/{experiment_id}/start", response_model=ABExperimentResponse) +async def start_experiment( + addon_id: int, + experiment_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Start a draft experiment.""" + await _verify_premium_owner(addon_id, user, db) + + result = await db.execute( + select(ABExperiment).where( + ABExperiment.id == experiment_id, + ABExperiment.addon_id == addon_id, + ) + ) + experiment = result.scalar_one_or_none() + if not experiment: + raise HTTPException(status_code=404, detail="Experiment not found") + + if experiment.status != ExperimentStatus.DRAFT: + raise HTTPException(status_code=400, detail="Experiment is not in DRAFT status") + + # Check no other running experiment for this addon + running = await db.execute( + select(func.count(ABExperiment.id)).where( + ABExperiment.addon_id == addon_id, + ABExperiment.status == ExperimentStatus.RUNNING, + ) + ) + if (running.scalar() or 0) > 0: + raise HTTPException(status_code=409, detail="Another experiment is already running for this addon") + + experiment.status = ExperimentStatus.RUNNING + experiment.started_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(experiment, ["variants"]) + return ABExperimentResponse.model_validate(experiment) + + +@router.post("/{experiment_id}/stop", response_model=ABExperimentResponse) +async def stop_experiment( + addon_id: int, + experiment_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Stop a running experiment.""" + await _verify_premium_owner(addon_id, user, db) + + result = await db.execute( + select(ABExperiment).where( + ABExperiment.id == experiment_id, + ABExperiment.addon_id == addon_id, + ) + ) + experiment = result.scalar_one_or_none() + if not experiment: + raise HTTPException(status_code=404, detail="Experiment not found") + + if experiment.status not in (ExperimentStatus.RUNNING, ExperimentStatus.PAUSED): + raise HTTPException(status_code=400, detail="Experiment is not running or paused") + + experiment.status = ExperimentStatus.COMPLETED + experiment.ended_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(experiment, ["variants"]) + return ABExperimentResponse.model_validate(experiment) + + +@router.delete("/{experiment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_experiment( + addon_id: int, + experiment_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Delete an experiment (only in DRAFT or COMPLETED status).""" + await _verify_premium_owner(addon_id, user, db) + + result = await db.execute( + select(ABExperiment).where( + ABExperiment.id == experiment_id, + ABExperiment.addon_id == addon_id, + ) + ) + experiment = result.scalar_one_or_none() + if not experiment: + raise HTTPException(status_code=404, detail="Experiment not found") + + if experiment.status in (ExperimentStatus.RUNNING, ExperimentStatus.PAUSED): + raise HTTPException(status_code=400, detail="Stop the experiment before deleting") + + await db.delete(experiment) + await db.commit() diff --git a/plexaddons-api/app/api/v1/feature_flags.py b/plexaddons-api/app/api/v1/feature_flags.py new file mode 100644 index 0000000..51e67cd --- /dev/null +++ b/plexaddons-api/app/api/v1/feature_flags.py @@ -0,0 +1,190 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.database import get_db +from app.api.deps import get_current_user +from app.models import User, Addon, FeatureFlag +from app.schemas import ( + FeatureFlagCreate, FeatureFlagUpdate, FeatureFlagResponse, + FeatureFlagListResponse, FeatureFlagEvaluateRequest, FeatureFlagEvaluateResponse, +) + +router = APIRouter(prefix="/addons/{addon_id}/flags", tags=["feature-flags"]) + + +async def _get_addon_or_404(db: AsyncSession, addon_id: int) -> Addon: + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + return addon + + +async def _get_flag_or_404(db: AsyncSession, flag_id: int, addon_id: int) -> FeatureFlag: + result = await db.execute( + select(FeatureFlag).where( + FeatureFlag.id == flag_id, + FeatureFlag.addon_id == addon_id, + ) + ) + flag = result.scalar_one_or_none() + if not flag: + raise HTTPException(status_code=404, detail="Feature flag not found") + return flag + + +@router.get("", response_model=FeatureFlagListResponse) +async def list_flags( + addon_id: int, + skip: int = 0, + limit: int = 50, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + total_result = await db.execute( + select(func.count(FeatureFlag.id)).where(FeatureFlag.addon_id == addon_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(FeatureFlag) + .where(FeatureFlag.addon_id == addon_id) + .order_by(FeatureFlag.created_at.desc()) + .offset(skip) + .limit(limit) + ) + flags = list(result.scalars().all()) + return FeatureFlagListResponse(flags=flags, total=total) + + +@router.post("", response_model=FeatureFlagResponse, status_code=201) +async def create_flag( + addon_id: int, + data: FeatureFlagCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + # Check for duplicate key + existing = await db.execute( + select(FeatureFlag).where( + FeatureFlag.addon_id == addon_id, + FeatureFlag.key == data.key, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Flag key already exists for this addon") + + flag = FeatureFlag( + addon_id=addon_id, + created_by_id=user.id, + key=data.key, + name=data.name, + description=data.description, + enabled=data.enabled, + percentage=data.percentage, + targeting=data.targeting, + ) + db.add(flag) + await db.commit() + await db.refresh(flag) + return flag + + +@router.get("/{flag_id}", response_model=FeatureFlagResponse) +async def get_flag( + addon_id: int, + flag_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + return await _get_flag_or_404(db, flag_id, addon_id) + + +@router.patch("/{flag_id}", response_model=FeatureFlagResponse) +async def update_flag( + addon_id: int, + flag_id: int, + data: FeatureFlagUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + flag = await _get_flag_or_404(db, flag_id, addon_id) + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(flag, key, value) + + await db.commit() + await db.refresh(flag) + return flag + + +@router.delete("/{flag_id}", status_code=204) +async def delete_flag( + addon_id: int, + flag_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + flag = await _get_flag_or_404(db, flag_id, addon_id) + await db.delete(flag) + await db.commit() + + +@router.post("/evaluate", response_model=list[FeatureFlagEvaluateResponse]) +async def evaluate_flags( + addon_id: int, + data: FeatureFlagEvaluateRequest, + db: AsyncSession = Depends(get_db), +): + """Public endpoint for pavc to evaluate all flags for an addon.""" + await _get_addon_or_404(db, addon_id) + + result = await db.execute( + select(FeatureFlag).where(FeatureFlag.addon_id == addon_id) + ) + flags = list(result.scalars().all()) + + evaluated = [] + for flag in flags: + enabled = flag.enabled + if enabled and flag.targeting: + # Check server targeting + if data.server_id and flag.targeting.get("server_ids"): + if data.server_id not in flag.targeting["server_ids"]: + enabled = False + # Check user targeting + if data.user_id and flag.targeting.get("user_ids"): + if data.user_id not in flag.targeting["user_ids"]: + enabled = False + + if enabled and flag.percentage < 100: + # Deterministic hash-based percentage check + import hashlib + hash_input = f"{flag.key}:{data.server_id or ''}:{data.user_id or ''}" + hash_val = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16) % 100 + enabled = hash_val < flag.percentage + + evaluated.append(FeatureFlagEvaluateResponse(key=flag.key, enabled=enabled)) + + return evaluated diff --git a/plexaddons-api/app/api/v1/marketplace.py b/plexaddons-api/app/api/v1/marketplace.py new file mode 100644 index 0000000..a04b374 --- /dev/null +++ b/plexaddons-api/app/api/v1/marketplace.py @@ -0,0 +1,526 @@ +"""Marketplace and Sponsorship endpoints (PREM-13, PREM-14).""" + +import secrets +import stripe +import logging +from datetime import datetime, timezone +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.config import get_settings +from app.database import get_db +from app.models import ( + User, Addon, SubscriptionTier, AddonLicense, LicenseStatus, +) +from app.schemas import ( + AddonPricingUpdate, + LicenseResponse, LicenseListResponse, + LicenseVerifyRequest, LicenseVerifyResponse, + PurchaseAddonRequest, PurchaseAddonResponse, + StripeConnectOnboardRequest, StripeConnectStatusResponse, + RevenueStatsResponse, + SponsorUrlUpdate, +) +from app.api.deps import get_current_user, rate_limit_check_authenticated, get_effective_tier +from app.core.cache import cache + +settings = get_settings() +logger = logging.getLogger(__name__) + + +# ============== MARKETPLACE (PREM-13) ============== + +marketplace_router = APIRouter(prefix="/marketplace", tags=["Marketplace"]) + + +@marketplace_router.put("/addons/{addon_id}/pricing") +async def update_addon_pricing( + addon_id: int, + body: AddonPricingUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Set up or update pricing for a paid addon (Premium only). + + Requires Stripe Connect account to be set up first. + """ + effective_tier = get_effective_tier(user) + if effective_tier != SubscriptionTier.PREMIUM and not user.is_admin: + raise HTTPException(status_code=403, detail="Paid addons require Premium subscription") + + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not your addon") + + if body.is_paid and not user.stripe_connect_account_id: + raise HTTPException( + status_code=400, + detail="Set up Stripe Connect first to enable paid addons", + ) + + if body.is_paid and (body.price_cents is None or body.price_cents < 100): + raise HTTPException( + status_code=400, + detail="Price must be at least $1.00 for paid addons", + ) + + addon.is_paid = body.is_paid + addon.price_cents = body.price_cents if body.is_paid else None + addon.revenue_split_percent = body.revenue_split_percent + + await db.commit() + await db.refresh(addon) + + await cache.invalidate_addon(addon_id=addon.id, addon_slug=addon.slug) + + return { + "addon_id": addon.id, + "is_paid": addon.is_paid, + "price_cents": addon.price_cents, + "revenue_split_percent": addon.revenue_split_percent, + } + + +@marketplace_router.post("/addons/{addon_id}/purchase", response_model=PurchaseAddonResponse) +async def purchase_addon( + addon_id: int, + body: PurchaseAddonRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Purchase a paid addon via Stripe Checkout. + + Creates a Stripe Checkout Session with Stripe Connect split payments. + The seller receives their revenue share directly via Stripe Connect, + and the platform retains the platform fee. + """ + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if not addon.is_paid or not addon.price_cents: + raise HTTPException(status_code=400, detail="This addon is not a paid addon") + if addon.owner_id == user.id: + raise HTTPException(status_code=400, detail="Cannot purchase your own addon") + + # Check if user already has an active license + existing = await db.execute( + select(AddonLicense).where( + AddonLicense.addon_id == addon_id, + AddonLicense.buyer_id == user.id, + AddonLicense.status == LicenseStatus.ACTIVE, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="You already have an active license for this addon") + + # Retrieve the addon owner to get their Connect account + owner_result = await db.execute(select(User).where(User.id == addon.owner_id)) + owner = owner_result.scalar_one_or_none() + if not owner or not owner.stripe_connect_account_id: + raise HTTPException( + status_code=400, + detail="The addon developer has not set up payments yet", + ) + + # Calculate the platform fee (inverse of developer share) + split = min(addon.revenue_split_percent or 90, 90) + developer_share = int(addon.price_cents * split / 100) + platform_fee = addon.price_cents - developer_share + + # Get or create Stripe customer for the buyer + from app.services.stripe_service import StripeService + customer_id = await StripeService._get_or_create_customer(user) + + success_url = f"{settings.frontend_url}/addons/{addon.slug}?purchase=success" + cancel_url = f"{settings.frontend_url}/addons/{addon.slug}?purchase=cancelled" + + try: + session = stripe.checkout.Session.create( + customer=customer_id, + mode="payment", + line_items=[{ + "price_data": { + "currency": "usd", + "product_data": { + "name": addon.name, + "description": f"License for {addon.name}", + }, + "unit_amount": addon.price_cents, + }, + "quantity": 1, + }], + payment_intent_data={ + "application_fee_amount": platform_fee, + "transfer_data": { + "destination": owner.stripe_connect_account_id, + }, + }, + metadata={ + "type": "addon_purchase", + "addon_id": str(addon.id), + "buyer_id": str(user.id), + "server_id": body.server_id or "", + "price_cents": str(addon.price_cents), + "developer_amount_cents": str(developer_share), + "platform_amount_cents": str(platform_fee), + }, + success_url=success_url, + cancel_url=cancel_url, + ) + + return PurchaseAddonResponse( + checkout_url=session.url, + session_id=session.id, + ) + except stripe.error.StripeError as e: + logger.error(f"Stripe Checkout session creation failed for addon {addon_id}: {e}") + raise HTTPException(status_code=502, detail="Failed to create payment session. Please try again.") + + +@marketplace_router.get("/addons/{addon_id}/licenses", response_model=LicenseListResponse) +async def list_addon_licenses( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List all licenses for an addon (owner only).""" + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not your addon") + + licenses_result = await db.execute( + select(AddonLicense) + .where(AddonLicense.addon_id == addon_id) + .order_by(AddonLicense.created_at.desc()) + ) + licenses = licenses_result.scalars().all() + + return LicenseListResponse(licenses=licenses, total=len(licenses)) + + +@marketplace_router.get("/my-licenses", response_model=LicenseListResponse) +async def list_my_licenses( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List all licenses the current user has purchased.""" + result = await db.execute( + select(AddonLicense) + .where(AddonLicense.buyer_id == user.id) + .order_by(AddonLicense.created_at.desc()) + ) + licenses = result.scalars().all() + + return LicenseListResponse(licenses=licenses, total=len(licenses)) + + +@marketplace_router.post("/licenses/{license_id}/revoke", response_model=LicenseResponse) +async def revoke_license( + license_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Revoke a license (addon owner or admin only).""" + result = await db.execute( + select(AddonLicense).where(AddonLicense.id == license_id) + ) + license = result.scalar_one_or_none() + if not license: + raise HTTPException(status_code=404, detail="License not found") + + # Check ownership of the addon + addon_result = await db.execute(select(Addon).where(Addon.id == license.addon_id)) + addon = addon_result.scalar_one_or_none() + if not addon or (addon.owner_id != user.id and not user.is_admin): + raise HTTPException(status_code=403, detail="Not authorized to revoke this license") + + license.status = LicenseStatus.REVOKED + license.revoked_at = datetime.now(timezone.utc) + + await db.commit() + await db.refresh(license) + return license + + +@marketplace_router.post("/verify-license", response_model=LicenseVerifyResponse) +async def verify_license( + body: LicenseVerifyRequest, + db: AsyncSession = Depends(get_db), +): + """ + Public endpoint to verify a license key. + + Used by pavc (version checker) to validate addon licenses. + No authentication required — only the license key is needed. + """ + result = await db.execute( + select(AddonLicense).where(AddonLicense.license_key == body.license_key) + ) + license = result.scalar_one_or_none() + + if not license: + return LicenseVerifyResponse(valid=False) + + # Check if expired + if license.expires_at and license.expires_at < datetime.now(timezone.utc): + return LicenseVerifyResponse( + valid=False, + addon_id=license.addon_id, + status=LicenseStatus.EXPIRED, + expires_at=license.expires_at, + ) + + # Check if revoked/suspended + if license.status != LicenseStatus.ACTIVE: + return LicenseVerifyResponse( + valid=False, + addon_id=license.addon_id, + status=license.status, + ) + + # If server_id was provided, check it matches + if body.server_id and license.server_id and body.server_id != license.server_id: + return LicenseVerifyResponse( + valid=False, + addon_id=license.addon_id, + status=license.status, + ) + + # Get addon slug + addon_result = await db.execute(select(Addon.slug).where(Addon.id == license.addon_id)) + addon_slug = addon_result.scalar_one_or_none() + + return LicenseVerifyResponse( + valid=True, + addon_id=license.addon_id, + addon_slug=addon_slug, + status=license.status, + expires_at=license.expires_at, + ) + + +@marketplace_router.get("/addons/{addon_id}/revenue", response_model=RevenueStatsResponse) +async def get_revenue_stats( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get revenue statistics for a paid addon (owner only).""" + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not your addon") + + stats = await db.execute( + select( + func.count(AddonLicense.id).label("total_sales"), + func.coalesce(func.sum(AddonLicense.amount_cents), 0).label("total_revenue"), + func.coalesce(func.sum(AddonLicense.developer_amount_cents), 0).label("dev_earnings"), + func.coalesce(func.sum(AddonLicense.platform_amount_cents), 0).label("platform_fees"), + ).where( + AddonLicense.addon_id == addon_id, + ) + ) + row = stats.one() + + active_count = await db.execute( + select(func.count(AddonLicense.id)).where( + AddonLicense.addon_id == addon_id, + AddonLicense.status == LicenseStatus.ACTIVE, + ) + ) + + return RevenueStatsResponse( + total_sales=row.total_sales, + total_revenue_cents=row.total_revenue, + developer_earnings_cents=row.dev_earnings, + platform_fees_cents=row.platform_fees, + active_licenses=active_count.scalar() or 0, + ) + + +# ============== STRIPE CONNECT (PREM-13) ============== + +connect_router = APIRouter(prefix="/stripe-connect", tags=["Stripe Connect"]) + + +@connect_router.get("/status", response_model=StripeConnectStatusResponse) +async def get_connect_status( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get the current user's Stripe Connect account status.""" + effective_tier = get_effective_tier(user) + if effective_tier != SubscriptionTier.PREMIUM and not user.is_admin: + raise HTTPException(status_code=403, detail="Stripe Connect requires Premium subscription") + + if not user.stripe_connect_account_id: + return StripeConnectStatusResponse(has_connect_account=False) + + try: + account = stripe.Account.retrieve(user.stripe_connect_account_id) + return StripeConnectStatusResponse( + has_connect_account=True, + account_id=user.stripe_connect_account_id, + payouts_enabled=account.payouts_enabled or False, + onboarding_complete=account.details_submitted or False, + ) + except stripe.error.StripeError as e: + logger.error(f"Failed to retrieve Stripe Connect account: {e}") + return StripeConnectStatusResponse( + has_connect_account=True, + account_id=user.stripe_connect_account_id, + payouts_enabled=False, + onboarding_complete=False, + ) + + +@connect_router.post("/onboard") +async def start_connect_onboarding( + body: StripeConnectOnboardRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Start Stripe Connect onboarding. + + Creates a Stripe Connect Express account and returns an Account Link URL + for the user to complete their onboarding. + """ + effective_tier = get_effective_tier(user) + if effective_tier != SubscriptionTier.PREMIUM and not user.is_admin: + raise HTTPException(status_code=403, detail="Stripe Connect requires Premium subscription") + + try: + if user.stripe_connect_account_id: + # Account already exists — check if onboarding is complete + account = stripe.Account.retrieve(user.stripe_connect_account_id) + if account.details_submitted: + return { + "message": "Stripe Connect account already set up", + "account_id": user.stripe_connect_account_id, + } + # Onboarding incomplete — generate a new Account Link + account_link = stripe.AccountLink.create( + account=user.stripe_connect_account_id, + refresh_url=body.refresh_url, + return_url=body.return_url, + type="account_onboarding", + ) + return { + "message": "Continue Stripe Connect onboarding", + "account_id": user.stripe_connect_account_id, + "onboarding_url": account_link.url, + } + + # Create a new Stripe Connect Express account + account = stripe.Account.create( + type="express", + email=user.email, + metadata={ + "user_id": str(user.id), + "discord_id": user.discord_id, + }, + capabilities={ + "card_payments": {"requested": True}, + "transfers": {"requested": True}, + }, + ) + + user.stripe_connect_account_id = account.id + await db.commit() + + # Generate an Account Link for onboarding + account_link = stripe.AccountLink.create( + account=account.id, + refresh_url=body.refresh_url, + return_url=body.return_url, + type="account_onboarding", + ) + + return { + "message": "Stripe Connect onboarding initiated", + "account_id": account.id, + "onboarding_url": account_link.url, + } + except stripe.error.StripeError as e: + logger.error(f"Stripe Connect onboarding failed: {e}") + raise HTTPException(status_code=502, detail="Failed to set up Stripe Connect. Please try again.") + + +# ============== SPONSORSHIP (PREM-14) ============== + +sponsorship_router = APIRouter(prefix="/addons/{addon_id}/sponsorship", tags=["Sponsorship"]) + + +@sponsorship_router.get("") +async def get_sponsor_url( + addon_id: int, + db: AsyncSession = Depends(get_db), +): + """Get the sponsor URL for an addon (public endpoint).""" + result = await db.execute( + select(Addon.sponsor_url, Addon.name, Addon.slug).where(Addon.id == addon_id) + ) + row = result.one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Addon not found") + + return { + "addon_id": addon_id, + "addon_name": row.name, + "addon_slug": row.slug, + "sponsor_url": row.sponsor_url, + } + + +@sponsorship_router.put("") +async def update_sponsor_url( + addon_id: int, + body: SponsorUrlUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Update the sponsor/donation URL for an addon (Premium only).""" + effective_tier = get_effective_tier(user) + if effective_tier != SubscriptionTier.PREMIUM and not user.is_admin: + raise HTTPException(status_code=403, detail="Sponsorship features require Premium subscription") + + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not your addon") + + addon.sponsor_url = body.sponsor_url + await db.commit() + + await cache.invalidate_addon(addon_id=addon.id, addon_slug=addon.slug) + + return { + "addon_id": addon.id, + "sponsor_url": addon.sponsor_url, + } diff --git a/plexaddons-api/app/api/v1/notifications.py b/plexaddons-api/app/api/v1/notifications.py new file mode 100644 index 0000000..c825248 --- /dev/null +++ b/plexaddons-api/app/api/v1/notifications.py @@ -0,0 +1,129 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update +from app.database import get_db +from app.models import User, Notification +from app.schemas import NotificationResponse, NotificationListResponse +from app.api.deps import get_current_user, rate_limit_check_authenticated +from app.core.exceptions import NotFoundError + +router = APIRouter(prefix="/notifications", tags=["Notifications"]) + + +@router.get("", response_model=NotificationListResponse) +async def list_notifications( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=50), + unread_only: bool = False, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List notifications for the current user.""" + skip = (page - 1) * per_page + + base_filter = [Notification.user_id == user.id] + if unread_only: + base_filter.append(Notification.is_read == False) + + # Total count + count_result = await db.execute( + select(func.count(Notification.id)).where(*base_filter) + ) + total = count_result.scalar() or 0 + + # Unread count (always) + unread_result = await db.execute( + select(func.count(Notification.id)) + .where(Notification.user_id == user.id, Notification.is_read == False) + ) + unread_count = unread_result.scalar() or 0 + + # Get notifications + result = await db.execute( + select(Notification) + .where(*base_filter) + .order_by(Notification.created_at.desc()) + .offset(skip) + .limit(per_page) + ) + notifications = result.scalars().all() + + return NotificationListResponse( + notifications=[NotificationResponse.model_validate(n) for n in notifications], + total=total, + unread_count=unread_count, + ) + + +@router.get("/unread-count") +async def get_unread_count( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get the number of unread notifications.""" + result = await db.execute( + select(func.count(Notification.id)) + .where(Notification.user_id == user.id, Notification.is_read == False) + ) + count = result.scalar() or 0 + return {"unread_count": count} + + +@router.post("/{notification_id}/read") +async def mark_as_read( + notification_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Mark a single notification as read.""" + result = await db.execute( + select(Notification) + .where(Notification.id == notification_id, Notification.user_id == user.id) + ) + notification = result.scalar_one_or_none() + if not notification: + raise NotFoundError("Notification not found") + + notification.is_read = True + await db.commit() + return {"status": "read"} + + +@router.post("/read-all") +async def mark_all_as_read( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Mark all notifications as read.""" + await db.execute( + update(Notification) + .where(Notification.user_id == user.id, Notification.is_read == False) + .values(is_read=True) + ) + await db.commit() + return {"status": "all_read"} + + +@router.delete("/{notification_id}") +async def delete_notification( + notification_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Delete a notification.""" + result = await db.execute( + select(Notification) + .where(Notification.id == notification_id, Notification.user_id == user.id) + ) + notification = result.scalar_one_or_none() + if not notification: + raise NotFoundError("Notification not found") + + await db.delete(notification) + await db.commit() + return {"status": "deleted"} diff --git a/plexaddons-api/app/api/v1/organizations.py b/plexaddons-api/app/api/v1/organizations.py index 5ca71e8..adc56ac 100644 --- a/plexaddons-api/app/api/v1/organizations.py +++ b/plexaddons-api/app/api/v1/organizations.py @@ -1,18 +1,24 @@ """Organization endpoints for team addon management (Premium feature).""" -from fastapi import APIRouter, Depends, HTTPException, status +import hashlib +import secrets +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func +from sqlalchemy import select, func, delete from typing import List, Optional from app.database import get_db from app.models import ( Organization, OrganizationMember, User, Addon, Version, - OrganizationRole, SubscriptionTier + OrganizationRole, SubscriptionTier, OrgAuditLog, OrgApiKey, + AddonUsageStats, ) from app.schemas import ( OrganizationCreate, OrganizationUpdate, OrganizationResponse, OrganizationDetailResponse, OrganizationListResponse, - OrganizationMemberResponse, InviteMemberRequest, UpdateMemberRoleRequest + OrganizationMemberResponse, InviteMemberRequest, UpdateMemberRoleRequest, + UpdateMemberPermissionsRequest, OrgAuditLogResponse, OrgAuditLogListResponse, + OrgApiKeyCreate, OrgApiKeyResponse, OrgApiKeyCreateResponse, OrgApiKeyListResponse, + OrgAnalyticsSummary, OrgPublicPageResponse, AddonResponse, ) from app.api.deps import get_current_user, require_premium from app.utils import slugify @@ -57,9 +63,25 @@ async def check_org_storage_quota(db: AsyncSession, org: Organization, owner: Us return (current_usage + additional_bytes) <= quota +async def log_org_audit( + db: AsyncSession, org_id: int, user_id: int, action: str, + details: Optional[dict] = None, ip_address: Optional[str] = None, +): + """Record an audit log entry for an organization action.""" + entry = OrgAuditLog( + organization_id=org_id, + user_id=user_id, + action=action, + details=details, + ip_address=ip_address, + ) + db.add(entry) + + @router.post("", response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED) async def create_organization( data: OrganizationCreate, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_premium), ): @@ -107,6 +129,10 @@ async def create_organization( role=OrganizationRole.OWNER, ) db.add(owner_member) + await db.flush() + + await log_org_audit(db, org.id, current_user.id, "org.created", + {"name": org.name}, request.client.host if request.client else None) await db.commit() await db.refresh(org) @@ -116,6 +142,7 @@ async def create_organization( slug=org.slug, description=org.description, avatar_url=org.avatar_url, + banner_url=org.banner_url, owner_id=org.owner_id, created_at=org.created_at, updated_at=org.updated_at, @@ -159,6 +186,7 @@ async def list_my_organizations( slug=org.slug, description=org.description, avatar_url=org.avatar_url, + banner_url=org.banner_url, owner_id=org.owner_id, created_at=org.created_at, updated_at=org.updated_at, @@ -208,6 +236,7 @@ async def get_organization( id=member.id, user_id=member.user_id, role=member.role, + permissions=member.permissions, joined_at=member.joined_at, discord_username=user.discord_username, discord_avatar=user.discord_avatar, @@ -229,6 +258,7 @@ async def get_organization( slug=org.slug, description=org.description, avatar_url=org.avatar_url, + banner_url=org.banner_url, owner_id=org.owner_id, created_at=org.created_at, updated_at=org.updated_at, @@ -244,6 +274,7 @@ async def get_organization( async def update_organization( org_slug: str, data: OrganizationUpdate, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -269,13 +300,20 @@ async def update_organization( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to update this organization") # Update fields + changes = {} if data.name is not None: + changes["name"] = data.name org.name = data.name if data.description is not None: + changes["description"] = data.description org.description = data.description if data.avatar_url is not None: org.avatar_url = data.avatar_url + if data.banner_url is not None: + org.banner_url = data.banner_url + await log_org_audit(db, org.id, current_user.id, "settings.updated", + changes or None, request.client.host if request.client else None) await db.commit() await db.refresh(org) @@ -294,6 +332,7 @@ async def update_organization( slug=org.slug, description=org.description, avatar_url=org.avatar_url, + banner_url=org.banner_url, owner_id=org.owner_id, created_at=org.created_at, updated_at=org.updated_at, @@ -306,6 +345,7 @@ async def update_organization( @router.delete("/{org_slug}", status_code=status.HTTP_204_NO_CONTENT) async def delete_organization( org_slug: str, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -331,6 +371,10 @@ async def delete_organization( addon.organization_id = None addon.owner_id = current_user.id + # Audit log written before deletion (cascades will remove it, but it's good practice) + await log_org_audit(db, org.id, current_user.id, "org.deleted", + {"name": org.name}, request.client.host if request.client else None) + # Delete organization (cascades to members) await db.delete(org) await db.commit() @@ -340,6 +384,7 @@ async def delete_organization( async def invite_member( org_slug: str, data: InviteMemberRequest, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -398,6 +443,11 @@ async def invite_member( invited_by_id=current_user.id, ) db.add(member) + await db.flush() + + await log_org_audit(db, org.id, current_user.id, "member.invited", + {"user": user.discord_username, "role": data.role.value}, + request.client.host if request.client else None) await db.commit() await db.refresh(member) @@ -405,6 +455,7 @@ async def invite_member( id=member.id, user_id=member.user_id, role=member.role, + permissions=member.permissions, joined_at=member.joined_at, discord_username=user.discord_username, discord_avatar=user.discord_avatar, @@ -416,6 +467,7 @@ async def update_member_role( org_slug: str, user_id: int, data: UpdateMemberRoleRequest, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -455,7 +507,11 @@ async def update_member_role( if member.role == OrganizationRole.OWNER: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change owner's role") + old_role = member.role.value member.role = data.role + await log_org_audit(db, org.id, current_user.id, "member.role_updated", + {"user_id": user_id, "old_role": old_role, "new_role": data.role.value}, + request.client.host if request.client else None) await db.commit() await db.refresh(member) @@ -463,6 +519,7 @@ async def update_member_role( id=member.id, user_id=member.user_id, role=member.role, + permissions=member.permissions, joined_at=member.joined_at, discord_username=user.discord_username, discord_avatar=user.discord_avatar, @@ -473,6 +530,7 @@ async def update_member_role( async def remove_member( org_slug: str, user_id: int, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -515,5 +573,424 @@ async def remove_member( if member.role == OrganizationRole.OWNER: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove the owner") + action = "member.left" if is_self_removal else "member.removed" + await log_org_audit(db, org.id, current_user.id, action, + {"user_id": user_id}, + request.client.host if request.client else None) await db.delete(member) await db.commit() + + +# ===================================================================== +# Member Permissions +# ===================================================================== + +@router.put("/{org_slug}/members/{user_id}/permissions", response_model=OrganizationMemberResponse) +async def update_member_permissions( + org_slug: str, + user_id: int, + data: UpdateMemberPermissionsRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update granular permissions for a member. Owner or Admin only.""" + result = await db.execute(select(Organization).where(Organization.slug == org_slug)) + org = result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found") + + # Check permission (owner or admin) + caller = await db.execute( + select(OrganizationMember) + .where(OrganizationMember.organization_id == org.id, + OrganizationMember.user_id == current_user.id, + OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN])) + ) + if not caller.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + row = await db.execute( + select(OrganizationMember, User) + .join(User, OrganizationMember.user_id == User.id) + .where(OrganizationMember.organization_id == org.id, + OrganizationMember.user_id == user_id) + ) + pair = row.first() + if not pair: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found") + member, user = pair + + if member.role == OrganizationRole.OWNER: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify owner permissions") + + member.permissions = data.permissions + await log_org_audit(db, org.id, current_user.id, "member.permissions_updated", + {"user_id": user_id, "permissions": data.permissions}, + request.client.host if request.client else None) + await db.commit() + await db.refresh(member) + + return OrganizationMemberResponse( + id=member.id, user_id=member.user_id, role=member.role, + permissions=member.permissions, joined_at=member.joined_at, + discord_username=user.discord_username, discord_avatar=user.discord_avatar, + ) + + +# ===================================================================== +# Audit Logs +# ===================================================================== + +@router.get("/{org_slug}/audit-logs", response_model=OrgAuditLogListResponse) +async def list_audit_logs( + org_slug: str, + page: int = 1, + per_page: int = 50, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List audit logs for the organization. Owner or Admin only.""" + result = await db.execute(select(Organization).where(Organization.slug == org_slug)) + org = result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found") + + caller = await db.execute( + select(OrganizationMember) + .where(OrganizationMember.organization_id == org.id, + OrganizationMember.user_id == current_user.id, + OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN])) + ) + if not caller.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + total_q = await db.execute( + select(func.count(OrgAuditLog.id)).where(OrgAuditLog.organization_id == org.id) + ) + total = total_q.scalar() or 0 + + offset = (max(page, 1) - 1) * per_page + rows = await db.execute( + select(OrgAuditLog, User) + .outerjoin(User, OrgAuditLog.user_id == User.id) + .where(OrgAuditLog.organization_id == org.id) + .order_by(OrgAuditLog.created_at.desc()) + .offset(offset) + .limit(min(per_page, 100)) + ) + + logs = [] + for log, user in rows.all(): + logs.append(OrgAuditLogResponse( + id=log.id, action=log.action, details=log.details, + user_id=log.user_id, + username=user.discord_username if user else None, + ip_address=log.ip_address, created_at=log.created_at, + )) + + return OrgAuditLogListResponse(logs=logs, total=total) + + +# ===================================================================== +# Organization API Keys +# ===================================================================== + +@router.post("/{org_slug}/api-keys", response_model=OrgApiKeyCreateResponse, status_code=status.HTTP_201_CREATED) +async def create_org_api_key( + org_slug: str, + data: OrgApiKeyCreate, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new organization API key. Owner or Admin only.""" + result = await db.execute(select(Organization).where(Organization.slug == org_slug)) + org = result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found") + + caller = await db.execute( + select(OrganizationMember) + .where(OrganizationMember.organization_id == org.id, + OrganizationMember.user_id == current_user.id, + OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN])) + ) + if not caller.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + # Limit 10 keys per org + count_q = await db.execute( + select(func.count(OrgApiKey.id)).where(OrgApiKey.organization_id == org.id) + ) + if (count_q.scalar() or 0) >= 10: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Maximum 10 API keys per organization") + + raw_key = secrets.token_urlsafe(32) + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + prefix = raw_key[:8] + + api_key = OrgApiKey( + organization_id=org.id, + created_by_id=current_user.id, + name=data.name, + key_hash=key_hash, + key_prefix=prefix, + scopes=data.scopes, + expires_at=data.expires_at, + ) + db.add(api_key) + await db.flush() + + await log_org_audit(db, org.id, current_user.id, "api_key.created", + {"name": data.name, "key_prefix": prefix}, + request.client.host if request.client else None) + await db.commit() + await db.refresh(api_key) + + return OrgApiKeyCreateResponse( + id=api_key.id, name=api_key.name, key_prefix=api_key.key_prefix, + scopes=api_key.scopes or [], is_active=api_key.is_active, + last_used_at=api_key.last_used_at, expires_at=api_key.expires_at, + created_by_id=api_key.created_by_id, created_at=api_key.created_at, + key=raw_key, + ) + + +@router.get("/{org_slug}/api-keys", response_model=OrgApiKeyListResponse) +async def list_org_api_keys( + org_slug: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List organization API keys. Owner or Admin only.""" + result = await db.execute(select(Organization).where(Organization.slug == org_slug)) + org = result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found") + + caller = await db.execute( + select(OrganizationMember) + .where(OrganizationMember.organization_id == org.id, + OrganizationMember.user_id == current_user.id, + OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN])) + ) + if not caller.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + rows = await db.execute( + select(OrgApiKey, User) + .outerjoin(User, OrgApiKey.created_by_id == User.id) + .where(OrgApiKey.organization_id == org.id) + .order_by(OrgApiKey.created_at.desc()) + ) + + keys = [] + for key, creator in rows.all(): + keys.append(OrgApiKeyResponse( + id=key.id, name=key.name, key_prefix=key.key_prefix, + scopes=key.scopes or [], is_active=key.is_active, + last_used_at=key.last_used_at, expires_at=key.expires_at, + created_by_id=key.created_by_id, + created_by_username=creator.discord_username if creator else None, + created_at=key.created_at, + )) + + return OrgApiKeyListResponse(api_keys=keys, total=len(keys)) + + +@router.delete("/{org_slug}/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_org_api_key( + org_slug: str, + key_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete an organization API key. Owner or Admin only.""" + result = await db.execute(select(Organization).where(Organization.slug == org_slug)) + org = result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found") + + caller = await db.execute( + select(OrganizationMember) + .where(OrganizationMember.organization_id == org.id, + OrganizationMember.user_id == current_user.id, + OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN])) + ) + if not caller.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + key_result = await db.execute( + select(OrgApiKey).where(OrgApiKey.id == key_id, OrgApiKey.organization_id == org.id) + ) + key = key_result.scalar_one_or_none() + if not key: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found") + + await log_org_audit(db, org.id, current_user.id, "api_key.deleted", + {"name": key.name, "key_prefix": key.key_prefix}, + request.client.host if request.client else None) + await db.delete(key) + await db.commit() + + +# ===================================================================== +# Organization Analytics +# ===================================================================== + +@router.get("/{org_slug}/analytics", response_model=OrgAnalyticsSummary) +async def get_org_analytics( + org_slug: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get aggregated analytics for all addons in the organization.""" + result = await db.execute(select(Organization).where(Organization.slug == org_slug)) + org = result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found") + + # Check membership with view_analytics permission + member_result = await db.execute( + select(OrganizationMember) + .where(OrganizationMember.organization_id == org.id, + OrganizationMember.user_id == current_user.id) + ) + membership = member_result.scalar_one_or_none() + if not membership: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member") + + # Non-owner/admin need view_analytics permission + if membership.role not in (OrganizationRole.OWNER, OrganizationRole.ADMIN): + perms = membership.permissions or {} + if not perms.get("view_analytics"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing view_analytics permission") + + # Get org addon IDs + addon_ids_q = await db.execute( + select(Addon.id).where(Addon.organization_id == org.id) + ) + addon_ids = [row[0] for row in addon_ids_q.all()] + + member_count_q = await db.execute( + select(func.count(OrganizationMember.id)).where(OrganizationMember.organization_id == org.id) + ) + member_count = member_count_q.scalar() or 0 + storage_used = await calculate_org_storage(db, org.id) + + if not addon_ids: + return OrgAnalyticsSummary( + addon_count=0, member_count=member_count, storage_used_bytes=storage_used, + ) + + # Aggregated stats from AddonUsageStats + stats_q = await db.execute( + select( + func.sum(AddonUsageStats.check_count), + func.sum(AddonUsageStats.unique_users), + ).where(AddonUsageStats.addon_id.in_(addon_ids)) + ) + row = stats_q.first() + total_checks = row[0] or 0 if row else 0 + total_unique = row[1] or 0 if row else 0 + + # Total downloads from usage stats + dl_q = await db.execute( + select(func.sum(AddonUsageStats.check_count)) + .where(AddonUsageStats.addon_id.in_(addon_ids)) + ) + total_downloads = dl_q.scalar() or 0 + + # Top addons by total checks + from sqlalchemy import desc as sa_desc + top_q = await db.execute( + select( + Addon.id, Addon.name, Addon.slug, + func.coalesce(func.sum(AddonUsageStats.check_count), 0).label("downloads"), + ) + .outerjoin(AddonUsageStats, AddonUsageStats.addon_id == Addon.id) + .where(Addon.id.in_(addon_ids)) + .group_by(Addon.id, Addon.name, Addon.slug) + .order_by(sa_desc("downloads")) + .limit(10) + ) + top_addons = [ + {"id": r[0], "name": r[1], "slug": r[2], "downloads": r[3]} + for r in top_q.all() + ] + + return OrgAnalyticsSummary( + total_downloads=total_downloads, + total_version_checks=total_checks, + total_unique_users=total_unique, + addon_count=len(addon_ids), + member_count=member_count, + storage_used_bytes=storage_used, + top_addons=top_addons, + ) + + +# ===================================================================== +# Public Organization Page +# ===================================================================== + +@router.get("/public/{org_slug}", response_model=OrgPublicPageResponse) +async def get_public_org_page( + org_slug: str, + db: AsyncSession = Depends(get_db), +): + """Get a public-facing organization page (no auth required).""" + result = await db.execute(select(Organization).where(Organization.slug == org_slug)) + org = result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization not found") + + # Owner info + owner_result = await db.execute(select(User).where(User.id == org.owner_id)) + owner = owner_result.scalar_one_or_none() + + member_count_q = await db.execute( + select(func.count(OrganizationMember.id)).where(OrganizationMember.organization_id == org.id) + ) + member_count = member_count_q.scalar() or 0 + + # Public addons (only active ones) + addons_result = await db.execute( + select(Addon) + .where(Addon.organization_id == org.id, Addon.is_active == True) + .order_by(Addon.created_at.desc()) + .limit(50) + ) + addons = addons_result.scalars().all() + + addon_responses = [] + for addon in addons: + addon_owner_q = await db.execute(select(User).where(User.id == addon.owner_id)) + addon_owner = addon_owner_q.scalar_one_or_none() + addon_responses.append(AddonResponse( + id=addon.id, name=addon.name, slug=addon.slug, + description=addon.description, + external=addon.external, is_active=addon.is_active, + is_public=addon.is_public, verified=addon.verified, + icon_url=addon.icon_url, banner_url=addon.banner_url, + tags=addon.tags or [], + owner_id=addon.owner_id, + owner_username=addon_owner.discord_username if addon_owner else None, + organization_id=addon.organization_id, + created_at=addon.created_at, + updated_at=addon.updated_at, + )) + + return OrgPublicPageResponse( + id=org.id, name=org.name, slug=org.slug, + description=org.description, avatar_url=org.avatar_url, + banner_url=org.banner_url, + owner_username=owner.discord_username if owner else None, + member_count=member_count, + addon_count=len(addon_responses), + addons=addon_responses, + created_at=org.created_at, + ) diff --git a/plexaddons-api/app/api/v1/payments.py b/plexaddons-api/app/api/v1/payments.py index 2d4a317..2e3c010 100644 --- a/plexaddons-api/app/api/v1/payments.py +++ b/plexaddons-api/app/api/v1/payments.py @@ -33,6 +33,8 @@ async def get_plans(): "3 version history", "100 requests/min", "Public profile", + "Addon categories", + "Auto-generated changelogs", ], ), PaymentPlan( @@ -51,6 +53,11 @@ async def get_plans(): "Ticket attachments", "Usage analytics (30 days)", "Private addons", + "Scheduled releases", + "Release channels (Beta/Alpha/Canary)", + "Version management", + "API key access (3 keys)", + "Auto-generated changelogs", "Supporter badge", ], ), @@ -71,8 +78,17 @@ async def get_plans(): "Ticket attachments", "Usage analytics (90 days)", "Private addons", - "API key access", + "Scheduled releases", + "Release channels (Beta/Alpha/Canary)", + "Version management", + "Gradual rollouts (A/B testing)", + "Real-time analytics dashboard", + "Staged rollouts", + "Team organizations", + "API key access (10 keys)", "Webhook notifications", + "Auto-generated changelogs", + "Priority support", "Supporter badge", ], ), diff --git a/plexaddons-api/app/api/v1/premium_analytics.py b/plexaddons-api/app/api/v1/premium_analytics.py new file mode 100644 index 0000000..bb844c2 --- /dev/null +++ b/plexaddons-api/app/api/v1/premium_analytics.py @@ -0,0 +1,757 @@ +"""Premium Analytics Suite endpoints (PREM-15 through PREM-19).""" + +import secrets +from datetime import datetime, timedelta, timezone, date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status, WebSocket, WebSocketDisconnect +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ + +from app.database import get_db +from app.models import ( + User, Addon, SubscriptionTier, VersionCheck, AddonUsageStats, Version, + SelfHostedConfig, AnalyticsAlert, CohortEntry, + AlertNotificationChannel, AlertComparison, +) +from app.schemas import ( + SelfHostedConfigCreate, SelfHostedConfigUpdate, SelfHostedConfigResponse, + AnalyticsAlertCreate, AnalyticsAlertUpdate, AnalyticsAlertResponse, AnalyticsAlertListResponse, + CohortAnalysisResponse, CohortSummary, + PredictiveEstimate, + RealtimeStats, +) +from app.api.deps import get_current_user, rate_limit_check_authenticated, get_effective_tier + + +# ============== SELF-HOSTED CONFIG (PREM-15) ============== + +selfhosted_router = APIRouter(prefix="/addons/{addon_id}/self-hosted", tags=["Self-Hosted Config"]) + + +async def _verify_premium_addon_owner(addon_id: int, user: User, db: AsyncSession) -> Addon: + """Verify user is premium and owns the addon.""" + effective_tier = get_effective_tier(user) + if effective_tier != SubscriptionTier.PREMIUM and not user.is_admin: + raise HTTPException(status_code=403, detail="Self-hosted config requires Premium subscription") + + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not your addon") + return addon + + +@selfhosted_router.get("", response_model=SelfHostedConfigResponse) +async def get_self_hosted_config( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get self-hosted version checker configuration for an addon.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(SelfHostedConfig).where(SelfHostedConfig.addon_id == addon_id) + ) + config = result.scalar_one_or_none() + if not config: + raise HTTPException(status_code=404, detail="Self-hosted config not found. Create one first.") + return config + + +@selfhosted_router.post("", response_model=SelfHostedConfigResponse, status_code=201) +async def create_self_hosted_config( + addon_id: int, + body: SelfHostedConfigCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Create self-hosted version checker configuration.""" + await _verify_premium_addon_owner(addon_id, user, db) + + # Check if config already exists + existing = await db.execute( + select(SelfHostedConfig).where(SelfHostedConfig.addon_id == addon_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Self-hosted config already exists for this addon") + + verification_token = secrets.token_urlsafe(32) + + config = SelfHostedConfig( + addon_id=addon_id, + custom_domain=body.custom_domain, + private_endpoint_enabled=body.private_endpoint_enabled, + api_key_required=body.api_key_required, + rate_limit_per_minute=body.rate_limit_per_minute, + verification_token=verification_token, + ) + db.add(config) + await db.commit() + await db.refresh(config) + return config + + +@selfhosted_router.put("", response_model=SelfHostedConfigResponse) +async def update_self_hosted_config( + addon_id: int, + body: SelfHostedConfigUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Update self-hosted version checker configuration.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(SelfHostedConfig).where(SelfHostedConfig.addon_id == addon_id) + ) + config = result.scalar_one_or_none() + if not config: + raise HTTPException(status_code=404, detail="Self-hosted config not found") + + update_data = body.model_dump(exclude_unset=True) + + # If custom domain changes, reset verification + if "custom_domain" in update_data and update_data["custom_domain"] != config.custom_domain: + config.domain_verified = False + config.verification_token = secrets.token_urlsafe(32) + + for key, value in update_data.items(): + setattr(config, key, value) + + await db.commit() + await db.refresh(config) + return config + + +@selfhosted_router.delete("", status_code=204) +async def delete_self_hosted_config( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Delete self-hosted version checker configuration.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(SelfHostedConfig).where(SelfHostedConfig.addon_id == addon_id) + ) + config = result.scalar_one_or_none() + if not config: + raise HTTPException(status_code=404, detail="Self-hosted config not found") + + await db.delete(config) + await db.commit() + + +@selfhosted_router.post("/verify-domain") +async def verify_domain( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Verify custom domain ownership. + + The user must add a TXT record `_plexdev-verify=` to their domain. + """ + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(SelfHostedConfig).where(SelfHostedConfig.addon_id == addon_id) + ) + config = result.scalar_one_or_none() + if not config: + raise HTTPException(status_code=404, detail="Self-hosted config not found") + if not config.custom_domain: + raise HTTPException(status_code=400, detail="No custom domain configured") + + # In production, this would do DNS TXT record lookup + # For now, return the expected TXT record for the user to set up + return { + "domain": config.custom_domain, + "verified": config.domain_verified, + "expected_txt_record": f"_plexdev-verify={config.verification_token}", + "instructions": f"Add a TXT record to {config.custom_domain} with value: _plexdev-verify={config.verification_token}", + } + + +# ============== ANALYTICS ALERTS (PREM-17) ============== + +alerts_router = APIRouter(prefix="/addons/{addon_id}/alerts", tags=["Analytics Alerts"]) + + +@alerts_router.get("", response_model=AnalyticsAlertListResponse) +async def list_alerts( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List all analytics alerts for an addon.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(AnalyticsAlert) + .where(AnalyticsAlert.addon_id == addon_id) + .order_by(AnalyticsAlert.created_at.desc()) + ) + alerts = result.scalars().all() + + return AnalyticsAlertListResponse(alerts=alerts, total=len(alerts)) + + +@alerts_router.post("", response_model=AnalyticsAlertResponse, status_code=201) +async def create_alert( + addon_id: int, + body: AnalyticsAlertCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Create an analytics alert for an addon.""" + await _verify_premium_addon_owner(addon_id, user, db) + + # Limit to 10 alerts per addon + count_result = await db.execute( + select(func.count(AnalyticsAlert.id)).where(AnalyticsAlert.addon_id == addon_id) + ) + if count_result.scalar() >= 10: + raise HTTPException(status_code=400, detail="Maximum 10 alerts per addon") + + # Validate notification channel has required fields + if body.notification_channel == AlertNotificationChannel.WEBHOOK and not body.webhook_url: + raise HTTPException(status_code=400, detail="webhook_url required for webhook channel") + if body.notification_channel == AlertNotificationChannel.EMAIL and not body.email: + raise HTTPException(status_code=400, detail="email required for email channel") + if body.notification_channel == AlertNotificationChannel.DISCORD and not body.discord_webhook_url: + raise HTTPException(status_code=400, detail="discord_webhook_url required for discord channel") + + alert = AnalyticsAlert( + addon_id=addon_id, + name=body.name, + metric=body.metric, + comparison=body.comparison, + threshold=body.threshold, + notification_channel=body.notification_channel, + webhook_url=body.webhook_url, + email=body.email, + discord_webhook_url=body.discord_webhook_url, + cooldown_minutes=body.cooldown_minutes, + ) + db.add(alert) + await db.commit() + await db.refresh(alert) + return alert + + +@alerts_router.put("/{alert_id}", response_model=AnalyticsAlertResponse) +async def update_alert( + addon_id: int, + alert_id: int, + body: AnalyticsAlertUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Update an analytics alert.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(AnalyticsAlert).where( + AnalyticsAlert.id == alert_id, + AnalyticsAlert.addon_id == addon_id, + ) + ) + alert = result.scalar_one_or_none() + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + update_data = body.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(alert, key, value) + + await db.commit() + await db.refresh(alert) + return alert + + +@alerts_router.delete("/{alert_id}", status_code=204) +async def delete_alert( + addon_id: int, + alert_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Delete an analytics alert.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(AnalyticsAlert).where( + AnalyticsAlert.id == alert_id, + AnalyticsAlert.addon_id == addon_id, + ) + ) + alert = result.scalar_one_or_none() + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + await db.delete(alert) + await db.commit() + + +@alerts_router.post("/{alert_id}/test") +async def test_alert( + addon_id: int, + alert_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Send a test notification for an alert.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select(AnalyticsAlert).where( + AnalyticsAlert.id == alert_id, + AnalyticsAlert.addon_id == addon_id, + ) + ) + alert = result.scalar_one_or_none() + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + # In production, this would actually send the notification. + # For now, return success with details about what would be sent. + return { + "status": "test_sent", + "channel": alert.notification_channel.value, + "message": f"Test alert: {alert.name} - {alert.metric} {alert.comparison.value} {alert.threshold}", + } + + +# ============== COHORT ANALYSIS (PREM-18) ============== + +cohort_router = APIRouter(prefix="/addons/{addon_id}/cohorts", tags=["Cohort Analysis"]) + + +@cohort_router.get("", response_model=CohortAnalysisResponse) +async def get_cohort_analysis( + addon_id: int, + days: int = Query(30, ge=1, le=90), + from_version: Optional[str] = Query(None, description="Filter by source version"), + to_version: Optional[str] = Query(None, description="Filter by target version"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Get cohort analysis showing version upgrade paths. + + Shows how many users upgraded from one version to another within the time window. + """ + await _verify_premium_addon_owner(addon_id, user, db) + + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + + query = select( + CohortEntry.from_version, + CohortEntry.to_version, + func.count(func.distinct(CohortEntry.client_ip_hash)).label("user_count"), + func.min(CohortEntry.transitioned_at).label("first_transition"), + func.max(CohortEntry.transitioned_at).label("last_transition"), + ).where( + CohortEntry.addon_id == addon_id, + CohortEntry.transitioned_at >= cutoff, + ) + + if from_version: + query = query.where(CohortEntry.from_version == from_version) + if to_version: + query = query.where(CohortEntry.to_version == to_version) + + query = query.group_by( + CohortEntry.from_version, CohortEntry.to_version + ).order_by(func.count(func.distinct(CohortEntry.client_ip_hash)).desc()) + + result = await db.execute(query) + rows = result.all() + + cohorts = [ + CohortSummary( + from_version=row.from_version, + to_version=row.to_version, + user_count=row.user_count, + first_transition=row.first_transition, + last_transition=row.last_transition, + ) + for row in rows + ] + + total = sum(c.user_count for c in cohorts) + + return CohortAnalysisResponse( + addon_id=addon_id, + period_days=days, + cohorts=cohorts, + total_transitions=total, + ) + + +# ============== PREDICTIVE ANALYTICS (PREM-19) ============== + +predictive_router = APIRouter(prefix="/addons/{addon_id}/predictive", tags=["Predictive Analytics"]) + + +@predictive_router.get("", response_model=PredictiveEstimate) +async def get_predictive_analytics( + addon_id: int, + target_version: str = Query(..., description="Version to estimate adoption for"), + days: int = Query(14, ge=3, le=90, description="Days of history to base prediction on"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Estimate rollout completion time based on current adoption rate. + + Uses linear regression on recent adoption data to project when adoption + milestones (50%, 90%, 100%) will be reached. + """ + await _verify_premium_addon_owner(addon_id, user, db) + + # Get the version + version_result = await db.execute( + select(Version).where( + Version.addon_id == addon_id, + Version.version == target_version, + ) + ) + version = version_result.scalar_one_or_none() + if not version: + raise HTTPException(status_code=404, detail="Version not found") + + start_date = date.today() - timedelta(days=days) + start_datetime = datetime.combine(start_date, datetime.min.time()) + + # Get total unique users for this addon (all versions) in the period + total_users_result = await db.execute( + select(func.count(func.distinct(VersionCheck.client_ip_hash))).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= start_datetime, + ) + ) + total_users = total_users_result.scalar() or 0 + + # Get unique users on the target version + adopted_result = await db.execute( + select(func.count(func.distinct(VersionCheck.client_ip_hash))).where( + VersionCheck.addon_id == addon_id, + VersionCheck.version_id == version.id, + VersionCheck.timestamp >= start_datetime, + ) + ) + adopted_users = adopted_result.scalar() or 0 + + # Calculate daily adoption rates for the target version over the period + daily_adoption = await db.execute( + select( + func.date(VersionCheck.timestamp).label("day"), + func.count(func.distinct(VersionCheck.client_ip_hash)).label("users"), + ).where( + VersionCheck.addon_id == addon_id, + VersionCheck.version_id == version.id, + VersionCheck.timestamp >= start_datetime, + ).group_by(func.date(VersionCheck.timestamp)) + .order_by(func.date(VersionCheck.timestamp)) + ) + daily_rows = daily_adoption.all() + + current_adoption = (adopted_users / total_users * 100) if total_users > 0 else 0 + + # Calculate daily adoption rate (average new adopters per day) + daily_rate = 0.0 + if len(daily_rows) >= 2: + # Use last N days to compute growth trend + values = [row.users for row in daily_rows] + # Simple average daily new user count + daily_rate = sum(values) / len(values) if values else 0.0 + elif len(daily_rows) == 1: + daily_rate = daily_rows[0].users + + # Calculate adoption rate as percent per day + daily_adoption_rate_pct = (daily_rate / total_users * 100) if total_users > 0 else 0.0 + + # Estimate days to milestones + def estimate_days_to(target_pct: float) -> Optional[int]: + if current_adoption >= target_pct: + return 0 + if daily_adoption_rate_pct <= 0: + return None + remaining = target_pct - current_adoption + return max(1, int(remaining / daily_adoption_rate_pct + 0.5)) + + return PredictiveEstimate( + addon_id=addon_id, + target_version=target_version, + current_adoption_percent=round(current_adoption, 2), + daily_adoption_rate=round(daily_adoption_rate_pct, 4), + estimated_days_to_50=estimate_days_to(50.0), + estimated_days_to_90=estimate_days_to(90.0), + estimated_days_to_100=estimate_days_to(100.0), + total_users=total_users, + adopted_users=adopted_users, + ) + + +# ============== REAL-TIME ANALYTICS (PREM-16) ============== + +realtime_router = APIRouter(prefix="/addons/{addon_id}/realtime", tags=["Real-Time Analytics"]) + + +@realtime_router.get("", response_model=RealtimeStats) +async def get_realtime_stats( + addon_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Get real-time analytics snapshot for an addon. + + Returns check counts and unique users for the last hour and 24 hours. + """ + await _verify_premium_addon_owner(addon_id, user, db) + + now = datetime.now(timezone.utc) + one_hour_ago = now - timedelta(hours=1) + twenty_four_hours_ago = now - timedelta(hours=24) + + # Checks in last hour + hour_result = await db.execute( + select( + func.count(VersionCheck.id).label("count"), + func.count(func.distinct(VersionCheck.client_ip_hash)).label("unique"), + ).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= one_hour_ago, + ) + ) + hour_row = hour_result.one() + + # Checks in last 24 hours + day_result = await db.execute( + select(func.count(VersionCheck.id)).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= twenty_four_hours_ago, + ) + ) + checks_24h = day_result.scalar() or 0 + + # Active versions in last 24 hours + active_versions_result = await db.execute( + select(func.count(func.distinct(VersionCheck.version_id))).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= twenty_four_hours_ago, + VersionCheck.version_id.isnot(None), + ) + ) + active_versions = active_versions_result.scalar() or 0 + + # Top version in last 24 hours + top_version_result = await db.execute( + select(Version.version).join( + VersionCheck, VersionCheck.version_id == Version.id + ).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= twenty_four_hours_ago, + ).group_by(Version.version) + .order_by(func.count(VersionCheck.id).desc()) + .limit(1) + ) + top_version = top_version_result.scalar_one_or_none() + + return RealtimeStats( + addon_id=addon_id, + checks_last_hour=hour_row.count or 0, + checks_last_24h=checks_24h, + unique_users_last_hour=hour_row.unique or 0, + active_versions=active_versions, + top_version=top_version, + ) + + +@realtime_router.get("/recent") +async def get_recent_checks( + addon_id: int, + limit: int = Query(50, ge=1, le=200), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get the most recent version checks for live feed display.""" + await _verify_premium_addon_owner(addon_id, user, db) + + result = await db.execute( + select( + VersionCheck.id, + VersionCheck.checked_version, + VersionCheck.timestamp, + VersionCheck.client_ip_hash, + Version.version.label("resolved_version"), + ).outerjoin(Version, VersionCheck.version_id == Version.id) + .where(VersionCheck.addon_id == addon_id) + .order_by(VersionCheck.timestamp.desc()) + .limit(limit) + ) + + checks = [ + { + "id": row.id, + "checked_version": row.checked_version, + "resolved_version": row.resolved_version, + "timestamp": row.timestamp.isoformat() if row.timestamp else None, + "client_hash_prefix": row.client_ip_hash[:8] if row.client_ip_hash else None, + } + for row in result.all() + ] + + return {"checks": checks, "total": len(checks)} + + +@realtime_router.get("/hourly") +async def get_hourly_breakdown( + addon_id: int, + hours: int = Query(24, ge=1, le=72), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Get hourly check breakdown for the last N hours.""" + await _verify_premium_addon_owner(addon_id, user, db) + + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + + hour_col = func.date_trunc('hour', VersionCheck.timestamp).label("hour") + result = await db.execute( + select( + hour_col, + func.count(VersionCheck.id).label("checks"), + func.count(func.distinct(VersionCheck.client_ip_hash)).label("unique_users"), + ).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= cutoff, + ).group_by(hour_col) + .order_by(hour_col) + ) + + hourly = [ + { + "hour": row.hour.isoformat() if row.hour else None, + "checks": row.checks, + "unique_users": row.unique_users, + } + for row in result.all() + ] + + return {"hourly": hourly, "period_hours": hours} + + +@realtime_router.websocket("/ws") +async def realtime_websocket( + websocket: WebSocket, + addon_id: int, + db: AsyncSession = Depends(get_db), +): + """ + WebSocket endpoint for live version-check streaming. + + Connect with a valid JWT token as query param: ?token= + Sends JSON events every 5 seconds with latest check stats. + """ + from app.core.security import decode_access_token + import asyncio + + # Authenticate via query param (WebSocket doesn't support Authorization header easily) + token = websocket.query_params.get("token") + if not token: + await websocket.close(code=4001, reason="Missing token") + return + + payload = decode_access_token(token) + if not payload or "sub" not in payload: + await websocket.close(code=4001, reason="Invalid token") + return + + user_id = int(payload["sub"]) + + # Verify user owns the addon and is premium + user_result = await db.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if not user: + await websocket.close(code=4001, reason="User not found") + return + + try: + await _verify_premium_addon_owner(addon_id, user, db) + except HTTPException: + await websocket.close(code=4003, reason="Forbidden") + return + + await websocket.accept() + + try: + while True: + now = datetime.now(timezone.utc) + one_hour_ago = now - timedelta(hours=1) + + hour_result = await db.execute( + select( + func.count(VersionCheck.id).label("count"), + func.count(func.distinct(VersionCheck.client_ip_hash)).label("unique"), + ).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= one_hour_ago, + ) + ) + row = hour_result.one() + + # Recent checks (last 10) + recent_result = await db.execute( + select( + VersionCheck.id, + VersionCheck.checked_version, + VersionCheck.timestamp, + ).where(VersionCheck.addon_id == addon_id) + .order_by(VersionCheck.timestamp.desc()) + .limit(10) + ) + recent = [ + { + "id": r.id, + "checked_version": r.checked_version, + "timestamp": r.timestamp.isoformat() if r.timestamp else None, + } + for r in recent_result.all() + ] + + await websocket.send_json({ + "type": "stats", + "addon_id": addon_id, + "checks_last_hour": row.count or 0, + "unique_users_last_hour": row.unique or 0, + "recent_checks": recent, + "timestamp": now.isoformat(), + }) + + await asyncio.sleep(5) + except WebSocketDisconnect: + pass diff --git a/plexaddons-api/app/api/v1/profiles.py b/plexaddons-api/app/api/v1/profiles.py index 6fd78f2..9a777ac 100644 --- a/plexaddons-api/app/api/v1/profiles.py +++ b/plexaddons-api/app/api/v1/profiles.py @@ -83,6 +83,9 @@ async def list_public_users( badges = json.loads(user.badges) if isinstance(user.badges, str) else user.badges except Exception: badges = [] + # Ensure admin users always have the staff badge + if user.is_admin and 'staff' not in badges: + badges = ['staff'] + badges # Get effective tier (temp_tier if active) effective_tier = get_effective_tier(user) @@ -95,6 +98,7 @@ async def list_public_users( "profile_slug": user.profile_slug, "badges": badges, "bio": user.bio, + "is_verified_developer": user.is_verified_developer, "addon_count": addon_counts.get(user.id, 0), "created_at": user.created_at.isoformat(), }) @@ -176,6 +180,7 @@ async def get_public_profile( updated_at=addon.updated_at, owner_username=user.discord_username, owner_discord_id=user.discord_id, + owner_verified_developer=user.is_verified_developer, latest_version=latest.version if latest else None, latest_release_date=latest.release_date if latest else None, version_count=len(versions), @@ -184,6 +189,9 @@ async def get_public_profile( # Parse badges JSON if stored as string badges = user.badges if user.badges else [] + # Ensure admin users always have the staff badge + if user.is_admin and 'staff' not in (badges or []): + badges = ['staff'] + (badges if isinstance(badges, list) else []) # Get effective tier (temp_tier if active) effective_tier = get_effective_tier(user) @@ -201,6 +209,7 @@ async def get_public_profile( badges=badges, banner_url=user.banner_url, accent_color=user.accent_color, + is_verified_developer=user.is_verified_developer, created_at=user.created_at, addons=addons_list, ) diff --git a/plexaddons-api/app/api/v1/reviews.py b/plexaddons-api/app/api/v1/reviews.py new file mode 100644 index 0000000..92cea12 --- /dev/null +++ b/plexaddons-api/app/api/v1/reviews.py @@ -0,0 +1,212 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from typing import Optional +from app.database import get_db +from app.models import User, Addon, AddonReview +from app.schemas import ( + ReviewCreate, ReviewUpdate, ReviewResponse, + ReviewListResponse, +) +from app.services import AddonService +from app.api.deps import get_current_user, get_current_user_optional, rate_limit_check, rate_limit_check_authenticated +from app.core.exceptions import NotFoundError, ForbiddenError, ConflictError + +router = APIRouter(prefix="/addons/{slug}/reviews", tags=["Reviews"]) + + +@router.get("", response_model=ReviewListResponse) +async def list_addon_reviews( + slug: str, + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=50), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check), +): + """List reviews for an addon.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + skip = (page - 1) * per_page + + # Get total count + count_result = await db.execute( + select(func.count(AddonReview.id)) + .where(AddonReview.addon_id == addon.id, AddonReview.is_visible == True) + ) + total = count_result.scalar() or 0 + + # Get average rating + avg_result = await db.execute( + select(func.avg(AddonReview.rating)) + .where(AddonReview.addon_id == addon.id, AddonReview.is_visible == True) + ) + average_rating = avg_result.scalar() + if average_rating is not None: + average_rating = round(float(average_rating), 1) + + # Get rating distribution + dist_result = await db.execute( + select(AddonReview.rating, func.count(AddonReview.id)) + .where(AddonReview.addon_id == addon.id, AddonReview.is_visible == True) + .group_by(AddonReview.rating) + ) + rating_distribution = {str(i): 0 for i in range(1, 6)} + for row in dist_result.all(): + rating_distribution[str(row[0])] = row[1] + + # Get reviews with author info + reviews_result = await db.execute( + select(AddonReview, User) + .outerjoin(User, AddonReview.user_id == User.id) + .where(AddonReview.addon_id == addon.id, AddonReview.is_visible == True) + .order_by(AddonReview.created_at.desc()) + .offset(skip) + .limit(per_page) + ) + + reviews = [] + for review, user in reviews_result.all(): + reviews.append(ReviewResponse( + id=review.id, + user_id=review.user_id, + addon_id=review.addon_id, + rating=review.rating, + title=review.title, + content=review.content, + is_visible=review.is_visible, + created_at=review.created_at, + updated_at=review.updated_at, + author_username=user.discord_username if user else None, + author_avatar=user.discord_avatar if user else None, + author_discord_id=user.discord_id if user else None, + )) + + return ReviewListResponse( + reviews=reviews, + total=total, + average_rating=average_rating, + rating_distribution=rating_distribution, + ) + + +@router.post("", response_model=ReviewResponse) +async def create_review( + slug: str, + data: ReviewCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Create a review for an addon. One review per user per addon.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + # Can't review your own addon + if addon.owner_id == user.id: + raise ForbiddenError("You cannot review your own addon") + + # Check for existing review + existing = await db.execute( + select(AddonReview) + .where(AddonReview.user_id == user.id, AddonReview.addon_id == addon.id) + ) + if existing.scalar_one_or_none(): + raise ConflictError("You have already reviewed this addon. Use PUT to update.") + + review = AddonReview( + user_id=user.id, + addon_id=addon.id, + rating=data.rating, + title=data.title, + content=data.content, + ) + db.add(review) + await db.commit() + await db.refresh(review) + + return ReviewResponse( + id=review.id, + user_id=review.user_id, + addon_id=review.addon_id, + rating=review.rating, + title=review.title, + content=review.content, + is_visible=review.is_visible, + created_at=review.created_at, + updated_at=review.updated_at, + author_username=user.discord_username, + author_avatar=user.discord_avatar, + author_discord_id=user.discord_id, + ) + + +@router.put("", response_model=ReviewResponse) +async def update_review( + slug: str, + data: ReviewUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Update your review for an addon.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + result = await db.execute( + select(AddonReview) + .where(AddonReview.user_id == user.id, AddonReview.addon_id == addon.id) + ) + review = result.scalar_one_or_none() + if not review: + raise NotFoundError("You haven't reviewed this addon") + + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(review, key, value) + + await db.commit() + await db.refresh(review) + + return ReviewResponse( + id=review.id, + user_id=review.user_id, + addon_id=review.addon_id, + rating=review.rating, + title=review.title, + content=review.content, + is_visible=review.is_visible, + created_at=review.created_at, + updated_at=review.updated_at, + author_username=user.discord_username, + author_avatar=user.discord_avatar, + author_discord_id=user.discord_id, + ) + + +@router.delete("") +async def delete_review( + slug: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Delete your review for an addon.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + result = await db.execute( + select(AddonReview) + .where(AddonReview.user_id == user.id, AddonReview.addon_id == addon.id) + ) + review = result.scalar_one_or_none() + if not review: + raise NotFoundError("You haven't reviewed this addon") + + await db.delete(review) + await db.commit() + return {"status": "deleted"} diff --git a/plexaddons-api/app/api/v1/rollouts.py b/plexaddons-api/app/api/v1/rollouts.py new file mode 100644 index 0000000..36756bd --- /dev/null +++ b/plexaddons-api/app/api/v1/rollouts.py @@ -0,0 +1,381 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from typing import Optional + +from app.database import get_db +from app.api.deps import get_current_user +from app.models import ( + User, Addon, StagedRollout, RolloutEvent, RolloutStage, RolloutStatus +) +from app.schemas import ( + StagedRolloutCreate, StagedRolloutUpdate, StagedRolloutResponse, + StagedRolloutListResponse, RolloutPromoteRequest, RolloutEventResponse, +) + +router = APIRouter(prefix="/addons/{addon_id}/rollouts", tags=["rollouts"]) + +STAGE_ORDER = [ + RolloutStage.CANARY, + RolloutStage.EARLY, + RolloutStage.PARTIAL, + RolloutStage.MAJORITY, + RolloutStage.FULL, +] + +STAGE_PERCENTAGES = { + RolloutStage.CANARY: 1, + RolloutStage.EARLY: 5, + RolloutStage.PARTIAL: 25, + RolloutStage.MAJORITY: 50, + RolloutStage.FULL: 100, +} + + +async def _get_addon_or_404(db: AsyncSession, addon_id: int) -> Addon: + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + return addon + + +async def _get_rollout_or_404(db: AsyncSession, rollout_id: int, addon_id: int) -> StagedRollout: + result = await db.execute( + select(StagedRollout).where( + StagedRollout.id == rollout_id, + StagedRollout.addon_id == addon_id, + ) + ) + rollout = result.scalar_one_or_none() + if not rollout: + raise HTTPException(status_code=404, detail="Rollout not found") + return rollout + + +@router.get("", response_model=StagedRolloutListResponse) +async def list_rollouts( + addon_id: int, + status_filter: Optional[str] = None, + skip: int = 0, + limit: int = 20, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + query = select(StagedRollout).where(StagedRollout.addon_id == addon_id) + count_query = select(func.count(StagedRollout.id)).where(StagedRollout.addon_id == addon_id) + + if status_filter: + try: + rs = RolloutStatus(status_filter) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid status filter") + query = query.where(StagedRollout.status == rs) + count_query = count_query.where(StagedRollout.status == rs) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(StagedRollout.created_at.desc()).offset(skip).limit(limit) + ) + rollouts = list(result.scalars().all()) + + # Eagerly load events + for r in rollouts: + ev_result = await db.execute( + select(RolloutEvent).where(RolloutEvent.rollout_id == r.id).order_by(RolloutEvent.created_at) + ) + r.events = list(ev_result.scalars().all()) + + return StagedRolloutListResponse(rollouts=rollouts, total=total) + + +@router.post("", response_model=StagedRolloutResponse, status_code=201) +async def create_rollout( + addon_id: int, + data: StagedRolloutCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + # Check no active rollout for same version + existing = await db.execute( + select(StagedRollout).where( + StagedRollout.addon_id == addon_id, + StagedRollout.version_id == data.version_id, + StagedRollout.status.in_([RolloutStatus.DRAFT, RolloutStatus.ACTIVE]), + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Active rollout already exists for this version") + + rollout = StagedRollout( + addon_id=addon_id, + version_id=data.version_id, + created_by_id=user.id, + stage=RolloutStage.CANARY, + percentage=STAGE_PERCENTAGES[RolloutStage.CANARY], + status=RolloutStatus.DRAFT, + targeting_rules=data.targeting_rules, + auto_promote=data.auto_promote, + auto_promote_after_hours=data.auto_promote_after_hours, + ) + db.add(rollout) + await db.flush() + + event = RolloutEvent( + rollout_id=rollout.id, + from_stage=None, + to_stage=RolloutStage.CANARY.value, + from_percentage=None, + to_percentage=STAGE_PERCENTAGES[RolloutStage.CANARY], + triggered_by="manual", + user_id=user.id, + ) + db.add(event) + await db.commit() + await db.refresh(rollout) + rollout.events = [event] + return rollout + + +@router.get("/{rollout_id}", response_model=StagedRolloutResponse) +async def get_rollout( + addon_id: int, + rollout_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + ev_result = await db.execute( + select(RolloutEvent).where(RolloutEvent.rollout_id == rollout.id).order_by(RolloutEvent.created_at) + ) + rollout.events = list(ev_result.scalars().all()) + return rollout + + +@router.patch("/{rollout_id}", response_model=StagedRolloutResponse) +async def update_rollout( + addon_id: int, + rollout_id: int, + data: StagedRolloutUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + if rollout.status in (RolloutStatus.COMPLETED, RolloutStatus.CANCELLED): + raise HTTPException(status_code=400, detail="Cannot update a completed or cancelled rollout") + + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(rollout, key, value) + + await db.commit() + await db.refresh(rollout) + ev_result = await db.execute( + select(RolloutEvent).where(RolloutEvent.rollout_id == rollout.id).order_by(RolloutEvent.created_at) + ) + rollout.events = list(ev_result.scalars().all()) + return rollout + + +@router.post("/{rollout_id}/activate", response_model=StagedRolloutResponse) +async def activate_rollout( + addon_id: int, + rollout_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + if rollout.status != RolloutStatus.DRAFT: + raise HTTPException(status_code=400, detail="Only draft rollouts can be activated") + + rollout.status = RolloutStatus.ACTIVE + await db.commit() + await db.refresh(rollout) + ev_result = await db.execute( + select(RolloutEvent).where(RolloutEvent.rollout_id == rollout.id).order_by(RolloutEvent.created_at) + ) + rollout.events = list(ev_result.scalars().all()) + return rollout + + +@router.post("/{rollout_id}/promote", response_model=StagedRolloutResponse) +async def promote_rollout( + addon_id: int, + rollout_id: int, + data: RolloutPromoteRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + if rollout.status != RolloutStatus.ACTIVE: + raise HTTPException(status_code=400, detail="Rollout must be active to promote") + if rollout.stage == RolloutStage.FULL: + raise HTTPException(status_code=400, detail="Rollout already at full stage") + if rollout.stage == RolloutStage.PAUSED: + raise HTTPException(status_code=400, detail="Unpause rollout before promoting") + + old_stage = rollout.stage + old_pct = rollout.percentage + + if data.target_stage: + target = data.target_stage + if STAGE_ORDER.index(target) <= STAGE_ORDER.index(old_stage): + raise HTTPException(status_code=400, detail="Target stage must be higher than current stage") + else: + idx = STAGE_ORDER.index(old_stage) + target = STAGE_ORDER[idx + 1] + + from datetime import datetime, timezone + rollout.stage = target + rollout.percentage = STAGE_PERCENTAGES[target] + rollout.promoted_at = datetime.now(timezone.utc) + + if target == RolloutStage.FULL: + rollout.status = RolloutStatus.COMPLETED + + event = RolloutEvent( + rollout_id=rollout.id, + from_stage=old_stage.value, + to_stage=target.value, + from_percentage=old_pct, + to_percentage=STAGE_PERCENTAGES[target], + triggered_by="manual", + user_id=user.id, + ) + db.add(event) + await db.commit() + await db.refresh(rollout) + + ev_result = await db.execute( + select(RolloutEvent).where(RolloutEvent.rollout_id == rollout.id).order_by(RolloutEvent.created_at) + ) + rollout.events = list(ev_result.scalars().all()) + return rollout + + +@router.post("/{rollout_id}/pause", response_model=StagedRolloutResponse) +async def pause_rollout( + addon_id: int, + rollout_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + if rollout.status != RolloutStatus.ACTIVE: + raise HTTPException(status_code=400, detail="Only active rollouts can be paused") + + rollout.status = RolloutStatus.PAUSED + rollout.stage = RolloutStage.PAUSED + + event = RolloutEvent( + rollout_id=rollout.id, + from_stage=rollout.stage.value, + to_stage=RolloutStage.PAUSED.value, + from_percentage=rollout.percentage, + to_percentage=rollout.percentage, + triggered_by="manual", + user_id=user.id, + ) + db.add(event) + await db.commit() + await db.refresh(rollout) + + ev_result = await db.execute( + select(RolloutEvent).where(RolloutEvent.rollout_id == rollout.id).order_by(RolloutEvent.created_at) + ) + rollout.events = list(ev_result.scalars().all()) + return rollout + + +@router.post("/{rollout_id}/cancel", response_model=StagedRolloutResponse) +async def cancel_rollout( + addon_id: int, + rollout_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + if rollout.status in (RolloutStatus.COMPLETED, RolloutStatus.CANCELLED): + raise HTTPException(status_code=400, detail="Rollout already completed or cancelled") + + old_stage = rollout.stage + rollout.status = RolloutStatus.CANCELLED + + event = RolloutEvent( + rollout_id=rollout.id, + from_stage=old_stage.value, + to_stage="cancelled", + from_percentage=rollout.percentage, + to_percentage=0, + triggered_by="manual", + user_id=user.id, + ) + db.add(event) + await db.commit() + await db.refresh(rollout) + + ev_result = await db.execute( + select(RolloutEvent).where(RolloutEvent.rollout_id == rollout.id).order_by(RolloutEvent.created_at) + ) + rollout.events = list(ev_result.scalars().all()) + return rollout + + +@router.post("/{rollout_id}/report-error", status_code=204) +async def report_rollout_error( + addon_id: int, + rollout_id: int, + db: AsyncSession = Depends(get_db), +): + """Public endpoint for pavc to report errors during rollout.""" + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + if rollout.status == RolloutStatus.ACTIVE: + rollout.error_reports = (rollout.error_reports or 0) + 1 + await db.commit() + + +@router.post("/{rollout_id}/check-in", status_code=204) +async def rollout_check_in( + addon_id: int, + rollout_id: int, + db: AsyncSession = Depends(get_db), +): + """Public endpoint for pavc to report successful check-in.""" + rollout = await _get_rollout_or_404(db, rollout_id, addon_id) + if rollout.status == RolloutStatus.ACTIVE: + rollout.total_checks = (rollout.total_checks or 0) + 1 + await db.commit() diff --git a/plexaddons-api/app/api/v1/security.py b/plexaddons-api/app/api/v1/security.py new file mode 100644 index 0000000..22f0ea7 --- /dev/null +++ b/plexaddons-api/app/api/v1/security.py @@ -0,0 +1,522 @@ +"""Security Suite API routes (PREM-5/6/7/8/9).""" +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.database import get_db +from app.api.deps import get_current_user +from app.models import ( + User, Addon, Version, ApiKey, + AddonSigningKey, VersionSignature, VulnerabilityScan, VersionSBOM, + TwoFactorChallenge, ScanStatus, +) +from app.schemas import ( + SigningKeyCreate, SigningKeyResponse, SigningKeyListResponse, + VersionSignatureCreate, VersionSignatureResponse, VersionSignatureVerifyRequest, + VulnerabilityScanResponse, VulnerabilityScanListResponse, + SBOMUpload, SBOMResponse, SBOMListResponse, + ApiKeyIPAllowlistUpdate, + TwoFactorChallengeRequest, TwoFactorChallengeResponse, + TwoFactorVerifyRequest, TwoFactorVerifyResponse, +) + +router = APIRouter(tags=["security"]) + + +# ================== Helpers ================== + +async def _get_addon_or_404(db: AsyncSession, addon_id: int) -> Addon: + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise HTTPException(status_code=404, detail="Addon not found") + return addon + + +async def _get_version_or_404(db: AsyncSession, version_id: int) -> Version: + result = await db.execute(select(Version).where(Version.id == version_id)) + version = result.scalar_one_or_none() + if not version: + raise HTTPException(status_code=404, detail="Version not found") + return version + + +# ================== PREM-5: Code Signing ================== + +signing_router = APIRouter(prefix="/addons/{addon_id}/signing-keys", tags=["code-signing"]) + + +@signing_router.get("", response_model=SigningKeyListResponse) +async def list_signing_keys( + addon_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + total_result = await db.execute( + select(func.count(AddonSigningKey.id)).where(AddonSigningKey.addon_id == addon_id) + ) + total = total_result.scalar() or 0 + result = await db.execute( + select(AddonSigningKey).where(AddonSigningKey.addon_id == addon_id).order_by(AddonSigningKey.created_at.desc()) + ) + keys = list(result.scalars().all()) + return SigningKeyListResponse(keys=keys, total=total) + + +@signing_router.post("", response_model=SigningKeyResponse, status_code=201) +async def create_signing_key( + addon_id: int, + data: SigningKeyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + fingerprint = hashlib.sha256(data.public_key.encode()).hexdigest() + + key = AddonSigningKey( + addon_id=addon_id, + created_by_id=user.id, + name=data.name, + public_key=data.public_key, + key_fingerprint=fingerprint, + algorithm=data.algorithm, + ) + db.add(key) + await db.commit() + await db.refresh(key) + return key + + +@signing_router.delete("/{key_id}", status_code=204) +async def revoke_signing_key( + addon_id: int, + key_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + addon = await _get_addon_or_404(db, addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + result = await db.execute( + select(AddonSigningKey).where(AddonSigningKey.id == key_id, AddonSigningKey.addon_id == addon_id) + ) + key = result.scalar_one_or_none() + if not key: + raise HTTPException(status_code=404, detail="Signing key not found") + + key.is_active = False + key.revoked_at = datetime.now(timezone.utc) + await db.commit() + + +# Version signatures +sig_router = APIRouter(prefix="/versions/{version_id}/signatures", tags=["code-signing"]) + + +@sig_router.post("", response_model=VersionSignatureResponse, status_code=201) +async def create_signature( + version_id: int, + data: VersionSignatureCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + version = await _get_version_or_404(db, version_id) + addon = await _get_addon_or_404(db, version.addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + # Verify signing key belongs to addon + key_result = await db.execute( + select(AddonSigningKey).where( + AddonSigningKey.id == data.signing_key_id, + AddonSigningKey.addon_id == addon.id, + AddonSigningKey.is_active == True, + ) + ) + if not key_result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Invalid or revoked signing key") + + sig = VersionSignature( + version_id=version_id, + signing_key_id=data.signing_key_id, + signature=data.signature, + signed_hash=data.signed_hash, + ) + db.add(sig) + await db.commit() + await db.refresh(sig) + return sig + + +@sig_router.get("", response_model=list[VersionSignatureResponse]) +async def list_signatures( + version_id: int, + db: AsyncSession = Depends(get_db), +): + """Public: list signatures for a version (for pavc verification).""" + await _get_version_or_404(db, version_id) + result = await db.execute( + select(VersionSignature).where(VersionSignature.version_id == version_id) + ) + return list(result.scalars().all()) + + +@sig_router.post("/{sig_id}/verify", response_model=VersionSignatureResponse) +async def verify_signature( + version_id: int, + sig_id: int, + data: VersionSignatureVerifyRequest, + db: AsyncSession = Depends(get_db), +): + """Public: verify a signature against an artifact hash.""" + result = await db.execute( + select(VersionSignature).where( + VersionSignature.id == sig_id, + VersionSignature.version_id == version_id, + ) + ) + sig = result.scalar_one_or_none() + if not sig: + raise HTTPException(status_code=404, detail="Signature not found") + + sig.verified = sig.signed_hash == data.artifact_hash + sig.verified_at = datetime.now(timezone.utc) if sig.verified else None + await db.commit() + await db.refresh(sig) + return sig + + +# ================== PREM-6: Vulnerability Scanning ================== + +scan_router = APIRouter(prefix="/versions/{version_id}/scans", tags=["vulnerability-scanning"]) + + +@scan_router.post("", response_model=VulnerabilityScanResponse, status_code=201) +async def initiate_scan( + version_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + version = await _get_version_or_404(db, version_id) + addon = await _get_addon_or_404(db, version.addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + # Check for existing pending/scanning scan + existing = await db.execute( + select(VulnerabilityScan).where( + VulnerabilityScan.version_id == version_id, + VulnerabilityScan.status.in_([ScanStatus.PENDING, ScanStatus.SCANNING]), + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Scan already in progress") + + scan = VulnerabilityScan( + version_id=version_id, + initiated_by_id=user.id, + status=ScanStatus.PENDING, + ) + db.add(scan) + await db.commit() + await db.refresh(scan) + return scan + + +@scan_router.get("", response_model=VulnerabilityScanListResponse) +async def list_scans( + version_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + version = await _get_version_or_404(db, version_id) + addon = await _get_addon_or_404(db, version.addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + total_result = await db.execute( + select(func.count(VulnerabilityScan.id)).where(VulnerabilityScan.version_id == version_id) + ) + total = total_result.scalar() or 0 + result = await db.execute( + select(VulnerabilityScan).where(VulnerabilityScan.version_id == version_id) + .order_by(VulnerabilityScan.created_at.desc()) + ) + scans = list(result.scalars().all()) + return VulnerabilityScanListResponse(scans=scans, total=total) + + +@scan_router.get("/{scan_id}", response_model=VulnerabilityScanResponse) +async def get_scan( + version_id: int, + scan_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + version = await _get_version_or_404(db, version_id) + addon = await _get_addon_or_404(db, version.addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + result = await db.execute( + select(VulnerabilityScan).where( + VulnerabilityScan.id == scan_id, + VulnerabilityScan.version_id == version_id, + ) + ) + scan = result.scalar_one_or_none() + if not scan: + raise HTTPException(status_code=404, detail="Scan not found") + return scan + + +# ================== PREM-7: SBOM ================== + +sbom_router = APIRouter(prefix="/versions/{version_id}/sbom", tags=["sbom"]) + + +def _parse_npm_lockfile(content: str) -> list[dict]: + """Parse package-lock.json and extract dependencies.""" + import json as json_lib + try: + data = json_lib.loads(content) + except json_lib.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON content") + + deps = [] + packages = data.get("packages", {}) + if packages: + for pkg_path, pkg_info in packages.items(): + if not pkg_path: # Root package + continue + name = pkg_path.replace("node_modules/", "", 1) + deps.append({ + "name": name, + "version": pkg_info.get("version", "unknown"), + "license": pkg_info.get("license", "unknown"), + "is_direct": not pkg_path.count("node_modules/") > 1, + }) + else: + # v1 lockfile + for name, info in data.get("dependencies", {}).items(): + deps.append({ + "name": name, + "version": info.get("version", "unknown"), + "license": "unknown", + "is_direct": not info.get("dev", False), + }) + return deps + + +def _parse_pip_lockfile(content: str) -> list[dict]: + """Parse requirements.txt style content.""" + deps = [] + for line in content.strip().splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + parts = line.split("==") + name = parts[0].split(">=")[0].split("<=")[0].split("~=")[0].split("!=")[0].strip() + version = parts[1].strip() if len(parts) > 1 else "any" + deps.append({"name": name, "version": version, "license": "unknown", "is_direct": True}) + return deps + + +@sbom_router.post("", response_model=SBOMResponse, status_code=201) +async def upload_sbom( + version_id: int, + data: SBOMUpload, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + version = await _get_version_or_404(db, version_id) + addon = await _get_addon_or_404(db, version.addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + if data.format == "npm": + deps = _parse_npm_lockfile(data.raw_content) + elif data.format == "pip": + deps = _parse_pip_lockfile(data.raw_content) + else: + deps = [] + + direct_count = sum(1 for d in deps if d.get("is_direct")) + + sbom = VersionSBOM( + version_id=version_id, + uploaded_by_id=user.id, + format=data.format, + raw_content=data.raw_content, + dependencies=deps, + total_dependencies=len(deps), + direct_dependencies=direct_count, + ) + db.add(sbom) + await db.commit() + await db.refresh(sbom) + return sbom + + +@sbom_router.get("", response_model=SBOMListResponse) +async def list_sboms( + version_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + version = await _get_version_or_404(db, version_id) + addon = await _get_addon_or_404(db, version.addon_id) + if addon.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + total_result = await db.execute( + select(func.count(VersionSBOM.id)).where(VersionSBOM.version_id == version_id) + ) + total = total_result.scalar() or 0 + result = await db.execute( + select(VersionSBOM).where(VersionSBOM.version_id == version_id) + .order_by(VersionSBOM.created_at.desc()) + ) + sboms = list(result.scalars().all()) + return SBOMListResponse(sboms=sboms, total=total) + + +# ================== PREM-8: IP Allowlist ================== + +ip_router = APIRouter(prefix="/api-keys/{key_id}/ip-allowlist", tags=["ip-allowlist"]) + + +@ip_router.put("") +async def update_ip_allowlist( + key_id: int, + data: ApiKeyIPAllowlistUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user.id) + ) + api_key = result.scalar_one_or_none() + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + + api_key.ip_allowlist = data.ip_allowlist + await db.commit() + return {"ip_allowlist": api_key.ip_allowlist} + + +@ip_router.get("") +async def get_ip_allowlist( + key_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user.id) + ) + api_key = result.scalar_one_or_none() + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + return {"ip_allowlist": api_key.ip_allowlist} + + +# ================== PREM-9: 2FA Challenges ================== + +twofa_router = APIRouter(prefix="/security/2fa", tags=["2fa"]) + + +@twofa_router.post("/challenge", response_model=TwoFactorChallengeResponse) +async def create_2fa_challenge( + data: TwoFactorChallengeRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """Create a 2FA challenge for a critical action. Sends code via email.""" + if not user.email: + raise HTTPException(status_code=400, detail="Email required for 2FA. Set your email in profile settings.") + + code = f"{secrets.randbelow(900000) + 100000}" + code_hash = hashlib.sha256(code.encode()).hexdigest() + + challenge = TwoFactorChallenge( + user_id=user.id, + action=data.action, + challenge_code_hash=code_hash, + expires_at=datetime.now(timezone.utc) + timedelta(minutes=10), + ) + db.add(challenge) + await db.commit() + await db.refresh(challenge) + + # Send code via email (best-effort) + try: + from app.services.email_service import EmailService + await EmailService.send_email( + to_email=user.email, + subject=f"PlexDev 2FA Verification Code: {code}", + html_content=f"

Your verification code is: {code}

" + f"

This code expires in 10 minutes.

" + f"

Action: {data.action.replace('_', ' ').title()}

", + ) + except Exception: + pass # Code is still valid, user may need to retry + + return TwoFactorChallengeResponse( + challenge_id=challenge.id, + action=data.action, + expires_at=challenge.expires_at, + message="Verification code sent to your email", + ) + + +@twofa_router.post("/verify", response_model=TwoFactorVerifyResponse) +async def verify_2fa_challenge( + data: TwoFactorVerifyRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """Verify a 2FA challenge code.""" + result = await db.execute( + select(TwoFactorChallenge).where( + TwoFactorChallenge.id == data.challenge_id, + TwoFactorChallenge.user_id == user.id, + ) + ) + challenge = result.scalar_one_or_none() + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + if challenge.is_used: + raise HTTPException(status_code=400, detail="Challenge already used") + + if challenge.expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Challenge expired") + + if challenge.attempts >= challenge.max_attempts: + raise HTTPException(status_code=429, detail="Too many attempts") + + challenge.attempts += 1 + code_hash = hashlib.sha256(data.code.encode()).hexdigest() + + if code_hash != challenge.challenge_code_hash: + await db.commit() + remaining = challenge.max_attempts - challenge.attempts + return TwoFactorVerifyResponse(verified=False, message=f"Invalid code. {remaining} attempts remaining.") + + challenge.is_verified = True + challenge.verified_at = datetime.now(timezone.utc) + await db.commit() + + return TwoFactorVerifyResponse(verified=True, message="Verification successful") diff --git a/plexaddons-api/app/api/v1/stars.py b/plexaddons-api/app/api/v1/stars.py new file mode 100644 index 0000000..cbad847 --- /dev/null +++ b/plexaddons-api/app/api/v1/stars.py @@ -0,0 +1,142 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, delete +from typing import Optional +from app.database import get_db +from app.models import User, Addon, AddonStar +from app.schemas import StarResponse, AddonResponse, AddonListResponse +from app.services import AddonService +from app.api.deps import get_current_user, get_current_user_optional, rate_limit_check, rate_limit_check_authenticated +from app.core.exceptions import NotFoundError + +router = APIRouter(prefix="/stars", tags=["Stars"]) + + +@router.post("/addons/{slug}", response_model=StarResponse) +async def star_addon( + slug: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Star/favorite an addon.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + # Check if already starred + existing = await db.execute( + select(AddonStar) + .where(AddonStar.user_id == user.id, AddonStar.addon_id == addon.id) + ) + if existing.scalar_one_or_none(): + # Already starred — return current state + count = await _get_star_count(db, addon.id) + return StarResponse(starred=True, star_count=count) + + star = AddonStar(user_id=user.id, addon_id=addon.id) + db.add(star) + await db.commit() + + count = await _get_star_count(db, addon.id) + return StarResponse(starred=True, star_count=count) + + +@router.delete("/addons/{slug}", response_model=StarResponse) +async def unstar_addon( + slug: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Remove star from an addon.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + await db.execute( + delete(AddonStar) + .where(AddonStar.user_id == user.id, AddonStar.addon_id == addon.id) + ) + await db.commit() + + count = await _get_star_count(db, addon.id) + return StarResponse(starred=False, star_count=count) + + +@router.get("/addons/{slug}", response_model=StarResponse) +async def get_addon_star_status( + slug: str, + user: Optional[User] = Depends(get_current_user_optional), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check), +): + """Get star status and count for an addon.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + starred = False + if user: + result = await db.execute( + select(AddonStar) + .where(AddonStar.user_id == user.id, AddonStar.addon_id == addon.id) + ) + starred = result.scalar_one_or_none() is not None + + count = await _get_star_count(db, addon.id) + return StarResponse(starred=starred, star_count=count) + + +@router.get("/mine", response_model=AddonListResponse) +async def list_my_starred_addons( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List addons the current user has starred.""" + skip = (page - 1) * per_page + + # Get starred addon IDs + count_result = await db.execute( + select(func.count(AddonStar.id)) + .where(AddonStar.user_id == user.id) + ) + total = count_result.scalar() or 0 + + starred_result = await db.execute( + select(AddonStar.addon_id) + .where(AddonStar.user_id == user.id) + .order_by(AddonStar.created_at.desc()) + .offset(skip) + .limit(per_page) + ) + addon_ids = [row[0] for row in starred_result.all()] + + if not addon_ids: + return AddonListResponse(addons=[], total=0, page=page, per_page=per_page) + + # Fetch the addons in the starred order + addons, _ = await AddonService.list_addons( + db, + skip=0, + limit=len(addon_ids), + addon_ids=addon_ids, + public_only=False, + ) + + return AddonListResponse( + addons=[AddonResponse(**a) for a in addons], + total=total, + page=page, + per_page=per_page, + ) + + +async def _get_star_count(db: AsyncSession, addon_id: int) -> int: + result = await db.execute( + select(func.count(AddonStar.id)).where(AddonStar.addon_id == addon_id) + ) + return result.scalar() or 0 diff --git a/plexaddons-api/app/api/v1/users.py b/plexaddons-api/app/api/v1/users.py index bd7e732..7d9bc30 100644 --- a/plexaddons-api/app/api/v1/users.py +++ b/plexaddons-api/app/api/v1/users.py @@ -2,7 +2,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, or_ from typing import Optional -import secrets from datetime import datetime, timezone from app.database import get_db from app.models import User, Subscription, SubscriptionStatus, PaymentProvider, SubscriptionTier, Addon @@ -13,8 +12,6 @@ SubscriptionResponse, UserProfileUpdate, UserPublicProfile, - ApiKeyCreate, - ApiKeyResponse, AddonResponse, WebhookConfigUpdate, WebhookConfigResponse, @@ -264,80 +261,6 @@ async def update_my_profile( return user -@router.get("/me/api-key", response_model=ApiKeyResponse) -async def get_my_api_key( - user: User = Depends(get_current_user), - _: None = Depends(rate_limit_check_authenticated), -): - """Get current user's API key status.""" - masked_key = None - if user.api_key: - # Show first 6 and last 4 characters: pa_xxxx...xxxx - masked_key = f"{user.api_key[:6]}...{user.api_key[-4:]}" - - return ApiKeyResponse( - has_api_key=user.api_key is not None, - created_at=user.api_key_created_at, - masked_key=masked_key - ) - - -@router.post("/me/api-key", response_model=ApiKeyCreate) -async def create_my_api_key( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - _: None = Depends(rate_limit_check_authenticated), -): - """ - Generate a new API key for the current user. - - Requirements: - - Premium subscription required - - Replaces any existing API key - - The full API key is only shown once! - """ - effective_tier = get_effective_tier(user) - if effective_tier != SubscriptionTier.PREMIUM: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="API keys require Premium subscription" - ) - - # Generate new API key with pa_ prefix - api_key = f"pa_{secrets.token_hex(32)}" - now = datetime.now(timezone.utc) - - user.api_key = api_key - user.api_key_created_at = now - - await db.commit() - - return ApiKeyCreate( - api_key=api_key, - created_at=now - ) - - -@router.delete("/me/api-key", status_code=status.HTTP_204_NO_CONTENT) -async def revoke_my_api_key( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - _: None = Depends(rate_limit_check_authenticated), -): - """Revoke the current user's API key.""" - if not user.api_key: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No API key to revoke" - ) - - user.api_key = None - user.api_key_created_at = None - - await db.commit() - - return None # ============== Webhook Endpoints ============== diff --git a/plexaddons-api/app/api/v1/versions.py b/plexaddons-api/app/api/v1/versions.py index 5a161ed..cec3d43 100644 --- a/plexaddons-api/app/api/v1/versions.py +++ b/plexaddons-api/app/api/v1/versions.py @@ -1,15 +1,19 @@ from fastapi import APIRouter, Depends, Query +from fastapi.responses import PlainTextResponse from sqlalchemy.ext.asyncio import AsyncSession from typing import Optional from app.database import get_db -from app.models import User +from app.models import User, SubscriptionTier, ReleaseChannel from app.schemas import ( VersionCreate, VersionUpdate, VersionResponse, + VersionListResponse, + VersionDeprecate, ) from app.services import AddonService, VersionService -from app.api.deps import get_current_user, get_current_user_optional, rate_limit_check, rate_limit_check_authenticated +from app.services.changelog_service import ChangelogService +from app.api.deps import get_current_user, get_current_user_optional, rate_limit_check, rate_limit_check_authenticated, get_effective_tier, check_paid_addon_access from app.core.exceptions import NotFoundError, ForbiddenError router = APIRouter(tags=["Versions"]) @@ -58,7 +62,10 @@ async def get_version( if not version: raise NotFoundError("Version not found") - return VersionResponse.model_validate(version) + resp = VersionResponse.model_validate(version) + if not await check_paid_addon_access(db, addon, user): + resp.download_url = "" + return resp @router.get("/addons/{slug}/versions/latest", response_model=VersionResponse) @@ -82,7 +89,10 @@ async def get_latest_version( if not version: raise NotFoundError("No versions found for this addon") - return VersionResponse.model_validate(version) + resp = VersionResponse.model_validate(version) + if not await check_paid_addon_access(db, addon, user): + resp.download_url = "" + return resp @router.patch("/addons/{slug}/versions/{version_str}", response_model=VersionResponse) @@ -134,3 +144,146 @@ async def delete_version( await VersionService.delete_version(db, version, user) return {"status": "deleted"} + + +@router.post("/addons/{slug}/versions/{version_str}/deprecate", response_model=VersionResponse) +async def deprecate_version( + slug: str, + version_str: str, + data: VersionDeprecate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Mark a version as deprecated (Pro+ feature). + + Deprecated versions remain downloadable but show a warning. + """ + effective_tier = get_effective_tier(user) + if effective_tier == SubscriptionTier.FREE: + raise ForbiddenError("Version deprecation requires Pro or Premium subscription") + + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise ForbiddenError("You don't have permission to manage this addon's versions") + + version = await VersionService.get_version_by_addon_and_version(db, addon.id, version_str) + if not version: + raise NotFoundError("Version not found") + + updated = await VersionService.deprecate_version(db, version, data.reason) + return VersionResponse.model_validate(updated) + + +@router.post("/addons/{slug}/versions/{version_str}/undeprecate", response_model=VersionResponse) +async def undeprecate_version( + slug: str, + version_str: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Remove deprecation from a version (Pro+ feature).""" + effective_tier = get_effective_tier(user) + if effective_tier == SubscriptionTier.FREE: + raise ForbiddenError("Version management requires Pro or Premium subscription") + + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise ForbiddenError("You don't have permission to manage this addon's versions") + + version = await VersionService.get_version_by_addon_and_version(db, addon.id, version_str) + if not version: + raise NotFoundError("Version not found") + + updated = await VersionService.undeprecate_version(db, version) + return VersionResponse.model_validate(updated) + + +@router.post("/addons/{slug}/versions/{version_str}/rollback", response_model=VersionResponse) +async def rollback_to_version( + slug: str, + version_str: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """ + Promote a version as the latest by updating its release date to today (Pro+ feature). + + This is a quick rollback action — useful when a bad version was published + and you want to make an older version the "latest" again. + """ + effective_tier = get_effective_tier(user) + if effective_tier == SubscriptionTier.FREE: + raise ForbiddenError("Version rollback requires Pro or Premium subscription") + + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise ForbiddenError("You don't have permission to manage this addon's versions") + + version = await VersionService.get_version_by_addon_and_version(db, addon.id, version_str) + if not version: + raise NotFoundError("Version not found") + + updated = await VersionService.rollback_to_version(db, addon, version) + return VersionResponse.model_validate(updated) + + +@router.get("/addons/{slug}/versions/latest/{channel}", response_model=VersionResponse) +async def get_latest_version_by_channel( + slug: str, + channel: ReleaseChannel, + db: AsyncSession = Depends(get_db), + user: Optional[User] = Depends(get_current_user_optional), + _: None = Depends(rate_limit_check), +): + """Get the latest non-deprecated, published version for a specific release channel.""" + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + if not addon.is_public: + if not user or (addon.owner_id != user.id and not user.is_admin): + raise NotFoundError("Addon not found") + + version = await VersionService.get_latest_version_by_channel(db, addon.id, channel) + if not version: + raise NotFoundError(f"No {channel.value} version found for this addon") + + return VersionResponse.model_validate(version) + + +@router.get("/addons/{slug}/changelog", response_class=PlainTextResponse) +async def get_addon_changelog( + slug: str, + from_version: Optional[str] = Query(None, description="Start version (inclusive)"), + to_version: Optional[str] = Query(None, description="End version (inclusive)"), + limit: int = Query(20, ge=1, le=100, description="Max versions to include"), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check), +): + """ + Generate a Markdown changelog for an addon from its version history. + + Pro+ feature: auto-generated from version descriptions and changelog content. + """ + addon = await AddonService.get_addon_by_slug(db, slug) + if not addon: + raise NotFoundError("Addon not found") + + changelog = await ChangelogService.generate_changelog( + db, + addon.id, + from_version=from_version, + to_version=to_version, + limit=limit, + ) + return PlainTextResponse(content=changelog, media_type="text/markdown") diff --git a/plexaddons-api/app/api/v1/webhooks.py b/plexaddons-api/app/api/v1/webhooks.py new file mode 100644 index 0000000..b9c55af --- /dev/null +++ b/plexaddons-api/app/api/v1/webhooks.py @@ -0,0 +1,329 @@ +"""Webhook endpoint management API routes (Premium feature).""" + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc + +from app.database import get_db +from app.api.deps import get_current_user, get_effective_tier, rate_limit_check_authenticated +from app.models import User, SubscriptionTier, WebhookEndpoint, WebhookDelivery +from app.schemas import ( + WebhookEndpointCreate, WebhookEndpointUpdate, WebhookEndpointResponse, + WebhookDeliveryResponse, WebhookTestResponse +) +from app.services.webhook_service import WebhookService + +router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) + +MAX_ENDPOINTS = 10 + + +def _require_premium(user: User): + effective_tier = get_effective_tier(user) + if effective_tier != SubscriptionTier.PREMIUM: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Webhook endpoints require Premium subscription" + ) + + +def _mask_secret(secret: str) -> str: + return f"whsec_{secret[:4]}...{secret[-4:]}" + + +@router.get("/endpoints", response_model=list[WebhookEndpointResponse]) +async def list_webhook_endpoints( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List all webhook endpoints for the current user.""" + _require_premium(user) + + result = await db.execute( + select(WebhookEndpoint) + .where(WebhookEndpoint.user_id == user.id) + .order_by(WebhookEndpoint.created_at) + ) + endpoints = result.scalars().all() + + return [ + WebhookEndpointResponse( + id=ep.id, name=ep.name, url=ep.url, is_active=ep.is_active, + event_filter=ep.event_filter, payload_template=ep.payload_template, + has_secret=True, masked_secret=_mask_secret(ep.secret), + created_at=ep.created_at, updated_at=ep.updated_at, + ) + for ep in endpoints + ] + + +@router.post("/endpoints", response_model=WebhookEndpointResponse, status_code=201) +async def create_webhook_endpoint( + data: WebhookEndpointCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Create a new webhook endpoint. Max 10 per user.""" + _require_premium(user) + + count_result = await db.execute( + select(func.count()).where(WebhookEndpoint.user_id == user.id) + ) + count = count_result.scalar() or 0 + if count >= MAX_ENDPOINTS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum {MAX_ENDPOINTS} webhook endpoints allowed" + ) + + secret = WebhookService.generate_webhook_secret() + endpoint = WebhookEndpoint( + user_id=user.id, + name=data.name, + url=data.url, + secret=secret, + is_active=data.is_active, + event_filter=data.event_filter, + payload_template=data.payload_template, + ) + db.add(endpoint) + await db.commit() + await db.refresh(endpoint) + + return WebhookEndpointResponse( + id=endpoint.id, name=endpoint.name, url=endpoint.url, + is_active=endpoint.is_active, event_filter=endpoint.event_filter, + payload_template=endpoint.payload_template, + has_secret=True, masked_secret=_mask_secret(endpoint.secret), + created_at=endpoint.created_at, updated_at=endpoint.updated_at, + ) + + +@router.patch("/endpoints/{endpoint_id}", response_model=WebhookEndpointResponse) +async def update_webhook_endpoint( + endpoint_id: int, + data: WebhookEndpointUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Update a webhook endpoint.""" + _require_premium(user) + + result = await db.execute( + select(WebhookEndpoint).where( + WebhookEndpoint.id == endpoint_id, + WebhookEndpoint.user_id == user.id, + ) + ) + endpoint = result.scalar_one_or_none() + if not endpoint: + raise HTTPException(status_code=404, detail="Webhook endpoint not found") + + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(endpoint, key, value) + + await db.commit() + await db.refresh(endpoint) + + return WebhookEndpointResponse( + id=endpoint.id, name=endpoint.name, url=endpoint.url, + is_active=endpoint.is_active, event_filter=endpoint.event_filter, + payload_template=endpoint.payload_template, + has_secret=True, masked_secret=_mask_secret(endpoint.secret), + created_at=endpoint.created_at, updated_at=endpoint.updated_at, + ) + + +@router.delete("/endpoints/{endpoint_id}", status_code=204) +async def delete_webhook_endpoint( + endpoint_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Delete a webhook endpoint and all its delivery history.""" + _require_premium(user) + + result = await db.execute( + select(WebhookEndpoint).where( + WebhookEndpoint.id == endpoint_id, + WebhookEndpoint.user_id == user.id, + ) + ) + endpoint = result.scalar_one_or_none() + if not endpoint: + raise HTTPException(status_code=404, detail="Webhook endpoint not found") + + await db.delete(endpoint) + await db.commit() + + +@router.post("/endpoints/{endpoint_id}/rotate-secret", response_model=dict) +async def rotate_endpoint_secret( + endpoint_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Rotate the signing secret for an endpoint. Full secret shown only once.""" + _require_premium(user) + + result = await db.execute( + select(WebhookEndpoint).where( + WebhookEndpoint.id == endpoint_id, + WebhookEndpoint.user_id == user.id, + ) + ) + endpoint = result.scalar_one_or_none() + if not endpoint: + raise HTTPException(status_code=404, detail="Webhook endpoint not found") + + new_secret = WebhookService.generate_webhook_secret() + endpoint.secret = new_secret + await db.commit() + + return {"secret": new_secret} + + +@router.post("/endpoints/{endpoint_id}/test", response_model=WebhookTestResponse) +async def test_webhook_endpoint( + endpoint_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Send a test event to a specific endpoint.""" + _require_premium(user) + + result = await db.execute( + select(WebhookEndpoint).where( + WebhookEndpoint.id == endpoint_id, + WebhookEndpoint.user_id == user.id, + ) + ) + endpoint = result.scalar_one_or_none() + if not endpoint: + raise HTTPException(status_code=404, detail="Webhook endpoint not found") + + test_result = await WebhookService.test_endpoint(endpoint) + return WebhookTestResponse(**test_result) + + +# ============== Delivery Log ============== + +@router.get("/endpoints/{endpoint_id}/deliveries", response_model=list[WebhookDeliveryResponse]) +async def list_deliveries( + endpoint_id: int, + page: int = Query(1, ge=1), + per_page: int = Query(25, ge=1, le=100), + status_filter: Optional[str] = Query(None, alias="status"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """List delivery history for an endpoint (last 100 kept).""" + _require_premium(user) + + # Verify endpoint ownership + ep_result = await db.execute( + select(WebhookEndpoint.id).where( + WebhookEndpoint.id == endpoint_id, + WebhookEndpoint.user_id == user.id, + ) + ) + if not ep_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Webhook endpoint not found") + + query = ( + select(WebhookDelivery) + .where(WebhookDelivery.endpoint_id == endpoint_id) + ) + if status_filter: + query = query.where(WebhookDelivery.status == status_filter) + + query = query.order_by(desc(WebhookDelivery.created_at)) + query = query.offset((page - 1) * per_page).limit(per_page) + + result = await db.execute(query) + deliveries = result.scalars().all() + + return [ + WebhookDeliveryResponse( + id=d.id, endpoint_id=d.endpoint_id, event_type=d.event_type, + status=d.status, status_code=d.status_code, + error_message=d.error_message, attempt=d.attempt, + max_attempts=d.max_attempts, next_retry_at=d.next_retry_at, + created_at=d.created_at, delivered_at=d.delivered_at, + ) + for d in deliveries + ] + + +@router.post("/endpoints/{endpoint_id}/deliveries/{delivery_id}/retry", response_model=WebhookDeliveryResponse) +async def retry_delivery( + endpoint_id: int, + delivery_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check_authenticated), +): + """Manually retry a failed delivery.""" + _require_premium(user) + + # Verify endpoint ownership + ep_result = await db.execute( + select(WebhookEndpoint).where( + WebhookEndpoint.id == endpoint_id, + WebhookEndpoint.user_id == user.id, + ) + ) + endpoint = ep_result.scalar_one_or_none() + if not endpoint: + raise HTTPException(status_code=404, detail="Webhook endpoint not found") + + result = await db.execute( + select(WebhookDelivery).where( + WebhookDelivery.id == delivery_id, + WebhookDelivery.endpoint_id == endpoint_id, + ) + ) + delivery = result.scalar_one_or_none() + if not delivery: + raise HTTPException(status_code=404, detail="Delivery not found") + + if delivery.status == "success": + raise HTTPException(status_code=400, detail="Cannot retry a successful delivery") + + # Attempt redelivery + success, status_code, error = await WebhookService.deliver_to_endpoint( + endpoint, delivery.event_type, delivery.payload + ) + + delivery.attempt += 1 + delivery.status_code = status_code + if success: + from datetime import datetime, timezone + delivery.status = "success" + delivery.delivered_at = datetime.now(timezone.utc) + delivery.error_message = None + delivery.next_retry_at = None + else: + delivery.status = "failed" + delivery.error_message = error + + await db.commit() + await db.refresh(delivery) + + return WebhookDeliveryResponse( + id=delivery.id, endpoint_id=delivery.endpoint_id, + event_type=delivery.event_type, status=delivery.status, + status_code=delivery.status_code, error_message=delivery.error_message, + attempt=delivery.attempt, max_attempts=delivery.max_attempts, + next_retry_at=delivery.next_retry_at, + created_at=delivery.created_at, delivered_at=delivery.delivered_at, + ) diff --git a/plexaddons-api/app/config.py b/plexaddons-api/app/config.py index fe2104d..4d10cb0 100644 --- a/plexaddons-api/app/config.py +++ b/plexaddons-api/app/config.py @@ -36,7 +36,7 @@ class Settings(BaseSettings): paypal_webhook_id: str paypal_pro_plan_id: str paypal_premium_plan_id: str - paypal_api_base: str = "https://api-m.sandbox.paypal.com" # Use api-m.paypal.com for production + paypal_api_base: str = "https://api-m.paypal.com" # Override in .env for sandbox: https://api-m.sandbox.paypal.com # Admin initial_admin_discord_id: Optional[str] = None diff --git a/plexaddons-api/app/core/cache.py b/plexaddons-api/app/core/cache.py new file mode 100644 index 0000000..1c0b767 --- /dev/null +++ b/plexaddons-api/app/core/cache.py @@ -0,0 +1,99 @@ +"""Redis caching layer for hot data.""" +import json +import logging +from typing import Optional, Any, Callable +from functools import wraps +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + +# Global cache client +_cache_client: Optional[redis.Redis] = None + + +def set_cache_client(client: redis.Redis) -> None: + global _cache_client + _cache_client = client + + +def get_cache_client() -> Optional[redis.Redis]: + return _cache_client + + +class CacheService: + """Redis-based caching service for frequently accessed data.""" + + # Default TTLs in seconds + TTL_ADDON_LIST = 60 # Addon listings: 60s + TTL_ADDON_DETAIL = 120 # Single addon detail: 2min + TTL_PLANS = 3600 # Payment plans: 1 hour + TTL_PROFILE = 300 # User profiles: 5 min + TTL_STATS = 60 # Admin stats: 60s + + @staticmethod + async def get(key: str) -> Optional[Any]: + client = get_cache_client() + if not client: + return None + try: + data = await client.get(f"cache:{key}") + if data: + return json.loads(data) + except Exception as e: + logger.warning(f"Cache get error for {key}: {e}") + return None + + @staticmethod + async def set(key: str, value: Any, ttl: int = 60) -> None: + client = get_cache_client() + if not client: + return + try: + await client.setex(f"cache:{key}", ttl, json.dumps(value, default=str)) + except Exception as e: + logger.warning(f"Cache set error for {key}: {e}") + + @staticmethod + async def delete(key: str) -> None: + client = get_cache_client() + if not client: + return + try: + await client.delete(f"cache:{key}") + except Exception as e: + logger.warning(f"Cache delete error for {key}: {e}") + + @staticmethod + async def delete_pattern(pattern: str) -> None: + """Delete all keys matching a pattern.""" + client = get_cache_client() + if not client: + return + try: + cursor = 0 + while True: + cursor, keys = await client.scan(cursor, match=f"cache:{pattern}", count=100) + if keys: + await client.delete(*keys) + if cursor == 0: + break + except Exception as e: + logger.warning(f"Cache delete_pattern error for {pattern}: {e}") + + @staticmethod + async def invalidate_addon(addon_id: int = None, addon_slug: str = None) -> None: + """Invalidate cache entries related to an addon.""" + await CacheService.delete_pattern("addon_list:*") + if addon_id: + await CacheService.delete(f"addon:{addon_id}") + if addon_slug: + await CacheService.delete(f"addon_slug:{addon_slug}") + + @staticmethod + async def invalidate_user(user_id: int) -> None: + """Invalidate cache entries related to a user.""" + await CacheService.delete(f"profile:{user_id}") + await CacheService.delete_pattern(f"user_addons:{user_id}:*") + + +cache = CacheService() diff --git a/plexaddons-api/app/core/logging.py b/plexaddons-api/app/core/logging.py new file mode 100644 index 0000000..0e33492 --- /dev/null +++ b/plexaddons-api/app/core/logging.py @@ -0,0 +1,79 @@ +"""Structured logging configuration using structlog.""" +import logging +import sys +import uuid + +import structlog +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +def setup_logging(json_output: bool = True, log_level: str = "INFO") -> None: + """Configure structured logging for the application.""" + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + if json_output: + renderer = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer() + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + foreign_pre_chain=shared_processors, + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO)) + + # Reduce noise from third-party libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + + +def get_logger(name: str = None) -> structlog.stdlib.BoundLogger: + """Get a structured logger instance.""" + return structlog.get_logger(name) + + +class CorrelationIdMiddleware(BaseHTTPMiddleware): + """Middleware to add a correlation ID to each request for tracing.""" + + async def dispatch(self, request: Request, call_next) -> Response: + correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4()) + + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars( + correlation_id=correlation_id, + method=request.method, + path=request.url.path, + ) + + response: Response = await call_next(request) + response.headers["X-Correlation-ID"] = correlation_id + return response diff --git a/plexaddons-api/app/core/security.py b/plexaddons-api/app/core/security.py index 0c40a31..62a375f 100644 --- a/plexaddons-api/app/core/security.py +++ b/plexaddons-api/app/core/security.py @@ -1,13 +1,10 @@ from datetime import datetime, timedelta, timezone from typing import Optional from jose import JWTError, jwt -from passlib.context import CryptContext from app.config import get_settings settings = get_settings() -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: """Create a JWT access token.""" @@ -30,13 +27,3 @@ def decode_access_token(token: str) -> Optional[dict]: return payload except JWTError: return None - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against its hash.""" - return pwd_context.verify(plain_password, hashed_password) - - -def get_password_hash(password: str) -> str: - """Hash a password.""" - return pwd_context.hash(password) diff --git a/plexaddons-api/app/database.py b/plexaddons-api/app/database.py index 92142f7..7957e41 100644 --- a/plexaddons-api/app/database.py +++ b/plexaddons-api/app/database.py @@ -7,14 +7,22 @@ # Convert postgresql:// to postgresql+asyncpg:// database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://") -engine = create_async_engine( - database_url, +_engine_kwargs = dict( echo=settings.debug, pool_pre_ping=True, - pool_size=10, - max_overflow=20, ) +# Pool settings only apply to connection-based backends (not SQLite) +if "sqlite" not in database_url: + _engine_kwargs.update( + pool_size=20, + max_overflow=30, + pool_timeout=30, + pool_recycle=1800, # Recycle connections after 30 minutes + ) + +engine = create_async_engine(database_url, **_engine_kwargs) + AsyncSessionLocal = async_sessionmaker( engine, class_=AsyncSession, diff --git a/plexaddons-api/app/main.py b/plexaddons-api/app/main.py index 24fd5db..5a9447e 100644 --- a/plexaddons-api/app/main.py +++ b/plexaddons-api/app/main.py @@ -12,10 +12,19 @@ from app.api.public import router as public_router from app.webhooks import router as webhooks_router from app.core.rate_limit import RateLimitMiddleware, set_rate_limiter, set_redis_client +from app.core.cache import set_cache_client from app.core.exceptions import PlexAddonsException +from app.core.logging import setup_logging, get_logger, CorrelationIdMiddleware settings = get_settings() +# Initialize structured logging +setup_logging( + json_output=settings.environment == "production", + log_level="DEBUG" if settings.debug else "INFO", +) +logger = get_logger("plexaddons") + # Scheduler for periodic tasks scheduler = AsyncIOScheduler() @@ -31,7 +40,7 @@ async def cleanup_audit_logs(): delete(AdminAuditLog).where(AdminAuditLog.created_at < cutoff) ) await db.commit() - print(f"[Scheduler] Cleaned up audit logs older than {cutoff}") + logger.info("Cleaned up audit logs", cutoff=str(cutoff)) async def cleanup_api_request_logs(): @@ -45,7 +54,7 @@ async def cleanup_api_request_logs(): delete(ApiRequestLog).where(ApiRequestLog.timestamp < cutoff) ) await db.commit() - print(f"[Scheduler] Cleaned up API request logs older than {cutoff}") + logger.info("Cleaned up API request logs", cutoff=str(cutoff)) async def send_weekly_summary(): @@ -55,9 +64,9 @@ async def send_weekly_summary(): async with AsyncSessionLocal() as db: result = await email_service.send_admin_weekly_summary(db) if result: - print("[Scheduler] Weekly summary email sent") + logger.info("Weekly summary email sent") else: - print("[Scheduler] Weekly summary email skipped (no admin email configured)") + logger.info("Weekly summary email skipped", reason="no admin email configured") async def compress_ticket_attachments(): @@ -66,7 +75,7 @@ async def compress_ticket_attachments(): async with AsyncSessionLocal() as db: compressed_count = await ticket_service.compress_old_attachments(db) - print(f"[Scheduler] Compressed {compressed_count} ticket attachments") + logger.info("Compressed ticket attachments", count=compressed_count) async def cleanup_ticket_attachments(): @@ -76,7 +85,7 @@ async def cleanup_ticket_attachments(): async with AsyncSessionLocal() as db: deleted_count = await ticket_service.delete_old_attachments(db) removed_dirs = await ticket_service.cleanup_empty_directories() - print(f"[Scheduler] Deleted {deleted_count} old ticket attachments, removed {removed_dirs} empty directories") + logger.info("Cleaned up ticket attachments", deleted=deleted_count, dirs_removed=removed_dirs) async def publish_scheduled_versions(): @@ -100,11 +109,29 @@ async def publish_scheduled_versions(): for version in versions: version.is_published = True - print(f"[Scheduler] Published scheduled version {version.version} for addon {version.addon_id}") + logger.info("Published scheduled version", version=version.version, addon_id=version.addon_id) if versions: await db.commit() - print(f"[Scheduler] Published {len(versions)} scheduled versions") + logger.info("Published scheduled versions", count=len(versions)) + + +async def cleanup_analytics_data(): + """Scheduled task to clean up old analytics data beyond retention period.""" + from app.services.analytics_service import AnalyticsService + + async with AsyncSessionLocal() as db: + await AnalyticsService.cleanup_old_data(db, retention_days=settings.analytics_retention_premium) + logger.info("Cleaned up analytics data", retention_days=settings.analytics_retention_premium) + + +async def recalculate_analytics_unique_users(): + """Scheduled task to recalculate unique user counts from version check logs.""" + from app.services.analytics_service import AnalyticsService + + async with AsyncSessionLocal() as db: + await AnalyticsService.recalculate_unique_users(db) + logger.info("Recalculated unique user counts") async def bootstrap_initial_admin(): @@ -124,32 +151,31 @@ async def bootstrap_initial_admin(): if user and not user.is_admin: user.is_admin = True await db.commit() - print(f"[Bootstrap] Promoted existing user {user.discord_username} to admin") + logger.info("Promoted user to admin", username=user.discord_username) elif not user: - print(f"[Bootstrap] Initial admin Discord ID configured: {settings.initial_admin_discord_id}") - print("[Bootstrap] User will be promoted to admin on first login") + logger.info("Initial admin configured, will promote on first login", discord_id=settings.initial_admin_discord_id) @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager.""" # Startup - print("[Startup] Initializing database...") + logger.info("Initializing database") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Initialize Redis for rate limiting - print("[Startup] Connecting to Redis...") + logger.info("Connecting to Redis") try: redis_client = redis.from_url(settings.redis_url, decode_responses=True) await redis_client.ping() set_redis_client(redis_client) # Store globally for OAuth state storage + set_cache_client(redis_client) # Store globally for caching rate_limiter = RateLimitMiddleware(redis_client) set_rate_limiter(rate_limiter) - print("[Startup] Redis connected successfully") + logger.info("Redis connected") except Exception as e: - print(f"[Startup] Redis connection failed: {e}") - print("[Startup] Rate limiting will be disabled") + logger.warning("Redis connection failed, rate limiting disabled", error=str(e)) # Bootstrap initial admin await bootstrap_initial_admin() @@ -191,15 +217,27 @@ async def lifespan(app: FastAPI): "interval", minutes=5, # Check every 5 minutes for scheduled versions ) + scheduler.add_job( + cleanup_analytics_data, + "cron", + hour=5, # Run at 5 AM daily + minute=0, + ) + scheduler.add_job( + recalculate_analytics_unique_users, + "cron", + hour=5, # Run at 5 AM daily + minute=30, + ) scheduler.start() - print("[Startup] Scheduler started") + logger.info("Scheduler started") yield # Shutdown - print("[Shutdown] Stopping scheduler...") + logger.info("Stopping scheduler") scheduler.shutdown() - print("[Shutdown] Closing database connections...") + logger.info("Closing database connections") await engine.dispose() @@ -230,6 +268,9 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) +# Add correlation ID middleware for request tracing +app.add_middleware(CorrelationIdMiddleware) + # Exception handlers @app.exception_handler(PlexAddonsException) @@ -271,6 +312,10 @@ async def add_security_headers(request: Request, call_next): # Referrer policy response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + # Prevent browser caching of API responses (dynamic JSON data) + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + # Permissions policy (disable unnecessary browser features) response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" @@ -290,11 +335,38 @@ async def add_security_headers(request: Request, call_next): # Health check endpoint @app.get("/health") async def health_check(): - return { + health = { "status": "healthy", "version": settings.app_version, "environment": settings.environment, + "checks": {}, } + + # Check database connectivity + try: + async with AsyncSessionLocal() as db: + from sqlalchemy import text + await db.execute(text("SELECT 1")) + health["checks"]["database"] = "ok" + except Exception as e: + health["checks"]["database"] = f"error: {str(e)}" + health["status"] = "degraded" + + # Check Redis connectivity + try: + from app.core.rate_limit import get_redis_client + rc = get_redis_client() + if rc: + await rc.ping() + health["checks"]["redis"] = "ok" + else: + health["checks"]["redis"] = "not configured" + except Exception as e: + health["checks"]["redis"] = f"error: {str(e)}" + health["status"] = "degraded" + + status_code = 200 if health["status"] == "healthy" else 503 + return JSONResponse(content=health, status_code=status_code) @app.get("/") diff --git a/plexaddons-api/app/models/__init__.py b/plexaddons-api/app/models/__init__.py index 613fecb..27a1f02 100644 --- a/plexaddons-api/app/models/__init__.py +++ b/plexaddons-api/app/models/__init__.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, BigInteger, Text, DateTime, ForeignKey, Date, Index, Enum as SQLEnum, JSON +from sqlalchemy import Column, Integer, String, Boolean, BigInteger, Text, DateTime, ForeignKey, Date, Index, Enum as SQLEnum, JSON, SmallInteger from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base @@ -42,6 +42,14 @@ class AddonTag(str, enum.Enum): OTHER = "other" # Miscellaneous +# Release Channels (Pro+ feature) +class ReleaseChannel(str, enum.Enum): + STABLE = "stable" + BETA = "beta" + ALPHA = "alpha" + CANARY = "canary" + + # Organization member roles class OrganizationRole(str, enum.Enum): OWNER = "owner" # Full control, billing @@ -49,6 +57,43 @@ class OrganizationRole(str, enum.Enum): MEMBER = "member" # Can create/edit addons +class CollaboratorRole(str, enum.Enum): + ADMIN = "admin" # Can manage collaborators, versions, settings + EDITOR = "editor" # Can create/edit versions + VIEWER = "viewer" # Read-only access to private addons + + +class ScanStatus(str, enum.Enum): + PENDING = "pending" + SCANNING = "scanning" + COMPLETED = "completed" + FAILED = "failed" + + +class VulnerabilitySeverity(str, enum.Enum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" +# Rollout stages +class RolloutStage(str, enum.Enum): + CANARY = "canary" # 1% + EARLY = "early" # 5% + PARTIAL = "partial" # 25% + MAJORITY = "majority" # 50% + FULL = "full" # 100% + PAUSED = "paused" # manually paused + + +class RolloutStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + PAUSED = "paused" + COMPLETED = "completed" + CANCELLED = "cancelled" + + # API Key Scopes - defines what each key can access class ApiKeyScope(str, enum.Enum): # Read operations (Pro+) @@ -85,23 +130,28 @@ class TicketCategory(str, enum.Enum): BILLING = "billing" TECHNICAL = "technical" FEATURE_REQUEST = "feature_request" - BUG_REPORT = "bug_report" -class SubscriptionStatus(str, enum.Enum): - ACTIVE = "active" - PAST_DUE = "past_due" - CANCELED = "canceled" - UNPAID = "unpaid" - TRIALING = "trialing" - PAUSED = "paused" - INCOMPLETE = "incomplete" - INCOMPLETE_EXPIRED = "incomplete_expired" +# Alert notification channels (Premium) +class AlertNotificationChannel(str, enum.Enum): + WEBHOOK = "webhook" + EMAIL = "email" + DISCORD = "discord" -class PaymentProvider(str, enum.Enum): - STRIPE = "stripe" - PAYPAL = "paypal" +# Alert comparison operators +class AlertComparison(str, enum.Enum): + BELOW = "below" # Notify when metric drops below threshold + ABOVE = "above" # Notify when metric exceeds threshold + BUG_REPORT = "bug_report" + + +# License status for paid addons +class LicenseStatus(str, enum.Enum): + ACTIVE = "active" + EXPIRED = "expired" + REVOKED = "revoked" + SUSPENDED = "suspended" class User(Base): @@ -144,14 +194,13 @@ class User(Base): # Badges (JSON array of badge IDs) badges = Column(Text, nullable=True) # e.g., '["pro", "early_adopter", "addon_creator"]' + # Verified developer status (admin-set, separate from addon verified) + is_verified_developer = Column(Boolean, default=False, index=True) + # Profile customization (tier-locked) banner_url = Column(String(500), nullable=True) # Pro+ only accent_color = Column(String(7), nullable=True) # Premium only, hex color e.g., "#e9a426" - # ============== API KEY (Premium only) ============== - api_key = Column(String(67), unique=True, nullable=True, index=True) # pa_ + 64 hex chars - api_key_created_at = Column(DateTime(timezone=True), nullable=True) - # ============== WEBHOOK NOTIFICATIONS (Premium only) ============== webhook_url = Column(String(500), nullable=True) # URL to send notifications to webhook_secret = Column(String(64), nullable=True) # Secret for signing webhook payloads @@ -165,6 +214,9 @@ class User(Base): temp_tier_granted_at = Column(DateTime(timezone=True), nullable=True) temp_tier_reason = Column(String(500), nullable=True) # Why was temp tier granted + # ============== STRIPE CONNECT (Marketplace payouts) ============== + stripe_connect_account_id = Column(String(255), nullable=True, unique=True) + # OAuth tokens (should be encrypted in production) discord_access_token = Column(Text, nullable=True) discord_refresh_token = Column(Text, nullable=True) @@ -231,12 +283,28 @@ class Addon(Base): homepage = Column(String(500), nullable=True) external = Column(Boolean, default=False) # true = free community addon tags = Column(JSON, default=list) # List of AddonTag values + icon_url = Column(String(500), nullable=True) # Addon icon/logo URL + readme = Column(Text, nullable=True) # Markdown long description / README + + # Media + banner_url = Column(String(500), nullable=True) # Hero banner image URL + screenshots = Column(JSON, default=list) # List of screenshot URLs (max 6) + + # Theme customization (Pro+) + theme_accent_color = Column(String(7), nullable=True) # Hex color e.g. "#ff5500" + theme_header_url = Column(String(500), nullable=True) # Custom header image URL # Status is_active = Column(Boolean, default=True) is_public = Column(Boolean, default=True, index=True) verified = Column(Boolean, default=False, index=True) # Verified by PlexDevelopment team + # Marketplace (Premium) + is_paid = Column(Boolean, default=False) + price_cents = Column(Integer, nullable=True) # Price in cents (e.g., 499 = $4.99) + revenue_split_percent = Column(Integer, default=90) # Developer's share (90 = 90/10 split) + sponsor_url = Column(String(500), nullable=True) # External sponsorship/donation link + created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) @@ -248,6 +316,8 @@ class Addon(Base): __table_args__ = ( Index("idx_addons_owner_name", "owner_id", "name", unique=True), Index("idx_addons_organization", "organization_id"), + Index("idx_addons_public_active", "is_public", "is_active"), + Index("idx_addons_updated_at", "updated_at"), ) @@ -283,6 +353,14 @@ class Version(Base): # Premium Feature: A/B Rollouts rollout_percentage = Column(Integer, default=100) # 0-100, percentage of users who see this version + # Pro+ Feature: Release Channels + channel = Column(SQLEnum(ReleaseChannel, values_callable=lambda x: [e.value for e in x]), default=ReleaseChannel.STABLE, nullable=False) + + # Pro+ Feature: Version Deprecation + is_deprecated = Column(Boolean, default=False) + deprecation_reason = Column(Text, nullable=True) + deprecated_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) # Relationships @@ -292,6 +370,8 @@ class Version(Base): Index("idx_versions_addon_version", "addon_id", "version", unique=True), Index("idx_versions_release_date", "release_date"), Index("idx_versions_scheduled_release", "scheduled_release_at"), + Index("idx_versions_channel", "addon_id", "channel"), + Index("idx_versions_addon_created_at", "addon_id", "created_at"), ) @@ -541,6 +621,159 @@ class AddonUsageStats(Base): ) +# ============== SELF-HOSTED VERSION CHECKER (Premium) ============== + +class SelfHostedConfig(Base): + """Self-hosted version checker configuration for Premium users (PREM-15).""" + __tablename__ = "self_hosted_configs" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False, unique=True) + + # Custom domain for versions.json endpoint + custom_domain = Column(String(255), nullable=True, unique=True) + domain_verified = Column(Boolean, default=False) + verification_token = Column(String(64), nullable=True) + + # Private versions.json endpoint config + private_endpoint_enabled = Column(Boolean, default=True) + api_key_required = Column(Boolean, default=True) + + # Rate limiting for self-hosted endpoint + rate_limit_per_minute = Column(Integer, default=60) + + # Timestamps + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + addon = relationship("Addon", backref="self_hosted_config") + + __table_args__ = ( + Index("idx_self_hosted_configs_addon", "addon_id", unique=True), + Index("idx_self_hosted_configs_domain", "custom_domain"), + ) + + +# ============== ANALYTICS ALERTS (Premium) ============== + +class AnalyticsAlert(Base): + """Alert configuration for analytics thresholds (PREM-17).""" + __tablename__ = "analytics_alerts" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + + # Alert configuration + name = Column(String(100), nullable=False) + metric = Column(String(50), nullable=False) # "daily_checks", "unique_users", "error_rate" + comparison = Column(SQLEnum(AlertComparison, values_callable=lambda x: [e.value for e in x]), nullable=False) + threshold = Column(Integer, nullable=False) + + # Notification settings + notification_channel = Column(SQLEnum(AlertNotificationChannel, values_callable=lambda x: [e.value for e in x]), nullable=False) + webhook_url = Column(String(500), nullable=True) + email = Column(String(320), nullable=True) + discord_webhook_url = Column(String(500), nullable=True) + + # State + is_active = Column(Boolean, default=True) + last_triggered_at = Column(DateTime(timezone=True), nullable=True) + trigger_count = Column(Integer, default=0) + + # Cooldown: minimum minutes between alerts + cooldown_minutes = Column(Integer, default=60) + + # Timestamps + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + addon = relationship("Addon", backref="analytics_alerts") + + __table_args__ = ( + Index("idx_analytics_alerts_addon", "addon_id"), + Index("idx_analytics_alerts_active", "is_active"), + ) + + +# ============== COHORT ANALYSIS (Premium) ============== + +class CohortEntry(Base): + """Tracks version upgrade paths for cohort analysis (PREM-18).""" + __tablename__ = "cohort_entries" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + + # Version transition + from_version = Column(String(50), nullable=False) + to_version = Column(String(50), nullable=False) + + # Privacy-preserving user tracking (hashed IP) + client_ip_hash = Column(String(64), nullable=False) + + # When the transition was detected + transitioned_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + Index("idx_cohort_entries_addon", "addon_id"), + Index("idx_cohort_entries_addon_versions", "addon_id", "from_version", "to_version"), + Index("idx_cohort_entries_transitioned", "transitioned_at"), + ) + + +# ============== STAR / FAVORITE SYSTEM ============== + +class AddonStar(Base): + """User stars/favorites on addons.""" + __tablename__ = "addon_stars" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + user = relationship("User", backref="stars") + addon = relationship("Addon", backref="stars") + + __table_args__ = ( + Index("idx_addon_stars_user_addon", "user_id", "addon_id", unique=True), + Index("idx_addon_stars_addon", "addon_id"), + ) + + +# ============== REVIEWS & RATINGS SYSTEM ============== + +class AddonReview(Base): + """User reviews and ratings for addons.""" + __tablename__ = "addon_reviews" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + + rating = Column(Integer, nullable=False) # 1-5 stars + title = Column(String(200), nullable=True) + content = Column(Text, nullable=True) + + # Moderation + is_visible = Column(Boolean, default=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + user = relationship("User", backref="reviews") + addon = relationship("Addon", backref="reviews") + + __table_args__ = ( + Index("idx_addon_reviews_user_addon", "user_id", "addon_id", unique=True), + Index("idx_addon_reviews_addon", "addon_id"), + ) + + # ============== ORGANIZATION MODELS (Premium Feature) ============== class Organization(Base): @@ -557,6 +790,7 @@ class Organization(Base): # Avatar/branding avatar_url = Column(String(500), nullable=True) + banner_url = Column(String(500), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) @@ -565,6 +799,8 @@ class Organization(Base): owner = relationship("User", back_populates="owned_organizations", foreign_keys=[owner_id]) members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan") addons = relationship("Addon", back_populates="organization") + audit_logs = relationship("OrgAuditLog", back_populates="organization", cascade="all, delete-orphan") + api_keys = relationship("OrgApiKey", back_populates="organization", cascade="all, delete-orphan") __table_args__ = ( Index("idx_organizations_owner", "owner_id"), @@ -582,6 +818,9 @@ class OrganizationMember(Base): # Role role = Column(SQLEnum(OrganizationRole), default=OrganizationRole.MEMBER, nullable=False) + # Granular permissions (JSON: {"manage_versions": true, "view_analytics": true, ...}) + permissions = Column(JSON, nullable=True) + # Invitation tracking invited_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) joined_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -597,6 +836,46 @@ class OrganizationMember(Base): ) +# ============== NOTIFICATION SYSTEM ============== + +class NotificationType(str, enum.Enum): + ADDON_UPDATE = "addon_update" # New version of a starred addon + REVIEW_RECEIVED = "review_received" # Someone reviewed your addon + STAR_RECEIVED = "star_received" # Someone starred your addon + SYSTEM = "system" # System announcements + ADDON_VERIFIED = "addon_verified" # Your addon was verified + VERSION_PUBLISHED = "version_published" # Scheduled version went live + + +class Notification(Base): + """In-app notifications for users.""" + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # Notification content + type = Column(SQLEnum(NotificationType), nullable=False) + title = Column(String(200), nullable=False) + message = Column(Text, nullable=True) + + # Optional link to related resource + link = Column(String(500), nullable=True) # e.g., /addons/my-addon + + # Read status + is_read = Column(Boolean, default=False, index=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + user = relationship("User", backref="notifications") + + __table_args__ = ( + Index("idx_notifications_user_read", "user_id", "is_read"), + Index("idx_notifications_user_created", "user_id", "created_at"), + ) + + # ============== API KEYS SYSTEM (Pro+) ============== class ApiKey(Base): @@ -622,6 +901,9 @@ class ApiKey(Base): # Expiration (optional) expires_at = Column(DateTime(timezone=True), nullable=True) + # IP Allowlisting (Premium) + ip_allowlist = Column(JSON, nullable=True) # List of CIDR ranges e.g. ["192.168.1.0/24", "10.0.0.1/32"] + # Status is_active = Column(Boolean, default=True, index=True) revoked_at = Column(DateTime(timezone=True), nullable=True) @@ -636,3 +918,501 @@ class ApiKey(Base): Index("idx_api_keys_user_id", "user_id"), Index("idx_api_keys_key_hash", "key_hash"), ) + + +class AddonCollaborator(Base): + """Collaborators who can co-manage addons (lighter than organizations).""" + __tablename__ = "addon_collaborators" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + role = Column(SQLEnum(CollaboratorRole, values_callable=lambda x: [e.value for e in x]), nullable=False, default=CollaboratorRole.EDITOR) + + invited_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + accepted = Column(Boolean, default=False) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + accepted_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + addon = relationship("Addon", backref="collaborators") + user = relationship("User", foreign_keys=[user_id], backref="collaborations") + invited_by = relationship("User", foreign_keys=[invited_by_id]) + + __table_args__ = ( + Index("idx_addon_collaborators_addon", "addon_id"), + Index("idx_addon_collaborators_user", "user_id"), + Index("idx_addon_collaborators_unique", "addon_id", "user_id", unique=True), + ) + + +# ============== ADDON LICENSES (Marketplace - Premium) ============== + +class AddonLicense(Base): + """License keys for paid addons (PREM-13).""" + __tablename__ = "addon_licenses" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + buyer_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + # License identification + license_key = Column(String(64), unique=True, nullable=False, index=True) + + # Payment info + stripe_payment_intent_id = Column(String(255), nullable=True) + amount_cents = Column(Integer, nullable=False) # Amount paid + developer_amount_cents = Column(Integer, nullable=False) # Developer's share + platform_amount_cents = Column(Integer, nullable=False) # Platform's share + + # Status + status = Column( + SQLEnum(LicenseStatus, values_callable=lambda x: [e.value for e in x]), + nullable=False, + default=LicenseStatus.ACTIVE, + ) + + # Optional: tie license to a server/instance + server_id = Column(String(100), nullable=True) + + # Expiration (optional, None = lifetime) + expires_at = Column(DateTime(timezone=True), nullable=True) + + # Timestamps + created_at = Column(DateTime(timezone=True), server_default=func.now()) + revoked_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + addon = relationship("Addon", backref="licenses") + buyer = relationship("User", backref="purchased_licenses") + + __table_args__ = ( + Index("idx_addon_licenses_addon", "addon_id"), + Index("idx_addon_licenses_buyer", "buyer_id"), + Index("idx_addon_licenses_key", "license_key", unique=True), + Index("idx_addon_licenses_status", "status"), + ) + + +class AddonSigningKey(Base): + """Code signing keys per addon (Premium: PREM-5).""" + __tablename__ = "addon_signing_keys" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + name = Column(String(100), nullable=False) + public_key = Column(Text, nullable=False) + key_fingerprint = Column(String(64), nullable=False) # SHA-256 fingerprint + algorithm = Column(String(20), nullable=False, default="ed25519") + + is_active = Column(Boolean, default=True) + revoked_at = Column(DateTime(timezone=True), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now()) + + addon = relationship("Addon", backref="signing_keys") + created_by = relationship("User", foreign_keys=[created_by_id]) + + __table_args__ = ( + Index("idx_signing_keys_addon", "addon_id"), + Index("idx_signing_keys_fingerprint", "key_fingerprint"), + ) + + +# ============== STAGED ROLLOUTS ============== + +class StagedRollout(Base): + """Staged rollout configuration for a version.""" + __tablename__ = "staged_rollouts" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + version_id = Column(Integer, ForeignKey("versions.id", ondelete="CASCADE"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + # Current stage & percentage + stage = Column(SQLEnum(RolloutStage, values_callable=lambda x: [e.value for e in x]), + nullable=False, default=RolloutStage.CANARY) + percentage = Column(Integer, nullable=False, default=1) # 0-100 + + status = Column(SQLEnum(RolloutStatus, values_callable=lambda x: [e.value for e in x]), + nullable=False, default=RolloutStatus.DRAFT) + + # Targeting rules (JSON: {"server_ids": [...], "user_ids": [...], "metadata": {...}}) + targeting_rules = Column(JSON, nullable=True) + + # Auto-promotion settings + auto_promote = Column(Boolean, default=False) + auto_promote_after_hours = Column(Integer, default=24) # hours without issues before auto-promoting + + # Metrics + total_checks = Column(Integer, default=0) + error_reports = Column(Integer, default=0) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + promoted_at = Column(DateTime(timezone=True), nullable=True) # Last promotion timestamp + + # Relationships + addon = relationship("Addon", backref="rollouts") + version = relationship("Version", backref="rollouts") + created_by = relationship("User") + + __table_args__ = ( + Index("idx_staged_rollouts_addon", "addon_id"), + Index("idx_staged_rollouts_version", "version_id"), + Index("idx_staged_rollouts_addon_status", "addon_id", "status"), + ) + + +class RolloutEvent(Base): + """Tracks rollout stage transitions.""" + __tablename__ = "rollout_events" + + id = Column(Integer, primary_key=True, index=True) + rollout_id = Column(Integer, ForeignKey("staged_rollouts.id", ondelete="CASCADE"), nullable=False) + from_stage = Column(String(20), nullable=True) + to_stage = Column(String(20), nullable=False) + from_percentage = Column(Integer, nullable=True) + to_percentage = Column(Integer, nullable=False) + triggered_by = Column(String(20), nullable=False, default="manual") # "manual" or "auto" + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + rollout = relationship("StagedRollout", backref="events") + + __table_args__ = ( + Index("idx_rollout_events_rollout", "rollout_id"), + ) + + +# ============== FEATURE FLAGS ============== + +class FeatureFlag(Base): + """Feature flags linked to addons for toggling functionality without releases.""" + __tablename__ = "feature_flags" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + key = Column(String(100), nullable=False) # e.g. "dark_mode", "new_ui" + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + + enabled = Column(Boolean, default=False) + percentage = Column(Integer, default=100) # % of users who see it when enabled + + # Targeting (JSON: {"server_ids": [...], "user_ids": [...]}) + targeting = Column(JSON, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now()) + + addon = relationship("Addon", backref="feature_flags") + created_by = relationship("User", foreign_keys=[created_by_id]) + + __table_args__ = ( + Index("idx_feature_flags_addon", "addon_id"), + Index("idx_feature_flags_addon_key", "addon_id", "key", unique=True), + ) + + +class VersionSignature(Base): + """Signature for a version, created with signing key (PREM-5).""" + __tablename__ = "version_signatures" + + id = Column(Integer, primary_key=True, index=True) + version_id = Column(Integer, ForeignKey("versions.id", ondelete="CASCADE"), nullable=False) + signing_key_id = Column(Integer, ForeignKey("addon_signing_keys.id", ondelete="SET NULL"), nullable=True) + + signature = Column(Text, nullable=False) + signed_hash = Column(String(128), nullable=False) # SHA-256 hash of the artifact that was signed + + verified = Column(Boolean, default=False) + verified_at = Column(DateTime(timezone=True), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + version = relationship("Version", backref="signatures") + signing_key = relationship("AddonSigningKey") + + __table_args__ = ( + Index("idx_version_signatures_version", "version_id"), + ) + + +class VulnerabilityScan(Base): + """Vulnerability scan results per version (PREM-6).""" + __tablename__ = "vulnerability_scans" + + id = Column(Integer, primary_key=True, index=True) + version_id = Column(Integer, ForeignKey("versions.id", ondelete="CASCADE"), nullable=False) + initiated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + status = Column(SQLEnum(ScanStatus, values_callable=lambda x: [e.value for e in x]), + nullable=False, default=ScanStatus.PENDING) + vulnerabilities = Column(JSON, default=list) # List of {id, severity, summary, url, package, patched_versions} + total_vulnerabilities = Column(Integer, default=0) + critical_count = Column(Integer, default=0) + high_count = Column(Integer, default=0) + medium_count = Column(Integer, default=0) + low_count = Column(Integer, default=0) + + scan_started_at = Column(DateTime(timezone=True), nullable=True) + scan_completed_at = Column(DateTime(timezone=True), nullable=True) + error_message = Column(Text, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + version = relationship("Version", backref="vulnerability_scans") + initiated_by = relationship("User", foreign_keys=[initiated_by_id]) + + __table_args__ = ( + Index("idx_vuln_scans_version", "version_id"), + Index("idx_vuln_scans_status", "status"), + ) + + +class VersionSBOM(Base): + """Software Bill of Materials per version (PREM-7).""" + __tablename__ = "version_sboms" + + id = Column(Integer, primary_key=True, index=True) + version_id = Column(Integer, ForeignKey("versions.id", ondelete="CASCADE"), nullable=False) + uploaded_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + format = Column(String(50), nullable=False, default="npm") # npm, pip, maven, etc. + raw_content = Column(Text, nullable=True) # Raw lockfile content + dependencies = Column(JSON, default=list) # Parsed: [{name, version, license, is_direct}] + total_dependencies = Column(Integer, default=0) + direct_dependencies = Column(Integer, default=0) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now()) + + version = relationship("Version", backref="sboms") + uploaded_by = relationship("User", foreign_keys=[uploaded_by_id]) + + __table_args__ = ( + Index("idx_sboms_version", "version_id"), + ) + + +class TwoFactorChallenge(Base): + """2FA challenges for critical actions (PREM-9).""" + __tablename__ = "two_factor_challenges" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + action = Column(String(50), nullable=False) # e.g. "delete_addon", "revoke_key", "change_payment" + challenge_code_hash = Column(String(128), nullable=False) # Hashed 6-digit code + + is_verified = Column(Boolean, default=False) + is_used = Column(Boolean, default=False) + attempts = Column(Integer, default=0) + max_attempts = Column(Integer, default=5) + + expires_at = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + verified_at = Column(DateTime(timezone=True), nullable=True) + + user = relationship("User", backref="two_factor_challenges") + + __table_args__ = ( + Index("idx_2fa_challenges_user", "user_id"), + Index("idx_2fa_challenges_expires", "expires_at"), + ) + + +# ============== ORGANIZATION ENHANCEMENTS ============== + +class OrgAuditLog(Base): + """Audit log for organization actions.""" + __tablename__ = "org_audit_logs" + + id = Column(Integer, primary_key=True, index=True) + organization_id = Column(Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + action = Column(String(100), nullable=False) # e.g. "member.invited", "addon.created" + details = Column(JSON, nullable=True) + ip_address = Column(String(45), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + organization = relationship("Organization", back_populates="audit_logs") + user = relationship("User") + + __table_args__ = ( + Index("idx_org_audit_logs_org", "organization_id"), + Index("idx_org_audit_logs_created", "organization_id", "created_at"), + ) + + +class OrgApiKey(Base): + """API keys scoped to an organization.""" + __tablename__ = "org_api_keys" + + id = Column(Integer, primary_key=True, index=True) + organization_id = Column(Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + name = Column(String(100), nullable=False) + key_hash = Column(String(128), nullable=False, unique=True) + key_prefix = Column(String(12), nullable=False) # First 8 chars for identification + scopes = Column(JSON, nullable=True) # ["read:addons", "read:analytics", "write:versions"] + is_active = Column(Boolean, default=True) + last_used_at = Column(DateTime(timezone=True), nullable=True) + expires_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + organization = relationship("Organization", back_populates="api_keys") + created_by = relationship("User") + + __table_args__ = ( + Index("idx_org_api_keys_org", "organization_id"), + Index("idx_org_api_keys_hash", "key_hash"), + ) + + +class WebhookEndpoint(Base): + """Multiple webhook endpoints per user (Premium feature).""" + __tablename__ = "webhook_endpoints" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + name = Column(String(100), nullable=False) + url = Column(String(500), nullable=False) + secret = Column(String(64), nullable=False) + is_active = Column(Boolean, default=True) + + # Per-event filtering (JSON array of event types, null = all events) + event_filter = Column(JSON, nullable=True) + + # Custom payload template (Jinja2-style, null = default format) + payload_template = Column(Text, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + user = relationship("User", backref="webhook_endpoints") + deliveries = relationship("WebhookDelivery", back_populates="endpoint", cascade="all, delete-orphan") + + __table_args__ = ( + Index("idx_webhook_endpoints_user", "user_id"), + ) + + +class WebhookDelivery(Base): + """Delivery log for webhook events with retry tracking.""" + __tablename__ = "webhook_deliveries" + + id = Column(Integer, primary_key=True, index=True) + endpoint_id = Column(Integer, ForeignKey("webhook_endpoints.id", ondelete="CASCADE"), nullable=False) + event_type = Column(String(50), nullable=False) + payload = Column(Text, nullable=False) + + # Delivery status + status = Column(String(20), nullable=False, default="pending") # pending, success, failed + status_code = Column(Integer, nullable=True) + response_body = Column(Text, nullable=True) + error_message = Column(Text, nullable=True) + + # Retry tracking + attempt = Column(SmallInteger, default=1) + max_attempts = Column(SmallInteger, default=6) # 1 initial + 5 retries + next_retry_at = Column(DateTime(timezone=True), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + delivered_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + endpoint = relationship("WebhookEndpoint", back_populates="deliveries") + + __table_args__ = ( + Index("idx_webhook_deliveries_endpoint", "endpoint_id"), + Index("idx_webhook_deliveries_status", "status"), + Index("idx_webhook_deliveries_retry", "status", "next_retry_at"), + ) + + +# ============== A/B TESTING (PREM-2) ============== + +class ExperimentStatus(str, enum.Enum): + DRAFT = "draft" + RUNNING = "running" + PAUSED = "paused" + COMPLETED = "completed" + + +class ABExperiment(Base): + """An A/B test experiment for an addon version rollout.""" + __tablename__ = "ab_experiments" + + id = Column(Integer, primary_key=True, index=True) + addon_id = Column(Integer, ForeignKey("addons.id", ondelete="CASCADE"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + status = Column( + SQLEnum(ExperimentStatus, values_callable=lambda x: [e.value for e in x]), + nullable=False, + default=ExperimentStatus.DRAFT, + ) + + # Targeting rules (JSON: {"server_ids": [...], "user_ids": [...], "metadata_match": {...}}) + targeting_rules = Column(JSON, nullable=True) + + # Auto-promotion: if no error reports within this window, promote winning variant + auto_promote = Column(Boolean, default=False) + auto_promote_after_hours = Column(Integer, default=24) + + started_at = Column(DateTime(timezone=True), nullable=True) + ended_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + addon = relationship("Addon", backref="experiments") + created_by = relationship("User") + variants = relationship("ABVariant", back_populates="experiment", cascade="all, delete-orphan") + + __table_args__ = ( + Index("idx_ab_experiments_addon", "addon_id"), + Index("idx_ab_experiments_status", "addon_id", "status"), + ) + + +class ABVariant(Base): + """A variant (group) within an A/B experiment.""" + __tablename__ = "ab_variants" + + id = Column(Integer, primary_key=True, index=True) + experiment_id = Column(Integer, ForeignKey("ab_experiments.id", ondelete="CASCADE"), nullable=False) + version_id = Column(Integer, ForeignKey("versions.id", ondelete="SET NULL"), nullable=True) + + name = Column(String(100), nullable=False) # e.g. "Control", "Variant A" + percentage = Column(Integer, nullable=False) # Traffic allocation 0-100 + is_control = Column(Boolean, default=False) + + # Metrics + total_checks = Column(Integer, default=0) + error_reports = Column(Integer, default=0) + unique_users = Column(Integer, default=0) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + experiment = relationship("ABExperiment", back_populates="variants") + version = relationship("Version") + + __table_args__ = ( + Index("idx_ab_variants_experiment", "experiment_id"), + ) diff --git a/plexaddons-api/app/schemas/__init__.py b/plexaddons-api/app/schemas/__init__.py index 32a0635..0886bc2 100644 --- a/plexaddons-api/app/schemas/__init__.py +++ b/plexaddons-api/app/schemas/__init__.py @@ -1,10 +1,13 @@ from pydantic import BaseModel, EmailStr, Field, field_validator -from typing import Optional, List +from typing import Dict, Optional, List from datetime import datetime, date import json from app.models import ( SubscriptionTier, SubscriptionStatus, PaymentProvider, - TicketStatus, TicketPriority, TicketCategory, AddonTag, OrganizationRole + TicketStatus, TicketPriority, TicketCategory, AddonTag, OrganizationRole, + ReleaseChannel, CollaboratorRole, LicenseStatus, + AlertNotificationChannel, AlertComparison, ScanStatus, + RolloutStage, RolloutStatus, ) @@ -63,6 +66,7 @@ class UserResponse(BaseModel): accent_color: Optional[str] = None # API key (only shows if exists, not the actual key) has_api_key: bool = False + is_verified_developer: bool = False # Temporary tier info temp_tier: Optional[SubscriptionTier] = None temp_tier_expires_at: Optional[datetime] = None @@ -122,6 +126,7 @@ class UserPublicProfile(BaseModel): badges: Optional[List[str]] = None banner_url: Optional[str] = None accent_color: Optional[str] = None + is_verified_developer: bool = False created_at: datetime addons: Optional[List["AddonResponse"]] = None # Only if show_addons=True @@ -141,19 +146,6 @@ class Config: # ============ API Key Schemas ============ -class ApiKeyCreate(BaseModel): - """Response when creating a new API key""" - api_key: str # Full key, only shown once - created_at: datetime - - -class ApiKeyResponse(BaseModel): - """API key info without the actual key""" - has_api_key: bool - created_at: Optional[datetime] = None - # Show masked key like pa_xxxx...xxxx - masked_key: Optional[str] = None - # ============ Webhook Schemas ============ @@ -220,6 +212,8 @@ class AddonBase(BaseModel): homepage: Optional[str] = None external: bool = False tags: Optional[List[AddonTag]] = Field(default_factory=list) + icon_url: Optional[str] = Field(None, max_length=500) + readme: Optional[str] = None class AddonCreate(AddonBase): @@ -235,6 +229,18 @@ class AddonUpdate(BaseModel): is_public: Optional[bool] = None verified: Optional[bool] = None # Admin only tags: Optional[List[AddonTag]] = None + icon_url: Optional[str] = Field(None, max_length=500) + readme: Optional[str] = None + banner_url: Optional[str] = Field(None, max_length=500) + screenshots: Optional[List[str]] = Field(None, max_items=6) + # Theme customization (Pro+) + theme_accent_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + theme_header_url: Optional[str] = Field(None, max_length=500) + # Marketplace (Premium) + is_paid: Optional[bool] = None + price_cents: Optional[int] = Field(None, ge=100, le=1000000) # $1.00 - $10,000.00 + revenue_split_percent: Optional[int] = Field(None, ge=50, le=90) + sponsor_url: Optional[str] = Field(None, max_length=500) class AddonResponse(BaseModel): @@ -250,15 +256,28 @@ class AddonResponse(BaseModel): owner_id: int organization_id: Optional[int] = None tags: List[AddonTag] = Field(default_factory=list) + icon_url: Optional[str] = None + readme: Optional[str] = None + banner_url: Optional[str] = None + screenshots: List[str] = Field(default_factory=list) + theme_accent_color: Optional[str] = None + theme_header_url: Optional[str] = None + # Marketplace + is_paid: bool = False + price_cents: Optional[int] = None + revenue_split_percent: int = 90 + sponsor_url: Optional[str] = None created_at: datetime updated_at: datetime # Denormalized for convenience owner_username: Optional[str] = None + owner_verified_developer: bool = False owner_discord_id: Optional[str] = None latest_version: Optional[str] = None latest_release_date: Optional[date] = None version_count: int = 0 + download_count: int = 0 # Total version checks / downloads class Config: from_attributes = True @@ -287,6 +306,7 @@ class VersionCreate(VersionBase): release_date: Optional[date] = None scheduled_release_at: Optional[datetime] = None # Pro+ feature rollout_percentage: int = Field(default=100, ge=0, le=100) # Premium feature + channel: ReleaseChannel = ReleaseChannel.STABLE # Pro+ feature class VersionUpdate(BaseModel): @@ -298,6 +318,12 @@ class VersionUpdate(BaseModel): rollout_percentage: Optional[int] = Field(default=None, ge=0, le=100) # Premium feature breaking: Optional[bool] = None urgent: Optional[bool] = None + channel: Optional[ReleaseChannel] = None # Pro+ feature + + +class VersionDeprecate(BaseModel): + """Schema for deprecating a version.""" + reason: str = Field(..., min_length=1, max_length=500) class VersionResponse(BaseModel): @@ -316,6 +342,11 @@ class VersionResponse(BaseModel): # Pro+ features scheduled_release_at: Optional[datetime] = None is_published: bool = True + channel: ReleaseChannel = ReleaseChannel.STABLE + # Deprecation + is_deprecated: bool = False + deprecation_reason: Optional[str] = None + deprecated_at: Optional[datetime] = None # Premium features rollout_percentage: int = 100 @@ -605,12 +636,14 @@ class OrganizationUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = None avatar_url: Optional[str] = None + banner_url: Optional[str] = None class OrganizationMemberResponse(BaseModel): id: int user_id: int role: OrganizationRole + permissions: Optional[Dict[str, bool]] = None joined_at: datetime # User info discord_username: Optional[str] = None @@ -626,6 +659,7 @@ class OrganizationResponse(BaseModel): slug: str description: Optional[str] = None avatar_url: Optional[str] = None + banner_url: Optional[str] = None owner_id: int created_at: datetime updated_at: datetime @@ -657,6 +691,96 @@ class UpdateMemberRoleRequest(BaseModel): role: OrganizationRole +class UpdateMemberPermissionsRequest(BaseModel): + permissions: Dict[str, bool] = Field( + ..., + description="Granular permissions: manage_versions, view_analytics, manage_billing, manage_members, manage_addons" + ) + + +ORG_PERMISSIONS = [ + "manage_versions", + "view_analytics", + "manage_billing", + "manage_members", + "manage_addons", +] + + +class OrgAuditLogResponse(BaseModel): + id: int + action: str + details: Optional[Dict] = None + user_id: Optional[int] = None + username: Optional[str] = None + ip_address: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +class OrgAuditLogListResponse(BaseModel): + logs: List[OrgAuditLogResponse] + total: int + + +class OrgApiKeyCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + scopes: List[str] = Field(default_factory=list) + expires_at: Optional[datetime] = None + + +class OrgApiKeyResponse(BaseModel): + id: int + name: str + key_prefix: str + scopes: List[str] = [] + is_active: bool + last_used_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + created_by_id: Optional[int] = None + created_by_username: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +class OrgApiKeyCreateResponse(OrgApiKeyResponse): + """Returned only on creation — includes the full key.""" + key: str + + +class OrgApiKeyListResponse(BaseModel): + api_keys: List[OrgApiKeyResponse] + total: int + + +class OrgAnalyticsSummary(BaseModel): + total_downloads: int = 0 + total_version_checks: int = 0 + total_unique_users: int = 0 + addon_count: int = 0 + member_count: int = 0 + storage_used_bytes: int = 0 + top_addons: List[Dict] = [] + + +class OrgPublicPageResponse(BaseModel): + id: int + name: str + slug: str + description: Optional[str] = None + avatar_url: Optional[str] = None + banner_url: Optional[str] = None + owner_username: Optional[str] = None + member_count: int = 0 + addon_count: int = 0 + addons: List[AddonResponse] = [] + created_at: datetime + + # ============== Tag Schema ============== class TagListResponse(BaseModel): @@ -723,6 +847,694 @@ class AvailableScopesResponse(BaseModel): max_keys: int +# ============ Star / Favorite Schemas ============ + +class StarResponse(BaseModel): + """Star status for an addon""" + starred: bool + star_count: int + + +# ============ Review Schemas ============ + +class ReviewCreate(BaseModel): + """Create a review for an addon""" + rating: int = Field(..., ge=1, le=5) + title: Optional[str] = Field(None, max_length=200) + content: Optional[str] = Field(None, max_length=2000) + + +class ReviewUpdate(BaseModel): + """Update an existing review""" + rating: Optional[int] = Field(None, ge=1, le=5) + title: Optional[str] = Field(None, max_length=200) + content: Optional[str] = Field(None, max_length=2000) + + +class ReviewResponse(BaseModel): + id: int + user_id: int + addon_id: int + rating: int + title: Optional[str] = None + content: Optional[str] = None + is_visible: bool = True + created_at: datetime + updated_at: datetime + # Denormalized author info + author_username: Optional[str] = None + author_avatar: Optional[str] = None + author_discord_id: Optional[str] = None + + class Config: + from_attributes = True + + +class ReviewListResponse(BaseModel): + reviews: List[ReviewResponse] + total: int + average_rating: Optional[float] = None + rating_distribution: Optional[dict] = None # {1: count, 2: count, ...} + + +# ============ Notification Schemas ============ + +class NotificationResponse(BaseModel): + id: int + type: str + title: str + message: Optional[str] = None + link: Optional[str] = None + is_read: bool = False + created_at: datetime + + class Config: + from_attributes = True + + +class NotificationListResponse(BaseModel): + notifications: List[NotificationResponse] + total: int + unread_count: int + + +# ============== Collaborator Schemas ============== + +class CollaboratorInvite(BaseModel): + user_id: int + role: CollaboratorRole = CollaboratorRole.EDITOR + +class CollaboratorUpdate(BaseModel): + role: CollaboratorRole + +class CollaboratorResponse(BaseModel): + id: int + addon_id: int + user_id: int + role: CollaboratorRole + accepted: bool + username: Optional[str] = None + display_name: Optional[str] = None + avatar: Optional[str] = None + invited_by_id: Optional[int] = None + created_at: datetime + accepted_at: Optional[datetime] = None + + class Config: + from_attributes = True + +class TransferOwnershipRequest(BaseModel): + new_owner_id: int + + +# ============== Marketplace / License Schemas (PREM-13) ============== + +class AddonPricingUpdate(BaseModel): + """Update pricing for a paid addon.""" + is_paid: bool + price_cents: Optional[int] = Field(None, ge=100, le=1000000) + revenue_split_percent: int = Field(90, ge=50, le=90) + +class LicenseResponse(BaseModel): + id: int + addon_id: int + buyer_id: Optional[int] = None + license_key: str + amount_cents: int + developer_amount_cents: int + platform_amount_cents: int + status: LicenseStatus + server_id: Optional[str] = None + expires_at: Optional[datetime] = None + created_at: datetime + revoked_at: Optional[datetime] = None +# ============== Self-Hosted Config Schemas (PREM-15) ============== + +class SelfHostedConfigCreate(BaseModel): + custom_domain: Optional[str] = Field(None, max_length=255) + private_endpoint_enabled: bool = True + api_key_required: bool = True + rate_limit_per_minute: int = Field(60, ge=1, le=600) + +class SelfHostedConfigUpdate(BaseModel): + custom_domain: Optional[str] = Field(None, max_length=255) + private_endpoint_enabled: Optional[bool] = None + api_key_required: Optional[bool] = None + rate_limit_per_minute: Optional[int] = Field(None, ge=1, le=600) + +class SelfHostedConfigResponse(BaseModel): + id: int + addon_id: int + custom_domain: Optional[str] = None + domain_verified: bool = False + verification_token: Optional[str] = None + private_endpoint_enabled: bool = True + api_key_required: bool = True + rate_limit_per_minute: int = 60 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class LicenseListResponse(BaseModel): + licenses: List[LicenseResponse] + total: int + +class LicenseVerifyRequest(BaseModel): + license_key: str = Field(..., min_length=1, max_length=64) + server_id: Optional[str] = Field(None, max_length=100) + +class LicenseVerifyResponse(BaseModel): + valid: bool + addon_id: Optional[int] = None + addon_slug: Optional[str] = None + status: Optional[LicenseStatus] = None + expires_at: Optional[datetime] = None + +class PurchaseAddonRequest(BaseModel): + """Request to purchase a paid addon.""" + server_id: Optional[str] = Field(None, max_length=100) + +class PurchaseAddonResponse(BaseModel): + checkout_url: str + session_id: str + message: str = "Redirecting to payment" + +class StripeConnectOnboardRequest(BaseModel): + """Request to start Stripe Connect onboarding.""" + return_url: str + refresh_url: str + +class StripeConnectStatusResponse(BaseModel): + has_connect_account: bool + account_id: Optional[str] = None + payouts_enabled: bool = False + onboarding_complete: bool = False + +class RevenueStatsResponse(BaseModel): + total_sales: int + total_revenue_cents: int + developer_earnings_cents: int + platform_fees_cents: int + active_licenses: int + + +# ============== Sponsorship Schemas (PREM-14) ============== + +class SponsorUrlUpdate(BaseModel): + sponsor_url: Optional[str] = Field(None, max_length=500) + +# ============== Analytics Alert Schemas (PREM-17) ============== + +class AnalyticsAlertCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + metric: str = Field(..., pattern=r"^(daily_checks|unique_users|error_rate)$") + comparison: AlertComparison + threshold: int = Field(..., ge=0) + notification_channel: AlertNotificationChannel + webhook_url: Optional[str] = Field(None, max_length=500) + email: Optional[str] = Field(None, max_length=320) + discord_webhook_url: Optional[str] = Field(None, max_length=500) + cooldown_minutes: int = Field(60, ge=5, le=1440) + +class AnalyticsAlertUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + metric: Optional[str] = Field(None, pattern=r"^(daily_checks|unique_users|error_rate)$") + comparison: Optional[AlertComparison] = None + threshold: Optional[int] = Field(None, ge=0) + notification_channel: Optional[AlertNotificationChannel] = None + webhook_url: Optional[str] = Field(None, max_length=500) + email: Optional[str] = Field(None, max_length=320) + discord_webhook_url: Optional[str] = Field(None, max_length=500) + is_active: Optional[bool] = None + cooldown_minutes: Optional[int] = Field(None, ge=5, le=1440) + +class AnalyticsAlertResponse(BaseModel): + id: int + addon_id: int + name: str + metric: str + comparison: AlertComparison + threshold: int + notification_channel: AlertNotificationChannel + webhook_url: Optional[str] = None + email: Optional[str] = None + discord_webhook_url: Optional[str] = None + is_active: bool = True + last_triggered_at: Optional[datetime] = None + trigger_count: int = 0 + cooldown_minutes: int = 60 + + model_config = {"from_attributes": True} + +class AnalyticsAlertListResponse(BaseModel): + alerts: List[AnalyticsAlertResponse] + total: int + + +# ============== Code Signing Schemas (PREM-5) ========================== + +class SigningKeyCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + public_key: str = Field(..., min_length=10) + algorithm: str = Field(default="ed25519", pattern=r'^(ed25519|rsa|ecdsa)$') + +class SigningKeyResponse(BaseModel): + id: int + addon_id: int + created_by_id: Optional[int] = None + name: str + public_key: str + key_fingerprint: str + algorithm: str + is_active: bool + revoked_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class SigningKeyListResponse(BaseModel): + keys: List[SigningKeyResponse] + total: int + +class VersionSignatureCreate(BaseModel): + signing_key_id: int + signature: str = Field(..., min_length=1) + signed_hash: str = Field(..., min_length=64, max_length=128) + +class VersionSignatureResponse(BaseModel): + id: int + version_id: int + signing_key_id: Optional[int] = None + signature: str + signed_hash: str + verified: bool + verified_at: Optional[datetime] = None + created_at: datetime + + class Config: + from_attributes = True + +class VersionSignatureVerifyRequest(BaseModel): + artifact_hash: str = Field(..., min_length=64, max_length=128) + + +# ============== Cohort Analysis Schemas (PREM-18) ========================== + +class CohortSummary(BaseModel): + from_version: str + to_version: str + user_count: int + first_transition: datetime + last_transition: datetime + +class CohortAnalysisResponse(BaseModel): + addon_id: int + period_days: int + cohorts: List[CohortSummary] + total_transitions: int + + +# ============== Predictive Analytics Schemas (PREM-19) ============== + +class PredictiveEstimate(BaseModel): + addon_id: int + target_version: str + current_adoption_percent: float + daily_adoption_rate: float + estimated_days_to_50: Optional[int] = None + estimated_days_to_90: Optional[int] = None + estimated_days_to_100: Optional[int] = None + total_users: int + adopted_users: int + + +# ============== Real-Time Analytics Schemas (PREM-16) ============== + +class RealtimeEvent(BaseModel): + addon_id: int + addon_name: str + version: str + timestamp: datetime + client_ip_hash: Optional[str] = None + +class RealtimeStats(BaseModel): + addon_id: int + checks_last_hour: int + checks_last_24h: int + unique_users_last_hour: int + active_versions: int + top_version: Optional[str] = None + + +# ============== Vulnerability Scanning Schemas (PREM-6) ========================== + +class VulnerabilityScanResponse(BaseModel): + id: int + version_id: int + initiated_by_id: Optional[int] = None + status: ScanStatus + vulnerabilities: Optional[list] = None + total_vulnerabilities: int + critical_count: int + high_count: int + medium_count: int + low_count: int + scan_started_at: Optional[datetime] = None + scan_completed_at: Optional[datetime] = None + error_message: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + +class VulnerabilityScanListResponse(BaseModel): + scans: List[VulnerabilityScanResponse] + total: int + + +# ============== SBOM Schemas (PREM-7) ============== + +class SBOMUpload(BaseModel): + format: str = Field(default="npm", pattern=r'^(npm|pip|maven|gradle|cargo|go)$') + raw_content: str = Field(..., min_length=1) + +class SBOMResponse(BaseModel): + id: int + version_id: int + uploaded_by_id: Optional[int] = None + format: str + dependencies: Optional[list] = None + total_dependencies: int + direct_dependencies: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class SBOMListResponse(BaseModel): + sboms: List[SBOMResponse] + total: int + + +# ============== IP Allowlist Schemas (PREM-8) ============== + +class ApiKeyIPAllowlistUpdate(BaseModel): + ip_allowlist: Optional[List[str]] = Field( + default=None, + description="List of CIDR ranges, e.g. ['192.168.1.0/24', '10.0.0.1/32']" + ) + + @field_validator('ip_allowlist') + @classmethod + def validate_cidrs(cls, v): + if v is None: + return v + import ipaddress + for cidr in v: + try: + ipaddress.ip_network(cidr, strict=False) + except ValueError: + raise ValueError(f"Invalid CIDR range: {cidr}") + if len(v) > 50: + raise ValueError("Maximum 50 CIDR ranges allowed") + return v + + +# ============== 2FA Challenge Schemas (PREM-9) ============== + +class TwoFactorChallengeRequest(BaseModel): + action: str = Field(..., pattern=r'^(delete_addon|revoke_key|change_payment|delete_account)$') + +class TwoFactorChallengeResponse(BaseModel): + challenge_id: int + action: str + expires_at: datetime + message: str + +class TwoFactorVerifyRequest(BaseModel): + challenge_id: int + code: str = Field(..., min_length=6, max_length=6) + +class TwoFactorVerifyResponse(BaseModel): + verified: bool + message: str +# ============== Staged Rollout Schemas ============== + +class StagedRolloutCreate(BaseModel): + version_id: int + targeting_rules: Optional[dict] = None + auto_promote: bool = False + auto_promote_after_hours: int = Field(default=24, ge=1, le=720) + +class StagedRolloutUpdate(BaseModel): + targeting_rules: Optional[dict] = None + auto_promote: Optional[bool] = None + auto_promote_after_hours: Optional[int] = Field(default=None, ge=1, le=720) + +class StagedRolloutResponse(BaseModel): + id: int + addon_id: int + version_id: int + created_by_id: Optional[int] = None + stage: RolloutStage + percentage: int + status: RolloutStatus + targeting_rules: Optional[dict] = None + auto_promote: bool + auto_promote_after_hours: int + total_checks: int + error_reports: int + created_at: datetime + updated_at: datetime + promoted_at: Optional[datetime] = None + events: List["RolloutEventResponse"] = [] + + class Config: + from_attributes = True + +class StagedRolloutListResponse(BaseModel): + rollouts: List[StagedRolloutResponse] + total: int + +class RolloutPromoteRequest(BaseModel): + """Promote to specific stage, or omit to advance to next stage.""" + target_stage: Optional[RolloutStage] = None + +class RolloutEventResponse(BaseModel): + id: int + rollout_id: int + from_stage: Optional[str] = None + to_stage: str + from_percentage: Optional[int] = None + to_percentage: int + triggered_by: str + user_id: Optional[int] = None + created_at: datetime + + class Config: + from_attributes = True + + +# ============== Feature Flag Schemas ============== + +class FeatureFlagCreate(BaseModel): + key: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_.-]+$') + name: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + enabled: bool = False + percentage: int = Field(default=100, ge=0, le=100) + targeting: Optional[dict] = None + +class FeatureFlagUpdate(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=200) + description: Optional[str] = None + enabled: Optional[bool] = None + percentage: Optional[int] = Field(default=None, ge=0, le=100) + targeting: Optional[dict] = None + +class FeatureFlagResponse(BaseModel): + id: int + addon_id: int + created_by_id: Optional[int] = None + key: str + name: str + description: Optional[str] = None + enabled: bool + percentage: int + targeting: Optional[dict] = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + +class FeatureFlagListResponse(BaseModel): + flags: List[FeatureFlagResponse] + total: int + +class FeatureFlagEvaluateRequest(BaseModel): + server_id: Optional[str] = None + user_id: Optional[str] = None + +class FeatureFlagEvaluateResponse(BaseModel): + key: str + enabled: bool + + +# ============ Webhook Endpoint Schemas (Premium) ============ + +VALID_WEBHOOK_EVENTS = [ + "version.released", "version.updated", "version.deleted", + "addon.created", "addon.updated", "addon.deleted", +] + +class WebhookEndpointCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + url: str = Field(..., max_length=500) + is_active: bool = True + event_filter: Optional[List[str]] = None + payload_template: Optional[str] = Field(None, max_length=5000) + + @field_validator('url') + @classmethod + def validate_url(cls, v: str) -> str: + if not v.startswith(('http://', 'https://')): + raise ValueError('URL must start with http:// or https://') + return v + + @field_validator('event_filter') + @classmethod + def validate_events(cls, v: Optional[List[str]]) -> Optional[List[str]]: + if v is not None: + for e in v: + if e not in VALID_WEBHOOK_EVENTS: + raise ValueError(f'Invalid event type: {e}') + return v + + +class WebhookEndpointUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + url: Optional[str] = Field(None, max_length=500) + is_active: Optional[bool] = None + event_filter: Optional[List[str]] = None + payload_template: Optional[str] = Field(None, max_length=5000) + + @field_validator('url') + @classmethod + def validate_url(cls, v: Optional[str]) -> Optional[str]: + if v is not None and not v.startswith(('http://', 'https://')): + raise ValueError('URL must start with http:// or https://') + return v + + @field_validator('event_filter') + @classmethod + def validate_events(cls, v: Optional[List[str]]) -> Optional[List[str]]: + if v is not None: + for e in v: + if e not in VALID_WEBHOOK_EVENTS: + raise ValueError(f'Invalid event type: {e}') + return v + + +class WebhookEndpointResponse(BaseModel): + id: int + name: str + url: str + is_active: bool + event_filter: Optional[List[str]] = None + payload_template: Optional[str] = None + has_secret: bool = True + masked_secret: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + +class WebhookDeliveryResponse(BaseModel): + id: int + endpoint_id: int + event_type: str + status: str + status_code: Optional[int] = None + error_message: Optional[str] = None + attempt: int + max_attempts: int + next_retry_at: Optional[datetime] = None + created_at: datetime + delivered_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# ============== A/B TESTING (PREM-2) ============== + +class ABVariantCreate(BaseModel): + name: str = Field(..., max_length=100) + version_id: Optional[int] = None + percentage: int = Field(..., ge=0, le=100) + is_control: bool = False + +class ABExperimentCreate(BaseModel): + name: str = Field(..., max_length=200) + description: Optional[str] = None + targeting_rules: Optional[dict] = None + auto_promote: bool = False + auto_promote_after_hours: int = Field(24, ge=1, le=720) + variants: list[ABVariantCreate] = Field(..., min_length=2, max_length=10) + +class ABExperimentUpdate(BaseModel): + name: Optional[str] = Field(None, max_length=200) + description: Optional[str] = None + targeting_rules: Optional[dict] = None + auto_promote: Optional[bool] = None + auto_promote_after_hours: Optional[int] = Field(None, ge=1, le=720) + +class ABVariantResponse(BaseModel): + id: int + experiment_id: int + version_id: Optional[int] = None + name: str + percentage: int + is_control: bool + total_checks: int + error_reports: int + unique_users: int + created_at: datetime + + class Config: + from_attributes = True + +class ABExperimentResponse(BaseModel): + id: int + addon_id: int + created_by_id: Optional[int] = None + name: str + description: Optional[str] = None + status: str + targeting_rules: Optional[dict] = None + auto_promote: bool + auto_promote_after_hours: int + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + variants: list[ABVariantResponse] = [] + + class Config: + from_attributes = True + +class ABExperimentListResponse(BaseModel): + experiments: list[ABExperimentResponse] + total: int + + # Forward reference resolution +StagedRolloutResponse.model_rebuild() AuthResponse.model_rebuild() UserPublicProfile.model_rebuild() diff --git a/plexaddons-api/app/scripts/seed_data.py b/plexaddons-api/app/scripts/seed_data.py new file mode 100644 index 0000000..ab828ba --- /dev/null +++ b/plexaddons-api/app/scripts/seed_data.py @@ -0,0 +1,215 @@ +""" +Seed data script for PlexAddons development database. + +Usage: + cd plexaddons-api + python -m app.scripts.seed_data + +Populates the database with sample users, addons, and versions +for local development and testing. +""" +import asyncio +import os +import sys +from datetime import datetime, timezone, timedelta, date + +# Ensure env vars are set for local dev +os.environ.setdefault("SECRET_KEY", "dev-secret-key-change-in-production") +os.environ.setdefault("DATABASE_URL", "postgresql://plexaddons:plexaddons@localhost:5432/plexaddons") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("DISCORD_CLIENT_ID", "dev") +os.environ.setdefault("DISCORD_CLIENT_SECRET", "dev") +os.environ.setdefault("DISCORD_REDIRECT_URI", "http://localhost:3000/auth/callback") +os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_dev") +os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "whsec_dev") +os.environ.setdefault("STRIPE_PRO_PRICE_ID", "price_dev_pro") +os.environ.setdefault("STRIPE_PREMIUM_PRICE_ID", "price_dev_premium") +os.environ.setdefault("PAYPAL_CLIENT_ID", "dev") +os.environ.setdefault("PAYPAL_CLIENT_SECRET", "dev") +os.environ.setdefault("PAYPAL_WEBHOOK_ID", "dev") +os.environ.setdefault("PAYPAL_PRO_PLAN_ID", "P-dev") +os.environ.setdefault("PAYPAL_PREMIUM_PLAN_ID", "P-dev") +os.environ.setdefault("EMAIL_ENABLED", "false") + +from app.database import AsyncSessionLocal, engine, Base +from app.models import ( + User, Addon, Version, SubscriptionTier, AddonTag, +) +from app.utils import slugify + + +SAMPLE_USERS = [ + { + "discord_id": "100000000000000001", + "discord_username": "devadmin", + "email": "admin@plexdev.local", + "subscription_tier": SubscriptionTier.PREMIUM, + "is_admin": True, + "bio": "PlexAddons platform administrator.", + "is_verified_developer": True, + }, + { + "discord_id": "100000000000000002", + "discord_username": "prouser", + "email": "pro@plexdev.local", + "subscription_tier": SubscriptionTier.PRO, + "is_admin": False, + "bio": "Pro tier developer building cool addons.", + }, + { + "discord_id": "100000000000000003", + "discord_username": "freeuser", + "email": "free@plexdev.local", + "subscription_tier": SubscriptionTier.FREE, + "is_admin": False, + }, +] + +SAMPLE_ADDONS = [ + { + "owner_idx": 0, # devadmin + "name": "Auto Moderator", + "description": "Automatically moderate your server with AI-powered content filtering.", + "tags": [AddonTag.MODERATION.value, AddonTag.AUTOMATION.value], + "is_public": True, + "verified": True, + "versions": [ + {"version": "1.0.0", "description": "Initial release", "days_ago": 30}, + {"version": "1.1.0", "description": "Added word filters", "days_ago": 20}, + {"version": "1.2.0", "description": "AI content analysis", "days_ago": 5}, + ], + }, + { + "owner_idx": 0, # devadmin + "name": "Music Bot Pro", + "description": "High quality music streaming from YouTube, Spotify and SoundCloud.", + "tags": [AddonTag.MUSIC.value, AddonTag.MEDIA.value], + "is_public": True, + "verified": True, + "versions": [ + {"version": "2.0.0", "description": "Major rewrite with queue system", "days_ago": 60}, + {"version": "2.1.0", "description": "Spotify integration", "days_ago": 15}, + ], + }, + { + "owner_idx": 1, # prouser + "name": "Level System", + "description": "XP and leveling with role rewards and leaderboards.", + "tags": [AddonTag.LEVELING.value, AddonTag.FUN.value], + "is_public": True, + "verified": False, + "versions": [ + {"version": "1.0.0", "description": "Basic XP and levels", "days_ago": 45}, + {"version": "1.0.1", "description": "Bug fixes", "days_ago": 40}, + {"version": "1.1.0", "description": "Leaderboard API", "days_ago": 10}, + ], + }, + { + "owner_idx": 1, # prouser + "name": "Economy Plus", + "description": "Virtual currency system with shops, trading, and gambling.", + "tags": [AddonTag.ECONOMY.value, AddonTag.FUN.value], + "is_public": True, + "verified": False, + "versions": [ + {"version": "0.9.0", "description": "Beta release", "days_ago": 25}, + {"version": "1.0.0", "description": "Stable release with shops", "days_ago": 7}, + ], + }, + { + "owner_idx": 2, # freeuser + "name": "Simple Logger", + "description": "Log server events to a channel with customizable formats.", + "tags": [AddonTag.LOGGING.value, AddonTag.UTILITY.value], + "is_public": True, + "verified": False, + "versions": [ + {"version": "1.0.0", "description": "Initial release", "days_ago": 14}, + ], + }, + { + "owner_idx": 2, # freeuser + "name": "Private Addon", + "description": "A private addon for testing.", + "tags": [AddonTag.OTHER.value], + "is_public": False, + "verified": False, + "versions": [ + {"version": "0.1.0", "description": "Draft", "days_ago": 3}, + ], + }, +] + + +async def seed(): + print("Creating database tables...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with AsyncSessionLocal() as db: + # Check if data already exists + from sqlalchemy import select, func as sa_func + result = await db.execute(select(sa_func.count()).select_from(User)) + count = result.scalar() + if count > 0: + print(f"Database already has {count} user(s). Skipping seed.") + print("To re-seed, truncate the tables first.") + return + + print("Seeding users...") + users = [] + for data in SAMPLE_USERS: + user = User( + discord_id=data["discord_id"], + discord_username=data["discord_username"], + email=data.get("email"), + subscription_tier=data["subscription_tier"], + is_admin=data.get("is_admin", False), + bio=data.get("bio"), + is_verified_developer=data.get("is_verified_developer", False), + ) + db.add(user) + users.append(user) + await db.flush() + + print("Seeding addons and versions...") + now = datetime.now(timezone.utc) + for addon_data in SAMPLE_ADDONS: + owner = users[addon_data["owner_idx"]] + addon = Addon( + owner_id=owner.id, + name=addon_data["name"], + slug=slugify(addon_data["name"]), + description=addon_data["description"], + tags=addon_data["tags"], + is_public=addon_data["is_public"], + verified=addon_data["verified"], + is_active=True, + ) + db.add(addon) + await db.flush() + + for ver_data in addon_data["versions"]: + release = date.today() - timedelta(days=ver_data["days_ago"]) + version = Version( + addon_id=addon.id, + version=ver_data["version"], + release_date=release, + download_url=f"https://example.com/downloads/{addon.slug}/{ver_data['version']}.zip", + description=ver_data["description"], + ) + db.add(version) + + print(f" + {addon.name} ({len(addon_data['versions'])} versions)") + + await db.commit() + + print(f"\nSeed complete!") + print(f" Users: {len(SAMPLE_USERS)}") + print(f" Addons: {len(SAMPLE_ADDONS)}") + print(f" Versions: {sum(len(a['versions']) for a in SAMPLE_ADDONS)}") + print(f"\nAdmin login: discord_id={SAMPLE_USERS[0]['discord_id']}") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/plexaddons-api/app/services/__init__.py b/plexaddons-api/app/services/__init__.py index 2e2d3d7..ada802e 100644 --- a/plexaddons-api/app/services/__init__.py +++ b/plexaddons-api/app/services/__init__.py @@ -11,3 +11,5 @@ from app.services.discord_service import DiscordService, discord_service from app.services.analytics_service import AnalyticsService from app.services.webhook_service import WebhookService, webhook_service +from app.services.notification_service import NotificationService +from app.services.collaborator_service import CollaboratorService diff --git a/plexaddons-api/app/services/addon_service.py b/plexaddons-api/app/services/addon_service.py index 0d89850..53fa972 100644 --- a/plexaddons-api/app/services/addon_service.py +++ b/plexaddons-api/app/services/addon_service.py @@ -1,8 +1,9 @@ from typing import Optional, List from fastapi import BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ -from app.models import Addon, Version, User +from sqlalchemy import select, func, and_, literal_column, cast, String +from sqlalchemy.orm import aliased +from app.models import Addon, Version, User, AddonUsageStats from app.schemas import AddonCreate, AddonUpdate from app.utils import slugify from app.core.exceptions import NotFoundError, ConflictError, ForbiddenError @@ -124,14 +125,13 @@ async def list_addons( limit: int = 20, owner_id: Optional[int] = None, search: Optional[str] = None, + tag: Optional[str] = None, + sort_by: str = "updated", public_only: bool = True, + addon_ids: Optional[List[int]] = None, ) -> tuple[List[dict], int]: - """List addons with latest version info.""" - # Base query - query = select(Addon) - count_query = select(func.count(Addon.id)) - - # Filters + """List addons with latest version info, server-side search, tag filter, and sorting.""" + # Base filters filters = [] if public_only: filters.append(Addon.is_public == True) @@ -140,43 +140,111 @@ async def list_addons( filters.append(Addon.owner_id == owner_id) if search: safe_search = sanitize_ilike_pattern(search) - filters.append(Addon.name.ilike(f"%{safe_search}%")) + # Search across name and description + filters.append( + func.concat(Addon.name, ' ', func.coalesce(Addon.description, '')).ilike(f"%{safe_search}%") + ) + if tag: + # Filter by tag - tags is stored as a PostgreSQL JSON column, cast to text for LIKE + safe_tag = sanitize_ilike_pattern(tag) + filters.append(cast(Addon.tags, String).ilike(f'%"{safe_tag}"%')) + if addon_ids is not None: + filters.append(Addon.id.in_(addon_ids)) + # Get total count + count_query = select(func.count(Addon.id)) if filters: - query = query.where(and_(*filters)) count_query = count_query.where(and_(*filters)) - - # Get total count total_result = await db.execute(count_query) total = total_result.scalar() or 0 - # Get addons - query = query.order_by(Addon.updated_at.desc()).offset(skip).limit(limit) - result = await db.execute(query) - addons = result.scalars().all() + # Subquery for latest version per addon + latest_version_sq = ( + select( + Version.addon_id, + Version.version.label("latest_version"), + Version.release_date.label("latest_release_date"), + func.row_number().over( + partition_by=Version.addon_id, + order_by=[Version.release_date.desc(), Version.created_at.desc()] + ).label("rn") + ) + .subquery("latest_v") + ) - # Enrich with latest version and owner info - enriched_addons = [] - for addon in addons: - # Get owner - owner_result = await db.execute(select(User).where(User.id == addon.owner_id)) - owner = owner_result.scalar_one_or_none() - - # Get latest version - latest_version_result = await db.execute( - select(Version) - .where(Version.addon_id == addon.id) - .order_by(Version.release_date.desc(), Version.created_at.desc()) - .limit(1) + # Subquery for version count per addon + version_count_sq = ( + select( + Version.addon_id, + func.count(Version.id).label("version_count") ) - latest_version = latest_version_result.scalar_one_or_none() - - # Get version count - version_count_result = await db.execute( - select(func.count(Version.id)).where(Version.addon_id == addon.id) + .group_by(Version.addon_id) + .subquery("v_count") + ) + + # Subquery for download count per addon (sum of check_count from usage stats) + download_count_sq = ( + select( + AddonUsageStats.addon_id, + func.coalesce(func.sum(AddonUsageStats.check_count), 0).label("download_count") ) - version_count = version_count_result.scalar() or 0 - + .group_by(AddonUsageStats.addon_id) + .subquery("dl_count") + ) + + # Main query with JOINs + query = ( + select( + Addon, + User.discord_username.label("owner_username"), + User.discord_id.label("owner_discord_id"), + User.is_verified_developer.label("owner_verified_developer"), + latest_version_sq.c.latest_version, + latest_version_sq.c.latest_release_date, + func.coalesce(version_count_sq.c.version_count, 0).label("version_count"), + func.coalesce(download_count_sq.c.download_count, 0).label("download_count"), + ) + .join(User, User.id == Addon.owner_id, isouter=True) + .join( + latest_version_sq, + and_( + latest_version_sq.c.addon_id == Addon.id, + latest_version_sq.c.rn == 1, + ), + isouter=True, + ) + .join( + version_count_sq, + version_count_sq.c.addon_id == Addon.id, + isouter=True, + ) + .join( + download_count_sq, + download_count_sq.c.addon_id == Addon.id, + isouter=True, + ) + ) + + if filters: + query = query.where(and_(*filters)) + + # Sorting + sort_options = { + "newest": Addon.created_at.desc(), + "oldest": Addon.created_at.asc(), + "name_asc": Addon.name.asc(), + "name_desc": Addon.name.desc(), + "updated": Addon.updated_at.desc(), + } + order = sort_options.get(sort_by, Addon.updated_at.desc()) + + query = query.order_by(order).offset(skip).limit(limit) + result = await db.execute(query) + rows = result.all() + + enriched_addons = [] + for row in rows: + addon = row[0] enriched_addons.append({ "id": addon.id, "slug": addon.slug, @@ -185,15 +253,28 @@ async def list_addons( "homepage": addon.homepage, "external": addon.external, "tags": addon.tags or [], + "icon_url": addon.icon_url, + "readme": addon.readme, + "banner_url": addon.banner_url, + "screenshots": addon.screenshots or [], + "theme_accent_color": addon.theme_accent_color, + "theme_header_url": addon.theme_header_url, + "is_paid": addon.is_paid, + "price_cents": addon.price_cents, + "revenue_split_percent": addon.revenue_split_percent, + "sponsor_url": addon.sponsor_url, "is_active": addon.is_active, "is_public": addon.is_public, "verified": addon.verified, "owner_id": addon.owner_id, - "owner_username": owner.discord_username if owner else None, - "owner_discord_id": owner.discord_id if owner else None, - "latest_version": latest_version.version if latest_version else None, - "latest_release_date": latest_version.release_date if latest_version else None, - "version_count": version_count, + "organization_id": addon.organization_id, + "owner_username": row.owner_username, + "owner_discord_id": row.owner_discord_id, + "owner_verified_developer": row.owner_verified_developer or False, + "latest_version": row.latest_version, + "latest_release_date": row.latest_release_date, + "version_count": row.version_count, + "download_count": row.download_count, "created_at": addon.created_at, "updated_at": addon.updated_at, }) @@ -204,32 +285,49 @@ async def list_addons( async def get_all_public_addons_for_json(db: AsyncSession, client_ip_hash: str = None) -> List[dict]: """ Get all public addons with latest version for versions.json format. + Uses optimized JOINs instead of per-addon queries. Args: client_ip_hash: Optional hashed IP for A/B rollout consistency. If provided, respects rollout_percentage. """ - result = await db.execute( - select(Addon) + # Fetch all public, non-paid addons with owner info in a single query + addons_query = ( + select(Addon, User.discord_username.label("owner_username")) + .join(User, User.id == Addon.owner_id, isouter=True) .where(Addon.is_public == True) .where(Addon.is_active == True) + .where(Addon.is_paid == False) + ) + addons_result = await db.execute(addons_query) + addon_rows = addons_result.all() + + if not addon_rows: + return [] + + # Collect addon IDs + addon_ids = [row[0].id for row in addon_rows] + + # Fetch all published, non-deprecated versions for these addons in a single query + versions_result = await db.execute( + select(Version) + .where(Version.addon_id.in_(addon_ids)) + .where(Version.is_published == True) + .where(Version.is_deprecated == False) + .order_by(Version.addon_id, Version.release_date.desc(), Version.created_at.desc()) ) - addons = result.scalars().all() + all_versions = versions_result.scalars().all() + + # Group versions by addon_id + versions_by_addon: dict[int, list] = {} + for v in all_versions: + versions_by_addon.setdefault(v.addon_id, []).append(v) addon_data = [] - for addon in addons: - # Get owner - owner_result = await db.execute(select(User).where(User.id == addon.owner_id)) - owner = owner_result.scalar_one_or_none() - - # Get latest PUBLISHED version that user is eligible for - latest_version_result = await db.execute( - select(Version) - .where(Version.addon_id == addon.id) - .where(Version.is_published == True) # Only published versions - .order_by(Version.release_date.desc(), Version.created_at.desc()) - ) - versions = latest_version_result.scalars().all() + for row in addon_rows: + addon = row[0] + owner_username = row.owner_username + versions = versions_by_addon.get(addon.id, []) # Find the latest version this user is eligible for (based on rollout) latest_version = None @@ -243,12 +341,9 @@ async def get_all_public_addons_for_json(db: AsyncSession, client_ip_hash: str = if hash_value < version.rollout_percentage: latest_version = version break - elif version.rollout_percentage >= 100: - latest_version = version - break - # Fallback to first published version if no rollout match - if not latest_version and versions: + # Fallback to first fully-rolled-out published version + if not latest_version: for v in versions: if v.rollout_percentage >= 100: latest_version = v @@ -265,7 +360,7 @@ async def get_all_public_addons_for_json(db: AsyncSession, client_ip_hash: str = "breaking": latest_version.breaking, "urgent": latest_version.urgent, "external": addon.external, - "author": owner.discord_username if owner else None, + "author": owner_username, "homepage": addon.homepage, "changelog": latest_version.changelog_url, "tags": addon.tags or [], diff --git a/plexaddons-api/app/services/analytics_service.py b/plexaddons-api/app/services/analytics_service.py index 74372bf..947557c 100644 --- a/plexaddons-api/app/services/analytics_service.py +++ b/plexaddons-api/app/services/analytics_service.py @@ -7,7 +7,8 @@ from sqlalchemy import select, func, and_ from app.config import get_settings from app.models import ( - VersionCheck, AddonUsageStats, Addon, Version, User, SubscriptionTier + VersionCheck, AddonUsageStats, Addon, Version, User, SubscriptionTier, + CohortEntry, ) from app.schemas import ( AddonAnalyticsResponse, DailyStats, VersionDistribution, AnalyticsSummary @@ -32,6 +33,7 @@ async def log_version_check( version_id: Optional[int], checked_version: str, client_ip: str, + client_id: Optional[str] = None, ) -> VersionCheck: """ Log a version check request. @@ -41,8 +43,9 @@ async def log_version_check( version_id: The version ID if resolved, None if version not found checked_version: The version string provided by the client client_ip: Client IP address (will be hashed) + client_id: Optional stable client instance ID (preferred over IP hash) """ - ip_hash = AnalyticsService.hash_ip(client_ip) + ip_hash = client_id if client_id else AnalyticsService.hash_ip(client_ip) check = VersionCheck( addon_id=addon_id, @@ -54,8 +57,50 @@ async def log_version_check( await db.commit() await db.refresh(check) + # Track version transitions for cohort analysis (PREM-18) + await AnalyticsService._track_cohort_transition( + db, addon_id, checked_version, ip_hash + ) + return check + @staticmethod + async def _track_cohort_transition( + db: AsyncSession, + addon_id: int, + current_version: str, + ip_hash: str, + ): + """ + Detect version transitions for cohort analysis. + + If a user (by IP hash) previously checked a different version of + the same addon, record a cohort entry for the transition. + """ + # Find the user's most recent previous check for this addon + prev_check = await db.execute( + select(VersionCheck.checked_version) + .where( + VersionCheck.addon_id == addon_id, + VersionCheck.client_ip_hash == ip_hash, + VersionCheck.checked_version != current_version, + VersionCheck.checked_version.isnot(None), + ) + .order_by(VersionCheck.timestamp.desc()) + .limit(1) + ) + prev_version = prev_check.scalar_one_or_none() + + if prev_version and prev_version != current_version: + entry = CohortEntry( + addon_id=addon_id, + from_version=prev_version, + to_version=current_version, + client_ip_hash=ip_hash, + ) + db.add(entry) + await db.commit() + @staticmethod async def update_daily_stats( db: AsyncSession, @@ -120,6 +165,7 @@ async def get_addon_analytics( raise ValueError(f"Addon {addon_id} not found") start_date = date.today() - timedelta(days=days) + start_datetime = datetime.combine(start_date, datetime.min.time()) # Get daily stats aggregated across all versions daily_query = select( @@ -143,11 +189,10 @@ async def get_addon_analytics( for row in daily_rows ] - # Get version distribution + # Get version distribution (check_count from aggregated stats) version_query = select( AddonUsageStats.version_id, func.sum(AddonUsageStats.check_count).label("check_count"), - func.sum(AddonUsageStats.unique_users).label("unique_users"), ).where( AddonUsageStats.addon_id == addon_id, AddonUsageStats.date >= start_date, @@ -157,6 +202,33 @@ async def get_addon_analytics( version_result = await db.execute(version_query) version_rows = version_result.all() + # Get TRUE unique users per version from raw VersionCheck logs + # (counting distinct IP hashes over the full period, not summing daily counts) + version_unique_query = select( + VersionCheck.version_id, + func.count(func.distinct(VersionCheck.client_ip_hash)).label("unique_users"), + ).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= start_datetime, + VersionCheck.version_id.isnot(None), + ).group_by(VersionCheck.version_id) + + version_unique_result = await db.execute(version_unique_query) + version_unique_map = { + row.version_id: row.unique_users + for row in version_unique_result.all() + } + + # Get TRUE total unique users across ALL versions for this addon + total_unique_query = select( + func.count(func.distinct(VersionCheck.client_ip_hash)).label("unique_users"), + ).where( + VersionCheck.addon_id == addon_id, + VersionCheck.timestamp >= start_datetime, + ) + total_unique_result = await db.execute(total_unique_query) + total_unique = total_unique_result.scalar() or 0 + # Get version names version_ids = [row.version_id for row in version_rows if row.version_id] versions_map = {} @@ -169,7 +241,6 @@ async def get_addon_analytics( # Calculate totals and percentages total_checks = sum(row.check_count or 0 for row in version_rows) - total_unique = sum(row.unique_users or 0 for row in version_rows) version_distribution = [] for row in version_rows: @@ -180,7 +251,7 @@ async def get_addon_analytics( version=versions_map.get(row.version_id, "Unknown"), version_id=row.version_id, check_count=row.check_count or 0, - unique_users=row.unique_users or 0, + unique_users=version_unique_map.get(row.version_id, 0), percentage=round(percentage, 2), ) ) @@ -218,9 +289,10 @@ async def get_user_analytics_summary( ) addons = addons_result.scalars().all() + addon_ids = [a.id for a in addons] + addon_analytics = [] total_checks = 0 - total_unique = 0 for addon in addons: try: @@ -229,10 +301,24 @@ async def get_user_analytics_summary( ) addon_analytics.append(analytics) total_checks += analytics.total_checks - total_unique += analytics.total_unique_users except ValueError: continue + # Get TRUE total unique users across ALL of the user's addons + # (a single user checking multiple addons should count as 1 unique user) + total_unique = 0 + if addon_ids: + start_date = date.today() - timedelta(days=days) + start_datetime = datetime.combine(start_date, datetime.min.time()) + total_unique_query = select( + func.count(func.distinct(VersionCheck.client_ip_hash)), + ).where( + VersionCheck.addon_id.in_(addon_ids), + VersionCheck.timestamp >= start_datetime, + ) + total_unique_result = await db.execute(total_unique_query) + total_unique = total_unique_result.scalar() or 0 + return AnalyticsSummary( total_addons=len(addons), total_checks=total_checks, diff --git a/plexaddons-api/app/services/changelog_service.py b/plexaddons-api/app/services/changelog_service.py new file mode 100644 index 0000000..8aa74e9 --- /dev/null +++ b/plexaddons-api/app/services/changelog_service.py @@ -0,0 +1,119 @@ +"""Automated changelog generation service. + +Generates changelogs from version history, comparing adjacent versions +and producing formatted Markdown output. +""" +from typing import Optional +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Version, Addon + + +class ChangelogService: + """Generate changelogs from addon version history.""" + + @staticmethod + async def generate_changelog( + db: AsyncSession, + addon_id: int, + from_version: Optional[str] = None, + to_version: Optional[str] = None, + limit: int = 20, + ) -> str: + """ + Generate a Markdown changelog for an addon. + + If from_version/to_version are provided, only include versions in that range. + Otherwise, include the latest `limit` versions. + """ + query = ( + select(Version) + .where(Version.addon_id == addon_id, Version.is_published == True) + .order_by(Version.created_at.desc()) + ) + + if from_version and to_version: + # Get versions between the two semvers (inclusive) + from_row = await db.execute( + select(Version.created_at) + .where(Version.addon_id == addon_id, Version.version == from_version) + ) + to_row = await db.execute( + select(Version.created_at) + .where(Version.addon_id == addon_id, Version.version == to_version) + ) + from_ts = from_row.scalar_one_or_none() + to_ts = to_row.scalar_one_or_none() + + if from_ts and to_ts: + low, high = sorted([from_ts, to_ts]) + query = query.where( + Version.created_at >= low, + Version.created_at <= high, + ) + else: + query = query.limit(limit) + + result = await db.execute(query) + versions = result.scalars().all() + + if not versions: + return "# Changelog\n\nNo versions found.\n" + + # Fetch addon name + addon_result = await db.execute(select(Addon.name).where(Addon.id == addon_id)) + addon_name = addon_result.scalar_one_or_none() or "Addon" + + return ChangelogService._format_changelog(addon_name, versions) + + @staticmethod + def _format_changelog(addon_name: str, versions: list) -> str: + """Format versions into a Markdown changelog.""" + lines = [f"# {addon_name} — Changelog\n"] + + for v in versions: + # Version header with date + date_str = "" + if v.release_date: + date_str = v.release_date.isoformat() + elif v.created_at: + date_str = v.created_at.strftime("%Y-%m-%d") + + flags = [] + if v.breaking: + flags.append("**BREAKING**") + if v.urgent: + flags.append("**URGENT**") + if v.is_deprecated: + flags.append("~~deprecated~~") + if v.channel and v.channel.value != "stable": + flags.append(f"`{v.channel.value}`") + + flag_str = " ".join(flags) + header = f"## [{v.version}] — {date_str}" + if flag_str: + header += f" {flag_str}" + lines.append(header) + lines.append("") + + # Description + if v.description: + lines.append(v.description) + lines.append("") + + # Changelog content + if v.changelog_content: + lines.append(v.changelog_content) + lines.append("") + + # Deprecation notice + if v.is_deprecated and v.deprecation_reason: + lines.append(f"> **Deprecated:** {v.deprecation_reason}") + lines.append("") + + lines.append("---") + lines.append("") + + return "\n".join(lines) diff --git a/plexaddons-api/app/services/collaborator_service.py b/plexaddons-api/app/services/collaborator_service.py new file mode 100644 index 0000000..b1bf665 --- /dev/null +++ b/plexaddons-api/app/services/collaborator_service.py @@ -0,0 +1,276 @@ +"""Service for managing addon collaborators and ownership transfers.""" + +from typing import Optional, List +from datetime import datetime, timezone +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from app.models import Addon, User, AddonCollaborator, CollaboratorRole +from app.core.exceptions import NotFoundError, ForbiddenError, ConflictError + + +class CollaboratorService: + + @staticmethod + async def get_addon_with_owner_check( + db: AsyncSession, addon_id: int, user: User + ) -> Addon: + """Get addon and verify user is the owner.""" + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + raise NotFoundError("Addon not found") + if addon.owner_id != user.id and not user.is_admin: + raise ForbiddenError("Only the addon owner can manage collaborators") + return addon + + @staticmethod + async def can_manage_addon( + db: AsyncSession, addon_id: int, user: User + ) -> bool: + """Check if user is owner or admin collaborator of the addon.""" + result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = result.scalar_one_or_none() + if not addon: + return False + if addon.owner_id == user.id or user.is_admin: + return True + collab = await db.execute( + select(AddonCollaborator).where( + and_( + AddonCollaborator.addon_id == addon_id, + AddonCollaborator.user_id == user.id, + AddonCollaborator.accepted == True, + AddonCollaborator.role == CollaboratorRole.ADMIN, + ) + ) + ) + return collab.scalar_one_or_none() is not None + + @staticmethod + async def list_collaborators( + db: AsyncSession, addon_id: int + ) -> List[dict]: + """List all collaborators for an addon with user info.""" + result = await db.execute( + select(AddonCollaborator, User) + .join(User, AddonCollaborator.user_id == User.id) + .where(AddonCollaborator.addon_id == addon_id) + .order_by(AddonCollaborator.created_at) + ) + rows = result.all() + collaborators = [] + for collab, user in rows: + collaborators.append({ + "id": collab.id, + "addon_id": collab.addon_id, + "user_id": collab.user_id, + "role": collab.role, + "accepted": collab.accepted, + "username": user.username, + "display_name": user.display_name, + "avatar": user.avatar, + "invited_by_id": collab.invited_by_id, + "created_at": collab.created_at, + "accepted_at": collab.accepted_at, + }) + return collaborators + + @staticmethod + async def invite_collaborator( + db: AsyncSession, addon_id: int, user_id: int, role: CollaboratorRole, invited_by: User + ) -> AddonCollaborator: + """Invite a user as a collaborator to an addon.""" + # Check addon exists + addon_result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = addon_result.scalar_one_or_none() + if not addon: + raise NotFoundError("Addon not found") + + # Can't invite the owner + if addon.owner_id == user_id: + raise ConflictError("Cannot add the addon owner as a collaborator") + + # Check target user exists + target_result = await db.execute(select(User).where(User.id == user_id)) + if not target_result.scalar_one_or_none(): + raise NotFoundError("User not found") + + # Check if already a collaborator + existing = await db.execute( + select(AddonCollaborator).where( + and_( + AddonCollaborator.addon_id == addon_id, + AddonCollaborator.user_id == user_id, + ) + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError("User is already a collaborator on this addon") + + collaborator = AddonCollaborator( + addon_id=addon_id, + user_id=user_id, + role=role, + invited_by_id=invited_by.id, + accepted=False, + ) + db.add(collaborator) + await db.commit() + await db.refresh(collaborator) + return collaborator + + @staticmethod + async def accept_invitation( + db: AsyncSession, collaborator_id: int, user: User + ) -> AddonCollaborator: + """Accept a collaboration invitation.""" + result = await db.execute( + select(AddonCollaborator).where(AddonCollaborator.id == collaborator_id) + ) + collab = result.scalar_one_or_none() + if not collab: + raise NotFoundError("Invitation not found") + if collab.user_id != user.id: + raise ForbiddenError("This invitation is not for you") + if collab.accepted: + raise ConflictError("Invitation already accepted") + + collab.accepted = True + collab.accepted_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(collab) + return collab + + @staticmethod + async def decline_invitation( + db: AsyncSession, collaborator_id: int, user: User + ) -> None: + """Decline (delete) a collaboration invitation.""" + result = await db.execute( + select(AddonCollaborator).where(AddonCollaborator.id == collaborator_id) + ) + collab = result.scalar_one_or_none() + if not collab: + raise NotFoundError("Invitation not found") + if collab.user_id != user.id: + raise ForbiddenError("This invitation is not for you") + + await db.delete(collab) + await db.commit() + + @staticmethod + async def update_collaborator_role( + db: AsyncSession, collaborator_id: int, role: CollaboratorRole + ) -> AddonCollaborator: + """Update a collaborator's role.""" + result = await db.execute( + select(AddonCollaborator).where(AddonCollaborator.id == collaborator_id) + ) + collab = result.scalar_one_or_none() + if not collab: + raise NotFoundError("Collaborator not found") + + collab.role = role + await db.commit() + await db.refresh(collab) + return collab + + @staticmethod + async def remove_collaborator( + db: AsyncSession, collaborator_id: int + ) -> None: + """Remove a collaborator from an addon.""" + result = await db.execute( + select(AddonCollaborator).where(AddonCollaborator.id == collaborator_id) + ) + collab = result.scalar_one_or_none() + if not collab: + raise NotFoundError("Collaborator not found") + + await db.delete(collab) + await db.commit() + + @staticmethod + async def transfer_ownership( + db: AsyncSession, addon_id: int, new_owner_id: int, current_user: User + ) -> Addon: + """Transfer addon ownership to another user.""" + # Get addon + addon_result = await db.execute(select(Addon).where(Addon.id == addon_id)) + addon = addon_result.scalar_one_or_none() + if not addon: + raise NotFoundError("Addon not found") + if addon.owner_id != current_user.id and not current_user.is_admin: + raise ForbiddenError("Only the addon owner can transfer ownership") + if addon.owner_id == new_owner_id: + raise ConflictError("User is already the owner") + + # Verify new owner exists + new_owner_result = await db.execute(select(User).where(User.id == new_owner_id)) + new_owner = new_owner_result.scalar_one_or_none() + if not new_owner: + raise NotFoundError("New owner not found") + + old_owner_id = addon.owner_id + addon.owner_id = new_owner_id + + # Remove new owner from collaborators if they were one + existing_collab = await db.execute( + select(AddonCollaborator).where( + and_( + AddonCollaborator.addon_id == addon_id, + AddonCollaborator.user_id == new_owner_id, + ) + ) + ) + collab = existing_collab.scalar_one_or_none() + if collab: + await db.delete(collab) + + # Add old owner as admin collaborator + old_collab = AddonCollaborator( + addon_id=addon_id, + user_id=old_owner_id, + role=CollaboratorRole.ADMIN, + invited_by_id=new_owner_id, + accepted=True, + accepted_at=datetime.now(timezone.utc), + ) + db.add(old_collab) + + await db.commit() + await db.refresh(addon) + return addon + + @staticmethod + async def get_my_invitations( + db: AsyncSession, user: User + ) -> List[dict]: + """Get pending collaboration invitations for the current user.""" + result = await db.execute( + select(AddonCollaborator, Addon) + .join(Addon, AddonCollaborator.addon_id == Addon.id) + .where( + and_( + AddonCollaborator.user_id == user.id, + AddonCollaborator.accepted == False, + ) + ) + .order_by(AddonCollaborator.created_at.desc()) + ) + rows = result.all() + invitations = [] + for collab, addon in rows: + invitations.append({ + "id": collab.id, + "addon_id": collab.addon_id, + "addon_name": addon.name, + "addon_slug": addon.slug, + "role": collab.role, + "accepted": collab.accepted, + "user_id": collab.user_id, + "invited_by_id": collab.invited_by_id, + "created_at": collab.created_at, + "accepted_at": collab.accepted_at, + }) + return invitations diff --git a/plexaddons-api/app/services/notification_service.py b/plexaddons-api/app/services/notification_service.py new file mode 100644 index 0000000..651a114 --- /dev/null +++ b/plexaddons-api/app/services/notification_service.py @@ -0,0 +1,119 @@ +"""Service for creating in-app notifications.""" + +from sqlalchemy.ext.asyncio import AsyncSession +from app.models import Notification, NotificationType + + +class NotificationService: + """Service for creating and managing notifications.""" + + @staticmethod + async def create( + db: AsyncSession, + user_id: int, + type: NotificationType, + title: str, + message: str | None = None, + link: str | None = None, + ) -> Notification: + """Create a notification for a user.""" + notification = Notification( + user_id=user_id, + type=type, + title=title, + message=message, + link=link, + ) + db.add(notification) + await db.commit() + await db.refresh(notification) + return notification + + @staticmethod + async def notify_addon_update( + db: AsyncSession, + user_id: int, + addon_name: str, + addon_slug: str, + new_version: str, + ) -> Notification: + """Notify user about a new version of a starred addon.""" + return await NotificationService.create( + db, + user_id=user_id, + type=NotificationType.ADDON_UPDATE, + title=f"{addon_name} updated to v{new_version}", + message=f"A new version of {addon_name} is available.", + link=f"/addons/{addon_slug}", + ) + + @staticmethod + async def notify_review_received( + db: AsyncSession, + owner_id: int, + addon_name: str, + addon_slug: str, + reviewer_name: str, + rating: int, + ) -> Notification: + """Notify addon owner about a new review.""" + return await NotificationService.create( + db, + user_id=owner_id, + type=NotificationType.REVIEW_RECEIVED, + title=f"New {rating}\u2605 review on {addon_name}", + message=f"{reviewer_name} left a review on {addon_name}.", + link=f"/addons/{addon_slug}", + ) + + @staticmethod + async def notify_star_received( + db: AsyncSession, + owner_id: int, + addon_name: str, + addon_slug: str, + star_user_name: str, + ) -> Notification: + """Notify addon owner when someone stars their addon.""" + return await NotificationService.create( + db, + user_id=owner_id, + type=NotificationType.STAR_RECEIVED, + title=f"{star_user_name} starred {addon_name}", + link=f"/addons/{addon_slug}", + ) + + @staticmethod + async def notify_addon_verified( + db: AsyncSession, + owner_id: int, + addon_name: str, + addon_slug: str, + ) -> Notification: + """Notify addon owner when their addon is verified.""" + return await NotificationService.create( + db, + user_id=owner_id, + type=NotificationType.ADDON_VERIFIED, + title=f"{addon_name} has been verified!", + message="Your addon has been verified by the PlexDevelopment team.", + link=f"/addons/{addon_slug}", + ) + + @staticmethod + async def notify_system( + db: AsyncSession, + user_id: int, + title: str, + message: str | None = None, + link: str | None = None, + ) -> Notification: + """Create a system notification.""" + return await NotificationService.create( + db, + user_id=user_id, + type=NotificationType.SYSTEM, + title=title, + message=message, + link=link, + ) diff --git a/plexaddons-api/app/services/stripe_service.py b/plexaddons-api/app/services/stripe_service.py index 7a3b9f6..b630224 100644 --- a/plexaddons-api/app/services/stripe_service.py +++ b/plexaddons-api/app/services/stripe_service.py @@ -1,12 +1,13 @@ import stripe import logging +import secrets from typing import Optional from fastapi import BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timezone from app.config import get_settings -from app.models import User, Subscription, SubscriptionTier, SubscriptionStatus, PaymentProvider, SubscriptionEvent +from app.models import User, Subscription, SubscriptionTier, SubscriptionStatus, PaymentProvider, SubscriptionEvent, Addon, AddonLicense, LicenseStatus from app.services.user_service import UserService from app.core.exceptions import PaymentError, BadRequestError import json @@ -142,6 +143,7 @@ async def handle_webhook_event( # Handle different event types handlers = { + "checkout.session.completed": StripeService._handle_checkout_completed, "customer.subscription.created": StripeService._handle_subscription_created, "customer.subscription.updated": StripeService._handle_subscription_updated, "customer.subscription.deleted": StripeService._handle_subscription_deleted, @@ -155,6 +157,68 @@ async def handle_webhook_event( return {"status": "success", "event_type": event_type} + @staticmethod + async def _handle_checkout_completed( + db: AsyncSession, + session_data: dict, + background_tasks: Optional[BackgroundTasks] = None, + ): + """Handle checkout.session.completed — creates a license for addon purchases.""" + metadata = session_data.get("metadata", {}) + + # Only handle addon purchases, not subscription checkouts + if metadata.get("type") != "addon_purchase": + return + + addon_id = int(metadata["addon_id"]) + buyer_id = int(metadata["buyer_id"]) + server_id = metadata.get("server_id") or None + price_cents = int(metadata["price_cents"]) + developer_amount_cents = int(metadata["developer_amount_cents"]) + platform_amount_cents = int(metadata["platform_amount_cents"]) + payment_intent_id = session_data.get("payment_intent") + + # Check if license already exists for this payment (idempotency) + if payment_intent_id: + existing = await db.execute( + select(AddonLicense).where( + AddonLicense.stripe_payment_intent_id == payment_intent_id, + ) + ) + if existing.scalar_one_or_none(): + logger.info(f"License already exists for payment_intent {payment_intent_id}") + return + + # Also check for duplicate active license + existing_active = await db.execute( + select(AddonLicense).where( + AddonLicense.addon_id == addon_id, + AddonLicense.buyer_id == buyer_id, + AddonLicense.status == LicenseStatus.ACTIVE, + ) + ) + if existing_active.scalar_one_or_none(): + logger.info(f"Active license already exists for addon {addon_id} buyer {buyer_id}") + return + + license_key = f"lic_{secrets.token_hex(24)}" + + license = AddonLicense( + addon_id=addon_id, + buyer_id=buyer_id, + license_key=license_key, + stripe_payment_intent_id=payment_intent_id, + amount_cents=price_cents, + developer_amount_cents=developer_amount_cents, + platform_amount_cents=platform_amount_cents, + status=LicenseStatus.ACTIVE, + server_id=server_id, + ) + db.add(license) + await db.commit() + + logger.info(f"License created for addon {addon_id}, buyer {buyer_id}, payment {payment_intent_id}") + @staticmethod async def _handle_subscription_created( db: AsyncSession, diff --git a/plexaddons-api/app/services/version_service.py b/plexaddons-api/app/services/version_service.py index b242fb0..35163b5 100644 --- a/plexaddons-api/app/services/version_service.py +++ b/plexaddons-api/app/services/version_service.py @@ -1,8 +1,8 @@ from typing import Optional, List -from datetime import date +from datetime import date, datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, delete -from app.models import Version, Addon, User, SubscriptionTier +from app.models import Version, Addon, User, SubscriptionTier, ReleaseChannel from app.schemas import VersionCreate, VersionUpdate from app.services.user_service import UserService from app.services.webhook_service import webhook_service @@ -175,6 +175,7 @@ async def create_version( breaking=data.breaking, urgent=data.urgent, storage_size_bytes=content_size, + channel=data.channel, ) db.add(version) await db.commit() @@ -247,18 +248,23 @@ async def list_versions( addon_id: int, skip: int = 0, limit: int = 50, + channel: Optional[ReleaseChannel] = None, ) -> tuple[List[Version], int]: - """List versions for an addon.""" + """List versions for an addon, optionally filtered by release channel.""" + base_filter = [Version.addon_id == addon_id] + if channel: + base_filter.append(Version.channel == channel) + # Get total count count_result = await db.execute( - select(func.count(Version.id)).where(Version.addon_id == addon_id) + select(func.count(Version.id)).where(*base_filter) ) total = count_result.scalar() or 0 # Get versions result = await db.execute( select(Version) - .where(Version.addon_id == addon_id) + .where(*base_filter) .order_by(Version.release_date.desc(), Version.created_at.desc()) .offset(skip) .limit(limit) @@ -266,3 +272,66 @@ async def list_versions( versions = result.scalars().all() return list(versions), total + + @staticmethod + async def deprecate_version( + db: AsyncSession, + version: Version, + reason: str, + ) -> Version: + """Mark a version as deprecated with a reason.""" + version.is_deprecated = True + version.deprecation_reason = reason + version.deprecated_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(version) + return version + + @staticmethod + async def undeprecate_version( + db: AsyncSession, + version: Version, + ) -> Version: + """Remove deprecation from a version.""" + version.is_deprecated = False + version.deprecation_reason = None + version.deprecated_at = None + await db.commit() + await db.refresh(version) + return version + + @staticmethod + async def get_latest_version_by_channel( + db: AsyncSession, + addon_id: int, + channel: ReleaseChannel = ReleaseChannel.STABLE, + ) -> Optional[Version]: + """Get the latest non-deprecated, published version for a channel.""" + result = await db.execute( + select(Version) + .where( + Version.addon_id == addon_id, + Version.channel == channel, + Version.is_published == True, + Version.is_deprecated == False, + ) + .order_by(Version.release_date.desc(), Version.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + @staticmethod + async def rollback_to_version( + db: AsyncSession, + addon: Addon, + version: Version, + ) -> Version: + """ + Make a specific version the 'latest' by updating its release_date to today. + This effectively promotes it as the newest version. + """ + version.release_date = date.today() + addon.updated_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(version) + return version diff --git a/plexaddons-api/app/services/webhook_service.py b/plexaddons-api/app/services/webhook_service.py index 7455ee2..0403c75 100644 --- a/plexaddons-api/app/services/webhook_service.py +++ b/plexaddons-api/app/services/webhook_service.py @@ -1,20 +1,32 @@ -"""Webhook notification service for Premium users.""" +"""Webhook notification service for Premium users. + +Supports: +- Multiple endpoints per user with event filtering +- Exponential backoff retries (1m, 5m, 30m, 2h, 12h) +- Delivery logging (last 100 per endpoint) +- Custom payload templates +- Discord webhook auto-detection and formatting +""" import hmac import hashlib import json +import re import secrets -from datetime import datetime, timezone -from typing import Optional, Dict, Any +from datetime import datetime, timezone, timedelta +from typing import Optional, Dict, Any, Tuple import httpx from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from app.models import User, Addon, Version, SubscriptionTier +from sqlalchemy import select, delete, func +from app.models import User, Addon, Version, SubscriptionTier, WebhookEndpoint, WebhookDelivery from app.config import get_settings from app.api.deps import get_effective_tier settings = get_settings() +# Retry backoff intervals in minutes +RETRY_INTERVALS = [1, 5, 30, 120, 720] # 1m, 5m, 30m, 2h, 12h + class WebhookEvent: """Webhook event types.""" @@ -55,23 +67,37 @@ def is_discord_webhook(url: str) -> bool: """Check if URL is a Discord webhook.""" return 'discord.com/api/webhooks/' in url or 'discordapp.com/api/webhooks/' in url + @staticmethod + def render_template(template: str, data: Dict[str, Any]) -> str: + """Simple template rendering: replaces {{key}} with values from data. + Supports dot notation for nested access: {{addon.name}}""" + def replacer(match): + key_path = match.group(1).strip() + obj = data + for part in key_path.split('.'): + if isinstance(obj, dict): + obj = obj.get(part) + else: + return match.group(0) + if obj is None: + return '' + return str(obj) if obj is not None else '' + return re.sub(r'\{\{(.+?)\}\}', replacer, template) + @staticmethod def format_discord_payload(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]: """Format payload for Discord webhook (embeds).""" - # Map event types to colors colors = { - WebhookEvent.VERSION_RELEASED: 0x22c55e, # green - WebhookEvent.VERSION_UPDATED: 0x3b82f6, # blue - WebhookEvent.VERSION_DELETED: 0xef4444, # red - WebhookEvent.ADDON_CREATED: 0x8b5cf6, # purple - WebhookEvent.ADDON_UPDATED: 0xf59e0b, # orange - WebhookEvent.ADDON_DELETED: 0xef4444, # red - "test": 0x6366f1, # indigo + WebhookEvent.VERSION_RELEASED: 0x22c55e, + WebhookEvent.VERSION_UPDATED: 0x3b82f6, + WebhookEvent.VERSION_DELETED: 0xef4444, + WebhookEvent.ADDON_CREATED: 0x8b5cf6, + WebhookEvent.ADDON_UPDATED: 0xf59e0b, + WebhookEvent.ADDON_DELETED: 0xef4444, + "test": 0x6366f1, } color = colors.get(event_type, 0x5865f2) - - # Build embed based on event type embed = { "color": color, "timestamp": datetime.now(timezone.utc).isoformat(), @@ -122,79 +148,254 @@ def format_discord_payload(event_type: str, data: Dict[str, Any]) -> Dict[str, A embed["title"] = f"📣 {event_type}" embed["description"] = json.dumps(data, indent=2, default=str)[:2000] - return { - "embeds": [embed] + return {"embeds": [embed]} + + # ==================== Multi-endpoint delivery ==================== + + @staticmethod + async def deliver_to_endpoint( + endpoint: WebhookEndpoint, + event_type: str, + payload_json: str, + ) -> Tuple[bool, Optional[int], Optional[str]]: + """Deliver a payload to a single endpoint. + Returns: (success, status_code, error_message)""" + is_discord = WebhookService.is_discord_webhook(endpoint.url) + + headers = { + "Content-Type": "application/json", + "User-Agent": "PlexAddons-Webhook/2.0", } - + if not is_discord: + signature = WebhookService.sign_payload(payload_json, endpoint.secret) + headers["X-PlexAddons-Event"] = event_type + headers["X-PlexAddons-Signature"] = signature + headers["X-PlexAddons-Timestamp"] = str(int(datetime.now(timezone.utc).timestamp())) + headers["X-PlexAddons-Endpoint-Id"] = str(endpoint.id) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(endpoint.url, content=payload_json, headers=headers) + if 200 <= response.status_code < 300: + return True, response.status_code, None + else: + body = response.text[:500] if response.text else "" + return False, response.status_code, f"HTTP {response.status_code}: {body}" + except httpx.TimeoutException: + return False, None, "Request timed out after 10 seconds" + except httpx.RequestError as e: + return False, None, f"Request failed: {str(e)}" + except Exception as e: + return False, None, f"Unexpected error: {str(e)}" + @staticmethod - async def send_webhook( + async def _build_payload( + endpoint: WebhookEndpoint, + event_type: str, + data: Dict[str, Any], + ) -> str: + """Build the JSON payload for an endpoint, applying templates if set.""" + is_discord = WebhookService.is_discord_webhook(endpoint.url) + + if is_discord: + payload = WebhookService.format_discord_payload(event_type, data) + elif endpoint.payload_template: + rendered = WebhookService.render_template(endpoint.payload_template, { + "event": event_type, + "timestamp": datetime.now(timezone.utc).isoformat(), + **data, + }) + try: + json.loads(rendered) + return rendered + except json.JSONDecodeError: + payload = {"event": event_type, "timestamp": datetime.now(timezone.utc).isoformat(), "data": data, "template_error": "Invalid JSON after rendering"} + else: + payload = {"event": event_type, "timestamp": datetime.now(timezone.utc).isoformat(), "data": data} + + return json.dumps(payload, default=str) + + @staticmethod + async def _prune_deliveries(db: AsyncSession, endpoint_id: int, keep: int = 100): + """Keep only the latest N deliveries per endpoint.""" + count_result = await db.execute( + select(func.count()).where(WebhookDelivery.endpoint_id == endpoint_id) + ) + total = count_result.scalar() or 0 + if total > keep: + oldest = await db.execute( + select(WebhookDelivery.id) + .where(WebhookDelivery.endpoint_id == endpoint_id) + .order_by(WebhookDelivery.created_at) + .limit(total - keep) + ) + ids_to_delete = [row[0] for row in oldest.all()] + if ids_to_delete: + await db.execute( + delete(WebhookDelivery).where(WebhookDelivery.id.in_(ids_to_delete)) + ) + + @staticmethod + async def dispatch_event( + db: AsyncSession, user: User, event_type: str, data: Dict[str, Any], - ) -> bool: - """ - Send a webhook notification to a user. - - Args: - user: The user to send the webhook to - event_type: The type of event (e.g., "version.released") - data: The event data - - Returns: - True if webhook was sent successfully, False otherwise - """ - # Check if user is Premium and has webhooks enabled + ) -> int: + """Dispatch a webhook event to all matching endpoints for a user. + Returns count of deliveries created.""" effective_tier = get_effective_tier(user) if effective_tier != SubscriptionTier.PREMIUM: - return False - + await WebhookService._send_legacy_webhook(user, event_type, data) + return 0 + + result = await db.execute( + select(WebhookEndpoint).where( + WebhookEndpoint.user_id == user.id, + WebhookEndpoint.is_active == True, + ) + ) + endpoints = result.scalars().all() + + if not endpoints: + await WebhookService._send_legacy_webhook(user, event_type, data) + return 0 + + count = 0 + for endpoint in endpoints: + if endpoint.event_filter: + if event_type not in endpoint.event_filter: + continue + + payload_json = await WebhookService._build_payload(endpoint, event_type, data) + + success, status_code, error = await WebhookService.deliver_to_endpoint( + endpoint, event_type, payload_json + ) + + now = datetime.now(timezone.utc) + delivery = WebhookDelivery( + endpoint_id=endpoint.id, + event_type=event_type, + payload=payload_json, + status="success" if success else "pending", + status_code=status_code, + error_message=error, + attempt=1, + max_attempts=6, + delivered_at=now if success else None, + ) + + if not success: + delivery.next_retry_at = now + timedelta(minutes=RETRY_INTERVALS[0]) + + db.add(delivery) + await WebhookService._prune_deliveries(db, endpoint.id) + count += 1 + + await db.commit() + return count + + @staticmethod + async def process_retries(db: AsyncSession): + """Process pending webhook retries. Called by scheduler.""" + now = datetime.now(timezone.utc) + result = await db.execute( + select(WebhookDelivery) + .where( + WebhookDelivery.status == "pending", + WebhookDelivery.next_retry_at <= now, + ) + .limit(50) + ) + deliveries = result.scalars().all() + + for delivery in deliveries: + ep_result = await db.execute( + select(WebhookEndpoint).where(WebhookEndpoint.id == delivery.endpoint_id) + ) + endpoint = ep_result.scalar_one_or_none() + if not endpoint or not endpoint.is_active: + delivery.status = "failed" + delivery.error_message = "Endpoint disabled or deleted" + continue + + success, status_code, error = await WebhookService.deliver_to_endpoint( + endpoint, delivery.event_type, delivery.payload + ) + + delivery.attempt += 1 + delivery.status_code = status_code + + if success: + delivery.status = "success" + delivery.delivered_at = now + delivery.error_message = None + delivery.next_retry_at = None + elif delivery.attempt >= delivery.max_attempts: + delivery.status = "failed" + delivery.error_message = error + delivery.next_retry_at = None + else: + retry_idx = min(delivery.attempt - 1, len(RETRY_INTERVALS) - 1) + delivery.next_retry_at = now + timedelta(minutes=RETRY_INTERVALS[retry_idx]) + delivery.error_message = error + + await db.commit() + + @staticmethod + async def test_endpoint(endpoint: WebhookEndpoint) -> Dict[str, Any]: + """Send a test event to a specific endpoint.""" + data = {"message": "This is a test webhook from PlexAddons", "endpoint_id": endpoint.id} + payload_json = await WebhookService._build_payload(endpoint, "test", data) + success, status_code, error = await WebhookService.deliver_to_endpoint(endpoint, "test", payload_json) + + if success: + return {"success": True, "status_code": status_code} + return {"success": False, "status_code": status_code, "error": error or "Delivery failed"} + + # ==================== Legacy single-endpoint support ==================== + + @staticmethod + async def _send_legacy_webhook(user: User, event_type: str, data: Dict[str, Any]) -> bool: + """Fallback: send to the legacy single webhook_url on User model.""" if not user.webhook_enabled or not user.webhook_url or not user.webhook_secret: return False - - # Check if this is a Discord webhook + is_discord = WebhookService.is_discord_webhook(user.webhook_url) - if is_discord: - # Use Discord embed format payload = WebhookService.format_discord_payload(event_type, data) else: - # Use standard PlexAddons format - payload = { - "event": event_type, - "timestamp": datetime.now(timezone.utc).isoformat(), - "data": data, - } - + payload = {"event": event_type, "timestamp": datetime.now(timezone.utc).isoformat(), "data": data} + payload_json = json.dumps(payload, default=str) - - # Sign the payload signature = WebhookService.sign_payload(payload_json, user.webhook_secret) - - # Send the webhook - headers = { - "Content-Type": "application/json", - "User-Agent": "PlexAddons-Webhook/1.0", - } - - # Only add custom headers for non-Discord webhooks + + headers = {"Content-Type": "application/json", "User-Agent": "PlexAddons-Webhook/1.0"} if not is_discord: headers["X-PlexAddons-Event"] = event_type headers["X-PlexAddons-Signature"] = signature headers["X-PlexAddons-Timestamp"] = str(int(datetime.now(timezone.utc).timestamp())) - + try: async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - user.webhook_url, - content=payload_json, - headers=headers, - ) + response = await client.post(user.webhook_url, content=payload_json, headers=headers) return 200 <= response.status_code < 300 except Exception as e: - # Log error but don't fail the operation - print(f"Webhook delivery failed for user {user.id}: {e}") + print(f"Legacy webhook delivery failed for user {user.id}: {e}") return False - + + @staticmethod + async def send_webhook( + user: User, + event_type: str, + data: Dict[str, Any], + ) -> bool: + """Legacy: Send a webhook notification via single user webhook URL.""" + return await WebhookService._send_legacy_webhook(user, event_type, data) + + # ==================== Convenience methods ==================== + @staticmethod async def notify_version_released( db: AsyncSession, @@ -202,26 +403,19 @@ async def notify_version_released( version: Version, owner: User, ) -> bool: - """Send webhook notification when a new version is released.""" data = { - "addon": { - "id": addon.id, - "name": addon.name, - "slug": addon.slug, - }, + "addon": {"id": addon.id, "name": addon.name, "slug": addon.slug}, "version": { - "id": version.id, - "version": version.version, - "download_url": version.download_url, - "description": version.description, - "changelog_url": version.changelog_url, - "breaking": version.breaking, + "id": version.id, "version": version.version, + "download_url": version.download_url, "description": version.description, + "changelog_url": version.changelog_url, "breaking": version.breaking, "urgent": version.urgent, "release_date": version.release_date.isoformat() if version.release_date else None, }, } - return await WebhookService.send_webhook(owner, WebhookEvent.VERSION_RELEASED, data) - + count = await WebhookService.dispatch_event(db, owner, WebhookEvent.VERSION_RELEASED, data) + return count > 0 + @staticmethod async def notify_version_updated( db: AsyncSession, @@ -229,24 +423,17 @@ async def notify_version_updated( version: Version, owner: User, ) -> bool: - """Send webhook notification when a version is updated.""" data = { - "addon": { - "id": addon.id, - "name": addon.name, - "slug": addon.slug, - }, + "addon": {"id": addon.id, "name": addon.name, "slug": addon.slug}, "version": { - "id": version.id, - "version": version.version, - "download_url": version.download_url, - "description": version.description, - "breaking": version.breaking, - "urgent": version.urgent, + "id": version.id, "version": version.version, + "download_url": version.download_url, "description": version.description, + "breaking": version.breaking, "urgent": version.urgent, }, } - return await WebhookService.send_webhook(owner, WebhookEvent.VERSION_UPDATED, data) - + count = await WebhookService.dispatch_event(db, owner, WebhookEvent.VERSION_UPDATED, data) + return count > 0 + @staticmethod async def notify_version_deleted( db: AsyncSession, @@ -254,55 +441,45 @@ async def notify_version_deleted( version_str: str, owner: User, ) -> bool: - """Send webhook notification when a version is deleted.""" data = { - "addon": { - "id": addon.id, - "name": addon.name, - "slug": addon.slug, - }, + "addon": {"id": addon.id, "name": addon.name, "slug": addon.slug}, "version": version_str, } - return await WebhookService.send_webhook(owner, WebhookEvent.VERSION_DELETED, data) - + count = await WebhookService.dispatch_event(db, owner, WebhookEvent.VERSION_DELETED, data) + return count > 0 + @staticmethod async def notify_addon_created( db: AsyncSession, addon: Addon, owner: User, ) -> bool: - """Send webhook notification when an addon is created.""" data = { "addon": { - "id": addon.id, - "name": addon.name, - "slug": addon.slug, - "description": addon.description, - "homepage": addon.homepage, + "id": addon.id, "name": addon.name, "slug": addon.slug, + "description": addon.description, "homepage": addon.homepage, "is_public": addon.is_public, }, } - return await WebhookService.send_webhook(owner, WebhookEvent.ADDON_CREATED, data) - + count = await WebhookService.dispatch_event(db, owner, WebhookEvent.ADDON_CREATED, data) + return count > 0 + @staticmethod async def notify_addon_updated( db: AsyncSession, addon: Addon, owner: User, ) -> bool: - """Send webhook notification when an addon is updated.""" data = { "addon": { - "id": addon.id, - "name": addon.name, - "slug": addon.slug, - "description": addon.description, - "homepage": addon.homepage, + "id": addon.id, "name": addon.name, "slug": addon.slug, + "description": addon.description, "homepage": addon.homepage, "is_public": addon.is_public, }, } - return await WebhookService.send_webhook(owner, WebhookEvent.ADDON_UPDATED, data) - + count = await WebhookService.dispatch_event(db, owner, WebhookEvent.ADDON_UPDATED, data) + return count > 0 + @staticmethod async def notify_addon_deleted( db: AsyncSession, @@ -310,97 +487,50 @@ async def notify_addon_deleted( addon_slug: str, owner: User, ) -> bool: - """Send webhook notification when an addon is deleted.""" - data = { - "addon": { - "name": addon_name, - "slug": addon_slug, - }, - } - return await WebhookService.send_webhook(owner, WebhookEvent.ADDON_DELETED, data) - + data = {"addon": {"name": addon_name, "slug": addon_slug}} + count = await WebhookService.dispatch_event(db, owner, WebhookEvent.ADDON_DELETED, data) + return count > 0 + @staticmethod async def test_webhook(user: User) -> Dict[str, Any]: - """ - Send a test webhook to verify configuration. - - Returns: - Dict with success status and any error message - """ + """Legacy test for the single user webhook URL.""" if not user.webhook_url or not user.webhook_secret: - return { - "success": False, - "error": "Webhook URL or secret not configured", - } - - # Check if this is a Discord webhook + return {"success": False, "error": "Webhook URL or secret not configured"} + is_discord = WebhookService.is_discord_webhook(user.webhook_url) - if is_discord: - # Use Discord embed format for test test_payload = WebhookService.format_discord_payload("test", { "message": "This is a test webhook from PlexAddons", "user_id": user.id, }) else: - # Standard format test_payload = { "event": "test", "timestamp": datetime.now(timezone.utc).isoformat(), - "data": { - "message": "This is a test webhook from PlexAddons", - "user_id": user.id, - }, + "data": {"message": "This is a test webhook from PlexAddons", "user_id": user.id}, } - + payload_json = json.dumps(test_payload, default=str) signature = WebhookService.sign_payload(payload_json, user.webhook_secret) - - headers = { - "Content-Type": "application/json", - "User-Agent": "PlexAddons-Webhook/1.0", - } - - # Only add custom headers for non-Discord webhooks + + headers = {"Content-Type": "application/json", "User-Agent": "PlexAddons-Webhook/1.0"} if not is_discord: headers["X-PlexAddons-Event"] = "test" headers["X-PlexAddons-Signature"] = signature headers["X-PlexAddons-Timestamp"] = str(int(datetime.now(timezone.utc).timestamp())) - + try: async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - user.webhook_url, - content=payload_json, - headers=headers, - ) - + response = await client.post(user.webhook_url, content=payload_json, headers=headers) if 200 <= response.status_code < 300: - return { - "success": True, - "status_code": response.status_code, - } - else: - return { - "success": False, - "error": f"Received status code {response.status_code}", - "status_code": response.status_code, - } + return {"success": True, "status_code": response.status_code} + return {"success": False, "error": f"Received status code {response.status_code}", "status_code": response.status_code} except httpx.TimeoutException: - return { - "success": False, - "error": "Request timed out after 10 seconds", - } + return {"success": False, "error": "Request timed out after 10 seconds"} except httpx.RequestError as e: - return { - "success": False, - "error": f"Request failed: {str(e)}", - } + return {"success": False, "error": f"Request failed: {str(e)}"} except Exception as e: - return { - "success": False, - "error": f"Unexpected error: {str(e)}", - } + return {"success": False, "error": f"Unexpected error: {str(e)}"} webhook_service = WebhookService() diff --git a/plexaddons-api/app/tasks.py b/plexaddons-api/app/tasks.py new file mode 100644 index 0000000..82958aa --- /dev/null +++ b/plexaddons-api/app/tasks.py @@ -0,0 +1,83 @@ +""" +Background task worker using arq (async Redis queue). + +Usage: + arq app.tasks.WorkerSettings + +This supplements APScheduler for ad-hoc or long-running tasks +(email sending, webhook delivery, analytics aggregation). +""" +from arq import create_pool +from arq.connections import RedisSettings, ArqRedis +from app.config import get_settings + +settings = get_settings() + + +def get_redis_settings() -> RedisSettings: + """Parse Redis URL into arq RedisSettings.""" + from urllib.parse import urlparse + parsed = urlparse(settings.redis_url) + return RedisSettings( + host=parsed.hostname or "localhost", + port=parsed.port or 6379, + database=int(parsed.path.lstrip("/") or "0"), + ) + + +async def send_webhook_task(ctx: dict, addon_id: int, event: str, payload: dict) -> None: + """Deliver a webhook notification asynchronously.""" + from app.services.webhook_service import WebhookService + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + service = WebhookService(db) + await service.deliver_webhook(addon_id, event, payload) + + +async def send_email_task(ctx: dict, to: str, subject: str, html: str) -> None: + """Send an email asynchronously.""" + from app.services.email_service import EmailService + service = EmailService() + await service.send_raw(to, subject, html) + + +async def aggregate_analytics_task(ctx: dict, addon_id: int) -> None: + """Aggregate analytics for an addon.""" + from app.services.analytics_service import AnalyticsService + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + service = AnalyticsService(db) + await service.recalculate_unique_users() + + +async def startup(ctx: dict) -> None: + """Worker startup hook.""" + pass + + +async def shutdown(ctx: dict) -> None: + """Worker shutdown hook.""" + pass + + +class WorkerSettings: + """arq worker settings.""" + functions = [ + send_webhook_task, + send_email_task, + aggregate_analytics_task, + ] + on_startup = startup + on_shutdown = shutdown + redis_settings = get_redis_settings() + max_jobs = 10 + job_timeout = 300 # 5 minutes + + +async def enqueue_task(func_name: str, *args, **kwargs) -> None: + """Enqueue a task to the arq worker.""" + redis: ArqRedis = await create_pool(get_redis_settings()) + try: + await redis.enqueue_job(func_name, *args, **kwargs) + finally: + await redis.close() diff --git a/plexaddons-api/app/webhooks/__init__.py b/plexaddons-api/app/webhooks/__init__.py index 91c6699..9e81b98 100644 --- a/plexaddons-api/app/webhooks/__init__.py +++ b/plexaddons-api/app/webhooks/__init__.py @@ -3,6 +3,7 @@ from fastapi import Depends from app.database import get_db from app.services import StripeService, PayPalService +from app.api.deps import rate_limit_check router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) @@ -12,6 +13,7 @@ async def stripe_webhook( request: Request, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check), ): """Handle Stripe webhook events.""" payload = await request.body() @@ -26,6 +28,7 @@ async def paypal_webhook( request: Request, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), + _: None = Depends(rate_limit_check), ): """Handle PayPal webhook events.""" payload = await request.json() diff --git a/plexaddons-api/pyproject.toml b/plexaddons-api/pyproject.toml new file mode 100644 index 0000000..fc64a61 --- /dev/null +++ b/plexaddons-api/pyproject.toml @@ -0,0 +1,6 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/plexaddons-api/requirements.txt b/plexaddons-api/requirements.txt index 52c604a..1f96bb9 100644 --- a/plexaddons-api/requirements.txt +++ b/plexaddons-api/requirements.txt @@ -12,11 +12,9 @@ psycopg2-binary==2.9.9 # Redis redis==5.0.1 -aioredis==2.0.1 # Authentication python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 httpx==0.26.0 # Discord Bot @@ -29,6 +27,11 @@ stripe==7.12.0 # Scheduling apscheduler==3.10.4 +# Background task queue +arq==0.26.1 +# Structured Logging +structlog==24.1.0 + # Email aiosmtplib==3.0.1 @@ -36,3 +39,8 @@ aiosmtplib==3.0.1 python-multipart==0.0.6 python-dotenv==1.0.0 email-validator==2.1.0 + +# Testing +pytest==8.2.0 +pytest-asyncio==0.24.0 +aiosqlite==0.20.0 diff --git a/plexaddons-api/tests/__init__.py b/plexaddons-api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plexaddons-api/tests/conftest.py b/plexaddons-api/tests/conftest.py new file mode 100644 index 0000000..f83f03f --- /dev/null +++ b/plexaddons-api/tests/conftest.py @@ -0,0 +1,108 @@ +"""Shared test fixtures for PlexAddons API tests.""" +import os +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +# Set test environment variables before importing app modules +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only") +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///test.db") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15") +os.environ.setdefault("DISCORD_CLIENT_ID", "test-client-id") +os.environ.setdefault("DISCORD_CLIENT_SECRET", "test-client-secret") +os.environ.setdefault("DISCORD_REDIRECT_URI", "http://localhost:3000/auth/callback") +os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_fake") +os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "whsec_test_fake") +os.environ.setdefault("STRIPE_PRO_PRICE_ID", "price_test_pro") +os.environ.setdefault("STRIPE_PREMIUM_PRICE_ID", "price_test_premium") +os.environ.setdefault("PAYPAL_CLIENT_ID", "test-paypal-id") +os.environ.setdefault("PAYPAL_CLIENT_SECRET", "test-paypal-secret") +os.environ.setdefault("PAYPAL_WEBHOOK_ID", "test-webhook-id") +os.environ.setdefault("PAYPAL_PRO_PLAN_ID", "P-test-pro") +os.environ.setdefault("PAYPAL_PREMIUM_PLAN_ID", "P-test-premium") +os.environ.setdefault("ENVIRONMENT", "testing") +os.environ.setdefault("EMAIL_ENABLED", "false") + +from app.database import Base +from app.core.security import create_access_token +from app.models import User, SubscriptionTier + + +# Use in-memory SQLite for tests +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +@pytest_asyncio.fixture +async def db_engine(): + """Create a test database engine.""" + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(db_engine): + """Create a test database session.""" + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + yield session + + +@pytest_asyncio.fixture +async def test_user(db_session: AsyncSession): + """Create a test user.""" + user = User( + discord_id="123456789", + discord_username="testuser", + discord_avatar=None, + email="test@example.com", + subscription_tier=SubscriptionTier.FREE, + is_admin=False, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def admin_user(db_session: AsyncSession): + """Create an admin test user.""" + user = User( + discord_id="987654321", + discord_username="adminuser", + discord_avatar=None, + email="admin@example.com", + subscription_tier=SubscriptionTier.PREMIUM, + is_admin=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +def auth_headers(test_user: User): + """Generate auth headers for the test user.""" + token = create_access_token({"sub": str(test_user.id)}) + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def admin_auth_headers(admin_user: User): + """Generate auth headers for the admin user.""" + token = create_access_token({"sub": str(admin_user.id)}) + return {"Authorization": f"Bearer {token}"} diff --git a/plexaddons-api/tests/test_deps.py b/plexaddons-api/tests/test_deps.py new file mode 100644 index 0000000..f3ff901 --- /dev/null +++ b/plexaddons-api/tests/test_deps.py @@ -0,0 +1,78 @@ +"""Tests for dependency helpers (get_effective_tier).""" +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only") +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///test.db") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15") +os.environ.setdefault("DISCORD_CLIENT_ID", "test") +os.environ.setdefault("DISCORD_CLIENT_SECRET", "test") +os.environ.setdefault("DISCORD_REDIRECT_URI", "http://localhost/callback") +os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_fake") +os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "whsec_test") +os.environ.setdefault("STRIPE_PRO_PRICE_ID", "price_pro") +os.environ.setdefault("STRIPE_PREMIUM_PRICE_ID", "price_premium") +os.environ.setdefault("PAYPAL_CLIENT_ID", "test") +os.environ.setdefault("PAYPAL_CLIENT_SECRET", "test") +os.environ.setdefault("PAYPAL_WEBHOOK_ID", "test") +os.environ.setdefault("PAYPAL_PRO_PLAN_ID", "P-test") +os.environ.setdefault("PAYPAL_PREMIUM_PLAN_ID", "P-test") + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock +from app.api.deps import get_effective_tier +from app.models import SubscriptionTier + + +def _make_user(tier=SubscriptionTier.FREE, temp_tier=None, temp_expires=None): + user = MagicMock() + user.subscription_tier = tier + user.temp_tier = temp_tier + user.temp_tier_expires_at = temp_expires + return user + + +class TestGetEffectiveTier: + def test_free_user(self): + user = _make_user(SubscriptionTier.FREE) + assert get_effective_tier(user) == SubscriptionTier.FREE + + def test_pro_user(self): + user = _make_user(SubscriptionTier.PRO) + assert get_effective_tier(user) == SubscriptionTier.PRO + + def test_premium_user(self): + user = _make_user(SubscriptionTier.PREMIUM) + assert get_effective_tier(user) == SubscriptionTier.PREMIUM + + def test_temp_tier_active(self): + future = datetime.now(timezone.utc) + timedelta(hours=1) + user = _make_user( + SubscriptionTier.FREE, + temp_tier=SubscriptionTier.PREMIUM, + temp_expires=future, + ) + assert get_effective_tier(user) == SubscriptionTier.PREMIUM + + def test_temp_tier_expired(self): + past = datetime.now(timezone.utc) - timedelta(hours=1) + user = _make_user( + SubscriptionTier.FREE, + temp_tier=SubscriptionTier.PRO, + temp_expires=past, + ) + assert get_effective_tier(user) == SubscriptionTier.FREE + + def test_temp_tier_none_expiry(self): + user = _make_user( + SubscriptionTier.FREE, + temp_tier=SubscriptionTier.PRO, + temp_expires=None, + ) + assert get_effective_tier(user) == SubscriptionTier.FREE + + def test_no_temp_tier(self): + user = _make_user( + SubscriptionTier.PRO, + temp_tier=None, + temp_expires=datetime.now(timezone.utc) + timedelta(hours=1), + ) + assert get_effective_tier(user) == SubscriptionTier.PRO diff --git a/plexaddons-api/tests/test_exceptions.py b/plexaddons-api/tests/test_exceptions.py new file mode 100644 index 0000000..7e584c2 --- /dev/null +++ b/plexaddons-api/tests/test_exceptions.py @@ -0,0 +1,60 @@ +"""Tests for custom exception classes.""" +from fastapi import status +from app.core.exceptions import ( + NotFoundError, + UnauthorizedError, + ForbiddenError, + BadRequestError, + ConflictError, + StorageQuotaExceededError, + VersionLimitExceededError, + PaymentError, +) + + +class TestExceptions: + def test_not_found_default(self): + exc = NotFoundError() + assert exc.status_code == status.HTTP_404_NOT_FOUND + assert exc.detail == "Resource not found" + + def test_not_found_custom(self): + exc = NotFoundError("Addon not found") + assert exc.detail == "Addon not found" + + def test_unauthorized_default(self): + exc = UnauthorizedError() + assert exc.status_code == status.HTTP_401_UNAUTHORIZED + assert exc.detail == "Not authenticated" + + def test_unauthorized_has_header(self): + exc = UnauthorizedError() + assert exc.headers == {"WWW-Authenticate": "Bearer"} + + def test_forbidden(self): + exc = ForbiddenError() + assert exc.status_code == status.HTTP_403_FORBIDDEN + + def test_bad_request(self): + exc = BadRequestError("Invalid input") + assert exc.status_code == status.HTTP_400_BAD_REQUEST + assert exc.detail == "Invalid input" + + def test_conflict(self): + exc = ConflictError() + assert exc.status_code == status.HTTP_409_CONFLICT + + def test_storage_quota(self): + exc = StorageQuotaExceededError() + assert exc.status_code == status.HTTP_402_PAYMENT_REQUIRED + assert "Storage quota" in exc.detail + + def test_version_limit(self): + exc = VersionLimitExceededError() + assert exc.status_code == status.HTTP_402_PAYMENT_REQUIRED + assert "Version history limit" in exc.detail + + def test_payment_error(self): + exc = PaymentError("Card declined") + assert exc.status_code == status.HTTP_402_PAYMENT_REQUIRED + assert exc.detail == "Card declined" diff --git a/plexaddons-api/tests/test_models.py b/plexaddons-api/tests/test_models.py new file mode 100644 index 0000000..a6a4705 --- /dev/null +++ b/plexaddons-api/tests/test_models.py @@ -0,0 +1,106 @@ +"""Tests for model enums and basic model construction.""" +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only") +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///test.db") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15") +os.environ.setdefault("DISCORD_CLIENT_ID", "test") +os.environ.setdefault("DISCORD_CLIENT_SECRET", "test") +os.environ.setdefault("DISCORD_REDIRECT_URI", "http://localhost/callback") +os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_fake") +os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "whsec_test") +os.environ.setdefault("STRIPE_PRO_PRICE_ID", "price_pro") +os.environ.setdefault("STRIPE_PREMIUM_PRICE_ID", "price_premium") +os.environ.setdefault("PAYPAL_CLIENT_ID", "test") +os.environ.setdefault("PAYPAL_CLIENT_SECRET", "test") +os.environ.setdefault("PAYPAL_WEBHOOK_ID", "test") +os.environ.setdefault("PAYPAL_PRO_PLAN_ID", "P-test") +os.environ.setdefault("PAYPAL_PREMIUM_PLAN_ID", "P-test") + +from app.models import ( + SubscriptionTier, + SubscriptionStatus, + PaymentProvider, + AddonTag, + ReleaseChannel, + OrganizationRole, + CollaboratorRole, + ApiKeyScope, +) + + +class TestSubscriptionTier: + def test_values(self): + assert SubscriptionTier.FREE.value == "free" + assert SubscriptionTier.PRO.value == "pro" + assert SubscriptionTier.PREMIUM.value == "premium" + + def test_count(self): + assert len(SubscriptionTier) == 3 + + def test_is_str(self): + assert isinstance(SubscriptionTier.FREE, str) + + +class TestSubscriptionStatus: + def test_active(self): + assert SubscriptionStatus.ACTIVE.value == "active" + + def test_all_statuses(self): + expected = { + "active", "past_due", "canceled", "unpaid", + "trialing", "paused", "incomplete", "incomplete_expired", + } + assert {s.value for s in SubscriptionStatus} == expected + + +class TestPaymentProvider: + def test_providers(self): + assert PaymentProvider.STRIPE.value == "stripe" + assert PaymentProvider.PAYPAL.value == "paypal" + + +class TestAddonTag: + def test_has_utility(self): + assert AddonTag.UTILITY.value == "utility" + + def test_count(self): + assert len(AddonTag) == 11 + + def test_all_are_strings(self): + for tag in AddonTag: + assert isinstance(tag, str) + + +class TestReleaseChannel: + def test_channels(self): + assert ReleaseChannel.STABLE.value == "stable" + assert ReleaseChannel.BETA.value == "beta" + assert ReleaseChannel.ALPHA.value == "alpha" + + +class TestOrganizationRole: + def test_roles(self): + assert OrganizationRole.OWNER.value == "owner" + assert OrganizationRole.ADMIN.value == "admin" + assert OrganizationRole.MEMBER.value == "member" + + +class TestCollaboratorRole: + def test_roles(self): + assert CollaboratorRole.ADMIN.value == "admin" + assert CollaboratorRole.EDITOR.value == "editor" + assert CollaboratorRole.VIEWER.value == "viewer" + + +class TestApiKeyScope: + def test_read_scopes(self): + assert "read" in ApiKeyScope.ADDONS_READ.value + assert "read" in ApiKeyScope.VERSIONS_READ.value + assert "read" in ApiKeyScope.ANALYTICS_READ.value + + def test_write_scopes(self): + assert "write" in ApiKeyScope.VERSIONS_WRITE.value + assert "write" in ApiKeyScope.ADDONS_WRITE.value + + def test_full_access(self): + assert ApiKeyScope.FULL_ACCESS.value == "full:access" diff --git a/plexaddons-api/tests/test_schemas.py b/plexaddons-api/tests/test_schemas.py new file mode 100644 index 0000000..be3d788 --- /dev/null +++ b/plexaddons-api/tests/test_schemas.py @@ -0,0 +1,131 @@ +"""Tests for Pydantic schema validation.""" +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only") +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///test.db") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15") +os.environ.setdefault("DISCORD_CLIENT_ID", "test") +os.environ.setdefault("DISCORD_CLIENT_SECRET", "test") +os.environ.setdefault("DISCORD_REDIRECT_URI", "http://localhost/callback") +os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_fake") +os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "whsec_test") +os.environ.setdefault("STRIPE_PRO_PRICE_ID", "price_pro") +os.environ.setdefault("STRIPE_PREMIUM_PRICE_ID", "price_premium") +os.environ.setdefault("PAYPAL_CLIENT_ID", "test") +os.environ.setdefault("PAYPAL_CLIENT_SECRET", "test") +os.environ.setdefault("PAYPAL_WEBHOOK_ID", "test") +os.environ.setdefault("PAYPAL_PRO_PLAN_ID", "P-test") +os.environ.setdefault("PAYPAL_PREMIUM_PLAN_ID", "P-test") + +import pytest +from pydantic import ValidationError +from app.schemas import ( + AddonCreate, + AddonUpdate, + UserProfileUpdate, + UserUpdate, +) +from app.models import AddonTag + + +class TestAddonCreate: + def test_valid_minimal(self): + addon = AddonCreate(name="My Addon") + assert addon.name == "My Addon" + assert addon.description is None + assert addon.tags == [] + + def test_valid_full(self): + addon = AddonCreate( + name="Cool Addon", + description="A cool addon", + homepage="https://example.com", + external=True, + tags=[AddonTag.UTILITY, AddonTag.MEDIA], + ) + assert addon.external is True + assert len(addon.tags) == 2 + + def test_name_too_short(self): + with pytest.raises(ValidationError): + AddonCreate(name="") + + def test_name_too_long(self): + with pytest.raises(ValidationError): + AddonCreate(name="x" * 101) + + +class TestAddonUpdate: + def test_all_optional(self): + update = AddonUpdate() + assert update.name is None + assert update.is_active is None + + def test_valid_partial(self): + update = AddonUpdate(name="New Name", is_public=True) + assert update.name == "New Name" + assert update.is_public is True + assert update.description is None + + def test_accent_color_valid(self): + update = AddonUpdate(theme_accent_color="#FF5500") + assert update.theme_accent_color == "#FF5500" + + def test_accent_color_invalid(self): + with pytest.raises(ValidationError): + AddonUpdate(theme_accent_color="red") + + def test_accent_color_invalid_short(self): + with pytest.raises(ValidationError): + AddonUpdate(theme_accent_color="#FFF") + + def test_screenshots_max(self): + update = AddonUpdate(screenshots=["a.png"] * 6) + assert len(update.screenshots) == 6 + + def test_screenshots_too_many(self): + with pytest.raises(ValidationError): + AddonUpdate(screenshots=["a.png"] * 7) + + +class TestUserProfileUpdate: + def test_valid_slug(self): + profile = UserProfileUpdate(profile_slug="my-profile_1") + assert profile.profile_slug == "my-profile_1" + + def test_slug_too_short(self): + with pytest.raises(ValidationError): + UserProfileUpdate(profile_slug="ab") + + def test_slug_invalid_chars(self): + with pytest.raises(ValidationError): + UserProfileUpdate(profile_slug="bad slug!") + + def test_accent_color_valid(self): + profile = UserProfileUpdate(accent_color="#AABBCC") + assert profile.accent_color == "#AABBCC" + + def test_accent_color_invalid(self): + with pytest.raises(ValidationError): + UserProfileUpdate(accent_color="notahex") + + def test_bio_max_length(self): + profile = UserProfileUpdate(bio="x" * 500) + assert len(profile.bio) == 500 + + def test_bio_too_long(self): + with pytest.raises(ValidationError): + UserProfileUpdate(bio="x" * 501) + + +class TestUserUpdate: + def test_valid_email(self): + update = UserUpdate(email="user@example.com") + assert update.email == "user@example.com" + + def test_invalid_email(self): + with pytest.raises(ValidationError): + UserUpdate(email="not-an-email") + + def test_none_email(self): + update = UserUpdate(email=None) + assert update.email is None diff --git a/plexaddons-api/tests/test_security.py b/plexaddons-api/tests/test_security.py new file mode 100644 index 0000000..ccf39ac --- /dev/null +++ b/plexaddons-api/tests/test_security.py @@ -0,0 +1,57 @@ +"""Tests for security utilities (JWT tokens).""" +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only") +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///test.db") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/15") +os.environ.setdefault("DISCORD_CLIENT_ID", "test") +os.environ.setdefault("DISCORD_CLIENT_SECRET", "test") +os.environ.setdefault("DISCORD_REDIRECT_URI", "http://localhost/callback") +os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_fake") +os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "whsec_test") +os.environ.setdefault("STRIPE_PRO_PRICE_ID", "price_pro") +os.environ.setdefault("STRIPE_PREMIUM_PRICE_ID", "price_premium") +os.environ.setdefault("PAYPAL_CLIENT_ID", "test") +os.environ.setdefault("PAYPAL_CLIENT_SECRET", "test") +os.environ.setdefault("PAYPAL_WEBHOOK_ID", "test") +os.environ.setdefault("PAYPAL_PRO_PLAN_ID", "P-test") +os.environ.setdefault("PAYPAL_PREMIUM_PLAN_ID", "P-test") + +from datetime import timedelta +from app.core.security import create_access_token, decode_access_token + + +class TestJWT: + def test_create_and_decode(self): + token = create_access_token({"sub": "42"}) + payload = decode_access_token(token) + assert payload is not None + assert payload["sub"] == "42" + + def test_custom_expiry(self): + token = create_access_token({"sub": "1"}, expires_delta=timedelta(hours=2)) + payload = decode_access_token(token) + assert payload is not None + assert payload["sub"] == "1" + + def test_expired_token(self): + token = create_access_token({"sub": "42"}, expires_delta=timedelta(seconds=-1)) + payload = decode_access_token(token) + assert payload is None + + def test_invalid_token(self): + payload = decode_access_token("not.a.valid.token") + assert payload is None + + def test_empty_token(self): + payload = decode_access_token("") + assert payload is None + + def test_payload_has_exp(self): + token = create_access_token({"sub": "1"}) + payload = decode_access_token(token) + assert "exp" in payload + + def test_extra_claims(self): + token = create_access_token({"sub": "1", "role": "admin"}) + payload = decode_access_token(token) + assert payload["role"] == "admin" diff --git a/plexaddons-api/tests/test_semver.py b/plexaddons-api/tests/test_semver.py new file mode 100644 index 0000000..a0c9f11 --- /dev/null +++ b/plexaddons-api/tests/test_semver.py @@ -0,0 +1,72 @@ +"""Tests for semver utilities.""" +from app.utils.semver import parse_version, compare_versions, is_valid_version + + +class TestParseVersion: + def test_basic(self): + assert parse_version("1.2.3") == (1, 2, 3) + + def test_zero(self): + assert parse_version("0.0.0") == (0, 0, 0) + + def test_large(self): + assert parse_version("100.200.300") == (100, 200, 300) + + def test_prerelease(self): + assert parse_version("1.2.3-beta.1") == (1, 2, 3) + + def test_build_metadata(self): + assert parse_version("1.2.3+build.42") == (1, 2, 3) + + def test_prerelease_and_build(self): + assert parse_version("1.2.3-alpha+build") == (1, 2, 3) + + def test_invalid_no_patch(self): + assert parse_version("1.2") is None + + def test_invalid_text(self): + assert parse_version("latest") is None + + def test_invalid_empty(self): + assert parse_version("") is None + + def test_whitespace_stripped(self): + assert parse_version(" 1.0.0 ") == (1, 0, 0) + + +class TestCompareVersions: + def test_equal(self): + assert compare_versions("1.0.0", "1.0.0") == 0 + + def test_major_greater(self): + assert compare_versions("2.0.0", "1.0.0") == 1 + + def test_major_less(self): + assert compare_versions("1.0.0", "2.0.0") == -1 + + def test_minor_greater(self): + assert compare_versions("1.1.0", "1.0.0") == 1 + + def test_patch_greater(self): + assert compare_versions("1.0.1", "1.0.0") == 1 + + def test_ten_vs_nine(self): + """Ensure numeric comparison, not string: 10 > 9.""" + assert compare_versions("10.0.0", "9.0.0") == 1 + + def test_invalid_returns_zero(self): + assert compare_versions("invalid", "1.0.0") == 0 + + +class TestIsValidVersion: + def test_valid(self): + assert is_valid_version("1.0.0") is True + + def test_valid_prerelease(self): + assert is_valid_version("1.0.0-beta") is True + + def test_invalid(self): + assert is_valid_version("not-a-version") is False + + def test_partial(self): + assert is_valid_version("1.0") is False diff --git a/plexaddons-api/tests/test_utils.py b/plexaddons-api/tests/test_utils.py new file mode 100644 index 0000000..9488c39 --- /dev/null +++ b/plexaddons-api/tests/test_utils.py @@ -0,0 +1,64 @@ +"""Tests for utility functions.""" +from app.utils import slugify, calculate_storage_size, format_bytes + + +class TestSlugify: + def test_basic(self): + assert slugify("Hello World") == "hello-world" + + def test_special_characters(self): + assert slugify("My Addon! @v2") == "my-addon-v2" + + def test_underscores(self): + assert slugify("my_addon_name") == "my-addon-name" + + def test_multiple_spaces(self): + assert slugify("too many spaces") == "too-many-spaces" + + def test_leading_trailing(self): + assert slugify("--trimmed--") == "trimmed" + + def test_unicode(self): + assert slugify("café résumé") == "caf-rsum" + + def test_empty(self): + assert slugify("") == "" + + def test_numbers_only(self): + assert slugify("123") == "123" + + def test_already_slug(self): + assert slugify("already-a-slug") == "already-a-slug" + + +class TestCalculateStorageSize: + def test_ascii(self): + assert calculate_storage_size("hello") == 5 + + def test_empty(self): + assert calculate_storage_size("") == 0 + + def test_none(self): + assert calculate_storage_size(None) == 0 + + def test_unicode(self): + # Multi-byte UTF-8 characters + size = calculate_storage_size("日本語") + assert size == 9 # 3 bytes per CJK character + + +class TestFormatBytes: + def test_bytes(self): + assert format_bytes(500) == "500.00 B" + + def test_kb(self): + assert format_bytes(1024) == "1.00 KB" + + def test_mb(self): + assert format_bytes(1024 * 1024) == "1.00 MB" + + def test_gb(self): + assert format_bytes(1024 ** 3) == "1.00 GB" + + def test_tb(self): + assert format_bytes(1024 ** 4) == "1.00 TB" diff --git a/plexaddons-web/e2e/smoke.spec.ts b/plexaddons-web/e2e/smoke.spec.ts new file mode 100644 index 0000000..c0631d0 --- /dev/null +++ b/plexaddons-web/e2e/smoke.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Homepage', () => { + test('loads and shows title', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/PlexAddons/i); + }); + + test('has navigation links', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('link', { name: /addons/i })).toBeVisible(); + }); + + test('can navigate to addons page', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: /addons/i }).first().click(); + await expect(page).toHaveURL(/\/addons/); + }); +}); + +test.describe('Addons Page', () => { + test('loads addon listing', async ({ page }) => { + await page.goto('/addons'); + await expect(page.locator('h1, h2').first()).toBeVisible(); + }); + + test('has search functionality', async ({ page }) => { + await page.goto('/addons'); + const searchInput = page.getByPlaceholder(/search/i); + if (await searchInput.isVisible()) { + await searchInput.fill('test'); + // Verify search triggers (no error) + await expect(searchInput).toHaveValue('test'); + } + }); +}); + +test.describe('Auth', () => { + test('login page loads', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator('body')).toBeVisible(); + }); + + test('unauthenticated user redirected from dashboard', async ({ page }) => { + await page.goto('/dashboard'); + // Should redirect to login + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/plexaddons-web/nginx.conf b/plexaddons-web/nginx.conf index 19d963c..f0343d1 100644 --- a/plexaddons-web/nginx.conf +++ b/plexaddons-web/nginx.conf @@ -43,12 +43,18 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Static files caching + # Static files caching (CDN-friendly immutable hashes from Vite build) location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff"; } + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # SPA fallback - all routes go to index.html location / { try_files $uri $uri/ /index.html; diff --git a/plexaddons-web/package-lock.json b/plexaddons-web/package-lock.json index 94b94cf..ef3bc41 100644 --- a/plexaddons-web/package-lock.json +++ b/plexaddons-web/package-lock.json @@ -11,8 +11,7 @@ "@paypal/react-paypal-js": "^8.1.3", "@stripe/react-stripe-js": "^2.4.0", "@stripe/stripe-js": "^2.2.0", - "clsx": "^2.1.0", - "date-fns": "^3.0.6", + "@tanstack/react-query": "^5.90.21", "highlight.js": "^11.11.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -24,13 +23,45 @@ "sonner": "^2.0.7" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^24.0.0", "typescript": "^5.5.3", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^1.3.1" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -62,7 +93,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -266,6 +296,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -314,6 +354,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -705,6 +860,19 @@ "node": ">=12" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1111,6 +1279,13 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@stripe/react-stripe-js": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.9.0.tgz", @@ -1129,8 +1304,116 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.4.0.tgz", "integrity": "sha512-WFkQx1mbs2b5+7looI9IV1BLa3bIApuN3ehp9FP58xGg7KL9hCHDECgW3BwO9l9L+xBPVAD7Yjn1EhGe6EDTeA==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1236,7 +1519,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1285,106 +1567,467 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://opencollective.com/vitest" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", - "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" }, - "bin": { - "browserslist": "cli.js" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "license": "MIT" }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://opencollective.com/vitest" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://opencollective.com/vitest" } }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "node_modules/@vitest/utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "license": "MIT", "funding": { "type": "github", @@ -1411,13 +2054,50 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, "engines": { - "node": ">=6" + "node": "*" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" } }, "node_modules/comma-separated-tokens": { @@ -1430,10 +2110,60 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, @@ -1443,14 +2173,18 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/debug": { @@ -1470,6 +2204,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -1483,6 +2224,98 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1505,6 +2338,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.262", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", @@ -1512,6 +2377,89 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1579,39 +2527,279 @@ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 0.4" } }, "node_modules/hast-util-is-element": { @@ -1694,103 +2882,508 @@ "@types/hast": "^3.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hi-base32": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", + "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", + "license": "MIT" + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hi-base32": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", - "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", - "license": "MIT" - }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "license": "MIT", "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1817,6 +3410,23 @@ "node": ">=6" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -1839,6 +3449,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -1864,6 +3484,26 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -1874,6 +3514,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -2144,6 +3794,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -2707,45 +4364,240 @@ ], "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parse-entities": { @@ -2773,6 +4625,46 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2780,6 +4672,35 @@ "dev": true, "license": "ISC" }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2809,6 +4730,41 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/promise-polyfill": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", @@ -2836,12 +4792,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2854,7 +4839,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2938,6 +4922,41 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rehype-highlight": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", @@ -3035,6 +5054,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -3077,6 +5103,51 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3096,6 +5167,159 @@ "semver": "bin/semver.js" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -3121,9 +5345,37 @@ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/stringify-entities": { @@ -3140,6 +5392,52 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -3158,6 +5456,82 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -3178,6 +5552,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3192,6 +5576,13 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -3293,6 +5684,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -3324,6 +5725,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -3358,7 +5770,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3413,6 +5824,289 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3420,6 +6114,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/plexaddons-web/package.json b/plexaddons-web/package.json index f6aa005..52bb8b4 100644 --- a/plexaddons-web/package.json +++ b/plexaddons-web/package.json @@ -7,14 +7,15 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@paypal/react-paypal-js": "^8.1.3", "@stripe/react-stripe-js": "^2.4.0", "@stripe/stripe-js": "^2.2.0", - "clsx": "^2.1.0", - "date-fns": "^3.0.6", + "@tanstack/react-query": "^5.90.21", "highlight.js": "^11.11.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -26,10 +27,14 @@ "sonner": "^2.0.7" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^24.0.0", "typescript": "^5.5.3", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^1.3.1" } } diff --git a/plexaddons-web/playwright.config.ts b/plexaddons-web/playwright.config.ts new file mode 100644 index 0000000..cc30362 --- /dev/null +++ b/plexaddons-web/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: process.env.BASE_URL || 'http://localhost:3310', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + /* Start the dev server before running tests if needed */ + // webServer: { + // command: 'docker compose up -d', + // url: 'http://localhost:3310', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/plexaddons-web/public/favicon.svg b/plexaddons-web/public/favicon.svg new file mode 100644 index 0000000..bfb5a05 --- /dev/null +++ b/plexaddons-web/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + P + diff --git a/plexaddons-web/src/App.tsx b/plexaddons-web/src/App.tsx index 35dcaca..1dd038c 100644 --- a/plexaddons-web/src/App.tsx +++ b/plexaddons-web/src/App.tsx @@ -4,6 +4,7 @@ import Layout from './components/Layout' import Home from './pages/Home' import Addons from './pages/Addons' import AddonDetail from './pages/AddonDetail' +import { CategoriesIndex, CategoryAddons } from './pages/Categories' import Users from './pages/Users' import Pricing from './pages/Pricing' import Docs from './pages/Docs' @@ -28,6 +29,7 @@ import Support from './pages/dashboard/Support' import NewTicket from './pages/dashboard/NewTicket' import TicketDetail from './pages/dashboard/TicketDetail' import Organizations from './pages/dashboard/Organizations' +import OrgPublicPage from './pages/OrgPublicPage' import AdminDashboard from './pages/admin/AdminDashboard' import AdminUsers from './pages/admin/AdminUsers' import AdminAddons from './pages/admin/AdminAddons' @@ -36,6 +38,7 @@ import AdminAuditLog from './pages/admin/AdminAuditLog' import AdminTickets from './pages/admin/AdminTickets' import AdminTicketDetail from './pages/admin/AdminTicketDetail' import AdminCannedResponses from './pages/admin/AdminCannedResponses' +import NotFound from './pages/NotFound' import ProtectedRoute from './components/ProtectedRoute' import AdminRoute from './components/AdminRoute' @@ -60,7 +63,9 @@ export default function App() { {/* Public routes */} } /> } /> + } /> } /> + } /> } /> } /> } /> @@ -78,6 +83,7 @@ export default function App() { } /> } /> } /> + } /> {/* Protected routes */} }> @@ -107,6 +113,9 @@ export default function App() { } /> } /> + + {/* Catch-all 404 */} + } /> diff --git a/plexaddons-web/src/components/AdminRoute.test.tsx b/plexaddons-web/src/components/AdminRoute.test.tsx new file mode 100644 index 0000000..af00b00 --- /dev/null +++ b/plexaddons-web/src/components/AdminRoute.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const mockUseAuth = vi.fn(); +vi.mock('../context/AuthContext', () => ({ + useAuth: () => mockUseAuth(), +})); + +import AdminRoute from './AdminRoute'; + +function renderAdmin(path = '/admin') { + return render( + + + Login Page} /> + Dashboard} /> + }> + Admin Panel} /> + + + + ); +} + +describe('AdminRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows spinner while loading', () => { + mockUseAuth.mockReturnValue({ user: null, isAuthenticated: false, isLoading: true }); + const { container } = renderAdmin(); + expect(container.querySelector('.spinner')).toBeInTheDocument(); + }); + + it('redirects to login when not authenticated', () => { + mockUseAuth.mockReturnValue({ user: null, isAuthenticated: false, isLoading: false }); + renderAdmin(); + expect(screen.getByText('Login Page')).toBeInTheDocument(); + }); + + it('redirects to dashboard when authenticated but not admin', () => { + mockUseAuth.mockReturnValue({ + user: { is_admin: false }, + isAuthenticated: true, + isLoading: false, + }); + renderAdmin(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + it('renders admin content when authenticated as admin', () => { + mockUseAuth.mockReturnValue({ + user: { is_admin: true }, + isAuthenticated: true, + isLoading: false, + }); + renderAdmin(); + expect(screen.getByText('Admin Panel')).toBeInTheDocument(); + }); +}); diff --git a/plexaddons-web/src/components/ApiKeysManager.tsx b/plexaddons-web/src/components/ApiKeysManager.tsx index a4341d0..c30288b 100644 --- a/plexaddons-web/src/components/ApiKeysManager.tsx +++ b/plexaddons-web/src/components/ApiKeysManager.tsx @@ -239,11 +239,11 @@ export default function ApiKeysManager() {
- 🕐 {formatLastUsed(key.last_used_at)} - 📊 {key.usage_count} requests - 📅 {formatDate(key.created_at)} + Last used {formatLastUsed(key.last_used_at)} + {key.usage_count} requests + Created {formatDate(key.created_at)} {key.expires_at && ( - ⏰ Expires {formatDate(key.expires_at)} + Expires {formatDate(key.expires_at)} )}
diff --git a/plexaddons-web/src/components/Collaborators.css b/plexaddons-web/src/components/Collaborators.css new file mode 100644 index 0000000..7a8b56f --- /dev/null +++ b/plexaddons-web/src/components/Collaborators.css @@ -0,0 +1,142 @@ +.collaborators-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + margin-top: 1.5rem; +} + +.collaborators-section h3 { + font-size: 1.1rem; + margin: 0 0 1rem; +} + +.pro-hint { + color: var(--text-muted); + font-size: 0.9rem; +} + +.loading-text { + color: var(--text-muted); + font-size: 0.9rem; +} + +/* Invite Form */ +.invite-form { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + align-items: center; +} + +.invite-input { + flex: 1; + max-width: 150px; + padding: 0.5rem 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: calc(var(--radius) / 2); + color: var(--text); + font-size: 0.9rem; +} + +.invite-input:focus { + outline: none; + border-color: var(--accent); +} + +.invite-select { + padding: 0.5rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: calc(var(--radius) / 2); + color: var(--text); + font-size: 0.9rem; +} + +/* Collaborators List */ +.collaborators-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.collaborator-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: calc(var(--radius) / 2); +} + +.collab-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.collab-avatar { + width: 32px; + height: 32px; + border-radius: 50%; +} + +.collab-details { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.collab-name { + font-weight: 500; + font-size: 0.9rem; +} + +.collab-status { + font-size: 0.75rem; + text-transform: capitalize; +} + +.collab-status.accepted { + color: #22c55e; +} + +.collab-status.pending { + color: #f59e0b; +} + +.collab-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.role-select { + padding: 0.35rem 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: calc(var(--radius) / 2); + color: var(--text); + font-size: 0.8rem; +} + +.btn-danger { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: calc(var(--radius) / 2); + cursor: pointer; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.2); +} + +.no-collaborators { + color: var(--text-muted); + font-size: 0.9rem; +} diff --git a/plexaddons-web/src/components/CollaboratorsManager.tsx b/plexaddons-web/src/components/CollaboratorsManager.tsx new file mode 100644 index 0000000..5a2e21a --- /dev/null +++ b/plexaddons-web/src/components/CollaboratorsManager.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { api } from '../services/api'; +import { useAuth } from '../context/AuthContext'; +import type { Collaborator, CollaboratorRole } from '../types'; +import './Collaborators.css'; + +interface CollaboratorsManagerProps { + addonId: number; + isOwner: boolean; +} + +export default function CollaboratorsManager({ addonId, isOwner }: CollaboratorsManagerProps) { + const { user } = useAuth(); + const [collaborators, setCollaborators] = useState([]); + const [loading, setLoading] = useState(true); + const [inviteUserId, setInviteUserId] = useState(''); + const [inviteRole, setInviteRole] = useState('editor'); + const [inviting, setInviting] = useState(false); + + const isPro = user?.subscription_tier === 'pro' || user?.subscription_tier === 'premium'; + + useEffect(() => { + loadCollaborators(); + }, [addonId]); + + const loadCollaborators = async () => { + try { + setLoading(true); + const data = await api.listCollaborators(addonId); + setCollaborators(data); + } catch { + // May not have access + } finally { + setLoading(false); + } + }; + + const handleInvite = async () => { + const userId = parseInt(inviteUserId); + if (!userId || isNaN(userId)) { + toast.error('Please enter a valid user ID'); + return; + } + try { + setInviting(true); + await api.inviteCollaborator(addonId, userId, inviteRole); + toast.success('Invitation sent'); + setInviteUserId(''); + loadCollaborators(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to invite'); + } finally { + setInviting(false); + } + }; + + const handleUpdateRole = async (collaboratorId: number, role: CollaboratorRole) => { + try { + await api.updateCollaborator(addonId, collaboratorId, role); + toast.success('Role updated'); + loadCollaborators(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update role'); + } + }; + + const handleRemove = async (collaboratorId: number) => { + if (!confirm('Remove this collaborator?')) return; + try { + await api.removeCollaborator(addonId, collaboratorId); + toast.success('Collaborator removed'); + loadCollaborators(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to remove'); + } + }; + + if (!isPro) { + return ( +
+

Collaborators

+

Upgrade to Pro to invite collaborators to manage your addons.

+
+ ); + } + + if (loading) { + return ( +
+

Collaborators

+

Loading...

+
+ ); + } + + return ( +
+

Collaborators

+ + {/* Invite form (owner only) */} + {isOwner && ( +
+ setInviteUserId(e.target.value)} + className="invite-input" + /> + + +
+ )} + + {/* Collaborators list */} + {collaborators.length > 0 ? ( +
+ {collaborators.map((collab) => ( +
+
+ {collab.avatar && ( + + )} +
+ + {collab.display_name || collab.username || `User #${collab.user_id}`} + + + {collab.accepted ? collab.role : 'Pending invitation'} + +
+
+ {isOwner && ( +
+ + +
+ )} +
+ ))} +
+ ) : ( +

No collaborators yet. Invite users to help manage this addon.

+ )} +
+ ); +} diff --git a/plexaddons-web/src/components/FeatureFlagManager.css b/plexaddons-web/src/components/FeatureFlagManager.css new file mode 100644 index 0000000..772b989 --- /dev/null +++ b/plexaddons-web/src/components/FeatureFlagManager.css @@ -0,0 +1,115 @@ +.flag-manager { + margin-top: 2rem; + padding: 1.5rem; + background: var(--card-bg, #1a1a2e); + border-radius: 12px; + border: 1px solid var(--border-color, #2a2a4a); +} + +.flag-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.flag-header h3 { + margin: 0; +} + +.flag-create-form { + background: var(--bg-secondary, #16162a); + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.flags-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.flag-card { + background: var(--bg-secondary, #16162a); + padding: 1rem; + border-radius: 8px; + border: 1px solid var(--border-color, #2a2a4a); +} + +.flag-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.flag-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.flag-key { + font-family: monospace; + background: var(--card-bg, #1a1a2e); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85rem; +} + +.flag-name { + font-weight: 500; +} + +.flag-toggle { + padding: 4px 12px; + border-radius: 12px; + border: none; + cursor: pointer; + font-weight: 600; + font-size: 0.8rem; + transition: all 0.2s; +} + +.flag-toggle-on { + background: #10b981; + color: white; +} + +.flag-toggle-off { + background: var(--border-color, #2a2a4a); + color: var(--text-secondary, #a0a0c0); +} + +.flag-description { + font-size: 0.85rem; + color: var(--text-secondary, #a0a0c0); + margin: 0 0 0.5rem 0; +} + +.flag-meta { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.8rem; + color: var(--text-secondary, #a0a0c0); + flex-wrap: wrap; +} + +.flag-percentage-slider { + width: 100px; + cursor: pointer; +} + +.flag-date { + margin-left: auto; +} + +.no-flags { + color: var(--text-secondary, #a0a0c0); + font-style: italic; +} diff --git a/plexaddons-web/src/components/FeatureFlagManager.tsx b/plexaddons-web/src/components/FeatureFlagManager.tsx new file mode 100644 index 0000000..cff2408 --- /dev/null +++ b/plexaddons-web/src/components/FeatureFlagManager.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { api } from '../services/api'; +import type { FeatureFlag, FeatureFlagCreate } from '../types'; +import './FeatureFlagManager.css'; + +interface Props { + addonId: number; +} + +export default function FeatureFlagManager({ addonId }: Props) { + const [flags, setFlags] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [key, setKey] = useState(''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [percentage, setPercentage] = useState(100); + const [creating, setCreating] = useState(false); + + useEffect(() => { + loadFlags(); + }, [addonId]); + + const loadFlags = async () => { + try { + setLoading(true); + const data = await api.listFlags(addonId); + setFlags(data.flags); + } catch (err) { + toast.error('Failed to load feature flags'); + } finally { + setLoading(false); + } + }; + + const handleCreate = async () => { + if (!key.trim() || !name.trim()) { + toast.error('Key and name are required'); + return; + } + try { + setCreating(true); + const data: FeatureFlagCreate = { + key: key.trim(), + name: name.trim(), + description: description.trim() || undefined, + enabled: false, + percentage, + }; + await api.createFlag(addonId, data); + toast.success('Feature flag created'); + setShowCreate(false); + setKey(''); + setName(''); + setDescription(''); + setPercentage(100); + await loadFlags(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create flag'); + } finally { + setCreating(false); + } + }; + + const handleToggle = async (flag: FeatureFlag) => { + try { + await api.updateFlag(addonId, flag.id, { enabled: !flag.enabled }); + toast.success(`Flag ${flag.enabled ? 'disabled' : 'enabled'}`); + await loadFlags(); + } catch (err) { + toast.error('Failed to update flag'); + } + }; + + const handleDelete = async (flagId: number) => { + if (!confirm('Delete this feature flag?')) return; + try { + await api.deleteFlag(addonId, flagId); + toast.success('Flag deleted'); + await loadFlags(); + } catch (err) { + toast.error('Failed to delete flag'); + } + }; + + const handlePercentageChange = async (flag: FeatureFlag, newPercent: number) => { + try { + await api.updateFlag(addonId, flag.id, { percentage: newPercent }); + toast.success('Percentage updated'); + await loadFlags(); + } catch (err) { + toast.error('Failed to update percentage'); + } + }; + + if (loading) return

Loading feature flags...

; + + return ( +
+
+

Feature Flags

+ +
+ + {showCreate && ( +
+
+ + setKey(e.target.value)} + pattern="^[a-zA-Z0-9_.\-]+$" + /> +
+
+ + setName(e.target.value)} + /> +
+
+ +