From e06a43c76f2b587b7e61827b2ec1b1a512d34b9f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 4 Sep 2026 13:53:46 +0000 Subject: [PATCH] fix(deploy): remove private source checkout from customer installer --- DEPLOYMENT.md | 19 ++ README.md | 94 +++++- SHA256SUMS | 6 + VERSION | 1 + compose/e.yml | 126 ++++++++ compose/s.yml | 176 +++++++++++ config/e-server.production.yaml | 35 +++ install.sh | 293 ++++++++++++++---- s-migrate.sh | 28 ++ s-migrations.tar.gz | Bin 0 -> 17494 bytes s-migrations/000001_s1_s3.up.sql | 81 +++++ s-migrations/000002_s4_s7.up.sql | 64 ++++ s-migrations/000003_user_management.up.sql | 21 ++ s-migrations/000004_access_revocation.up.sql | 2 + .../000005_internal_auth_nonces.up.sql | 6 + ...00006_e_contract_playback_decisions.up.sql | 22 ++ .../000007_external_auth_license_cache.up.sql | 16 + s-migrations/000008_gateway_routing.up.sql | 86 +++++ s-migrations/000009_gateway_operations.up.sql | 19 ++ .../000010_license_entitlements.up.sql | 10 + .../000011_e_address_directory.up.sql | 75 +++++ s-migrations/000012_remove_gateway.up.sql | 48 +++ .../000013_e_address_observability.up.sql | 22 ++ s-migrations/000014_operations_closure.up.sql | 218 +++++++++++++ ...000015_e_address_refresh_rate_limit.up.sql | 2 + s-migrations/000016_system_operations.up.sql | 90 ++++++ ...17_notifications_security_lifecycle.up.sql | 124 ++++++++ s-migrations/000018_initial_setup.up.sql | 9 + .../000019_e_address_group_rules.up.sql | 6 + .../000020_playback_session_context.up.sql | 17 + s-migrations/000021_subtitle_sources.up.sql | 55 ++++ ...00022_metadata_provider_credentials.up.sql | 10 + .../000023_user_engagement_and_bot.up.sql | 80 +++++ s-migrations/000024_daily_checkins.up.sql | 20 ++ ...0025_user_store_calendar_monitoring.up.sql | 34 ++ ...00026_telegram_brand_and_chat_state.up.sql | 9 + .../000027_site_brand_settings.up.sql | 17 + .../000028_media_request_metadata.up.sql | 13 + .../000029_support_attachments.up.sql | 15 + .../000030_user_password_and_banner.up.sql | 14 + s-migrations/000031_login_background.up.sql | 3 + .../000032_branding_image_limits.up.sql | 9 + .../000033_negative_point_balances.up.sql | 5 + ...embership_limits_points_red_packets.up.sql | 54 ++++ ...000035_plugin_management_permission.up.sql | 17 + .../000036_plugin_service_actor.up.sql | 14 + s-migrations/000037_user_groups.up.sql | 34 ++ s-migrations/000038_store_settings.up.sql | 12 + ...000039_telegram_command_auto_delete.up.sql | 3 + ...enable_telegram_command_auto_delete.up.sql | 9 + ...041_web_logins_not_playback_devices.up.sql | 10 + s-migrations/000042_login_protection.up.sql | 11 + s-migrations/000043_plugin_v11.up.sql | 104 +++++++ .../000044_telegram_membership_signup.up.sql | 14 + ...00045_plugin_telegram_inbound_media.up.sql | 8 + .../000046_plugin_telegram_chat_type.up.sql | 2 + ...000047_plugin_telegram_send_message.up.sql | 3 + .../000048_telegram_pro_user_duration.up.sql | 3 + .../000049_telegram_group_cleanup.up.sql | 21 ++ ...000050_telegram_rebind_after_unbind.up.sql | 7 + .../000051_storage_webhook_events.up.sql | 13 + .../000052_user_agent_security_rules.up.sql | 3 + .../000053_precise_client_access_rules.up.sql | 3 + s-migrations/000054_api_keys.up.sql | 13 + s-migrations/000055_playback_traffic.up.sql | 3 + s-migrations/000056_playback_estimate.up.sql | 1 + s-migrations/000057_admin_agent.up.sql | 60 ++++ test/static-check.sh | 23 ++ 68 files changed, 2374 insertions(+), 71 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 SHA256SUMS create mode 100644 VERSION create mode 100644 compose/e.yml create mode 100644 compose/s.yml create mode 100644 config/e-server.production.yaml create mode 100755 s-migrate.sh create mode 100644 s-migrations.tar.gz create mode 100644 s-migrations/000001_s1_s3.up.sql create mode 100644 s-migrations/000002_s4_s7.up.sql create mode 100644 s-migrations/000003_user_management.up.sql create mode 100644 s-migrations/000004_access_revocation.up.sql create mode 100644 s-migrations/000005_internal_auth_nonces.up.sql create mode 100644 s-migrations/000006_e_contract_playback_decisions.up.sql create mode 100644 s-migrations/000007_external_auth_license_cache.up.sql create mode 100644 s-migrations/000008_gateway_routing.up.sql create mode 100644 s-migrations/000009_gateway_operations.up.sql create mode 100644 s-migrations/000010_license_entitlements.up.sql create mode 100644 s-migrations/000011_e_address_directory.up.sql create mode 100644 s-migrations/000012_remove_gateway.up.sql create mode 100644 s-migrations/000013_e_address_observability.up.sql create mode 100644 s-migrations/000014_operations_closure.up.sql create mode 100644 s-migrations/000015_e_address_refresh_rate_limit.up.sql create mode 100644 s-migrations/000016_system_operations.up.sql create mode 100644 s-migrations/000017_notifications_security_lifecycle.up.sql create mode 100644 s-migrations/000018_initial_setup.up.sql create mode 100644 s-migrations/000019_e_address_group_rules.up.sql create mode 100644 s-migrations/000020_playback_session_context.up.sql create mode 100644 s-migrations/000021_subtitle_sources.up.sql create mode 100644 s-migrations/000022_metadata_provider_credentials.up.sql create mode 100644 s-migrations/000023_user_engagement_and_bot.up.sql create mode 100644 s-migrations/000024_daily_checkins.up.sql create mode 100644 s-migrations/000025_user_store_calendar_monitoring.up.sql create mode 100644 s-migrations/000026_telegram_brand_and_chat_state.up.sql create mode 100644 s-migrations/000027_site_brand_settings.up.sql create mode 100644 s-migrations/000028_media_request_metadata.up.sql create mode 100644 s-migrations/000029_support_attachments.up.sql create mode 100644 s-migrations/000030_user_password_and_banner.up.sql create mode 100644 s-migrations/000031_login_background.up.sql create mode 100644 s-migrations/000032_branding_image_limits.up.sql create mode 100644 s-migrations/000033_negative_point_balances.up.sql create mode 100644 s-migrations/000034_membership_limits_points_red_packets.up.sql create mode 100644 s-migrations/000035_plugin_management_permission.up.sql create mode 100644 s-migrations/000036_plugin_service_actor.up.sql create mode 100644 s-migrations/000037_user_groups.up.sql create mode 100644 s-migrations/000038_store_settings.up.sql create mode 100644 s-migrations/000039_telegram_command_auto_delete.up.sql create mode 100644 s-migrations/000040_enable_telegram_command_auto_delete.up.sql create mode 100644 s-migrations/000041_web_logins_not_playback_devices.up.sql create mode 100644 s-migrations/000042_login_protection.up.sql create mode 100644 s-migrations/000043_plugin_v11.up.sql create mode 100644 s-migrations/000044_telegram_membership_signup.up.sql create mode 100644 s-migrations/000045_plugin_telegram_inbound_media.up.sql create mode 100644 s-migrations/000046_plugin_telegram_chat_type.up.sql create mode 100644 s-migrations/000047_plugin_telegram_send_message.up.sql create mode 100644 s-migrations/000048_telegram_pro_user_duration.up.sql create mode 100644 s-migrations/000049_telegram_group_cleanup.up.sql create mode 100644 s-migrations/000050_telegram_rebind_after_unbind.up.sql create mode 100644 s-migrations/000051_storage_webhook_events.up.sql create mode 100644 s-migrations/000052_user_agent_security_rules.up.sql create mode 100644 s-migrations/000053_precise_client_access_rules.up.sql create mode 100644 s-migrations/000054_api_keys.up.sql create mode 100644 s-migrations/000055_playback_traffic.up.sql create mode 100644 s-migrations/000056_playback_estimate.up.sql create mode 100644 s-migrations/000057_admin_agent.up.sql create mode 100755 test/static-check.sh diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..5054d7f --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,19 @@ +# Customer deployment boundary + +The customer deployment is composed from public images and public, schema-only +migration assets. It has no source checkout, no build context, and no Auth +container. The default image registry is `git.leee.xin/overcooked` and the +default version is `latest`. + +The S control service is exposed on port 18080 and the S WebUI on 18083. E is +exposed on 8096. Ports 80 and 443 are not claimed, so an existing edge proxy +does not conflict with this deployment. + +Generated keys are written only to `/opt/overcooked/production.env` with mode +600. The installer does not log generated values, activation codes, or Auth +configuration. Failure logs are redacted before display. + +The installer must stop on missing prerequisites, invalid public address, +occupied ports, failed image verification, failed schema migration, or failed +health checks. It must never continue with a partially initialized database. + diff --git a/README.md b/README.md index 786fd73..fff10dc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,96 @@ # OverCooked Customer Installer -Public customer deployment script only. No Auth source, credentials, keys, or production configuration are included. +This repository is the public, image-only customer installer. A customer does +not need access to any application source repository. The installer downloads +only these public images: + +- `git.leee.xin/overcooked/overcooked-e` +- `git.leee.xin/overcooked/overcooked-s-control` +- `git.leee.xin/overcooked/overcooked-s-webui` +- `git.leee.xin/overcooked/overcooked-media-node` + +It does not download or start an Auth image, and it does not use Docker build +contexts. ## Install -Download this script and set the Auth URL, Auth public key map, and customer server IP: +```sh +curl -fsSL \\ + https://git.leee.xin/OverCooked/OverCooked-Installer/raw/branch/main/install.sh \\ + -o install.sh - curl -fsSL https://git.leee.xin/OverCooked/OverCooked-Installer/raw/branch/main/install.sh -o install.sh - sudo env OVERCOOKED_AUTH_URL=https://auth.example.invalid OVERCOOKED_AUTH_PUBLIC_KEYS='{"auth-1":"PUBLIC_KEY"}' OVERCOOKED_SERVER_IP=203.0.113.10 ./install.sh +sudo env \\ + OVERCOOKED_SERVER_IP=203.0.113.10 \\ + OVERCOOKED_AUTH_PUBLIC_KEYS='{"auth-1":"PUBLIC_KEY"}' \\ + OVERCOOKED_ACTIVATION_CODE='ACTIVATION_CODE' \\ + OVERCOOKED_TURNSTILE_SITE_KEY='TURNSTILE_SITE_KEY' \\ + OVERCOOKED_TURNSTILE_SECRET_KEY='TURNSTILE_SECRET_KEY' \\ + ./install.sh +``` + +The default central Auth endpoint is `http://148.135.97.10:18091`. Override it +with `OVERCOOKED_AUTH_URL`. Auth private keys, administrator credentials, +license databases, provider credentials, and production secrets are never +included in this repository. + +The current S production image requires the Turnstile key pair shown above. +The installer fails early if the pair is missing or incomplete; it never uses +placeholder credentials. + +## Endpoints + +- WebUI: `http://SERVER_IP:18083/` +- S API: `http://SERVER_IP:18080/` +- E: `http://SERVER_IP:8096/` +- First setup: `http://SERVER_IP:18083/setup` + +The installer checks these host ports before changing service state and does +not bind ports 80 or 443. Configure the firewall to allow the chosen public +ports and restrict database/Valkey traffic to the Docker networks. + +The server address is selected from `OVERCOOKED_SERVER_IP`, or from the +interface selected by `ip -4 route get 1.1.1.1`. A detected RFC1918, +loopback, link-local, or carrier-grade NAT address is rejected unless explicitly +provided through `OVERCOOKED_SERVER_IP`. + +## Configuration and logs + +`OVERCOOKED_REGISTRY` and `OVERCOOKED_VERSION` override the default image +registry and tag. `OVERCOOKED_ROOT` changes the default `/opt/overcooked` +installation directory. Generated `production.env` is mode 600 and contains +the setup token and randomly generated service keys. The installer never +prints those values. Keep the file private. + +```sh +docker compose --env-file /opt/overcooked/production.env \\ + -f /opt/overcooked/compose/s.yml -p overcooked-s ps +docker compose --env-file /opt/overcooked/production.env \\ + -f /opt/overcooked/compose/e.yml -p overcooked-e logs --tail=100 +``` + +Migration is fail-closed. E uses the migration command built into its official +image. S uses only the schema migration assets published in this repository. +If either migration fails, the installer exits non-zero and does not start the +corresponding application service. + +## Common 404/401 download failures + +The customer command must fetch the raw script from the public Installer +repository. A 404 usually means a wrong repository, branch, or raw path. A +401/403 means that a private source URL or credentialed mirror was used. Do +not grant customer access to application source to work around these errors. +Use the public raw URL and verify anonymous image pulls instead. + +## Uninstall + +Review the project names and volumes first, then stop the two projects: + +```sh +docker compose --env-file /opt/overcooked/production.env \\ + -f /opt/overcooked/compose/s.yml -p overcooked-s down +docker compose --env-file /opt/overcooked/production.env \\ + -f /opt/overcooked/compose/e.yml -p overcooked-e down +``` + +The named volumes contain customer data and are intentionally not removed by +the installer or this documentation. diff --git a/SHA256SUMS b/SHA256SUMS new file mode 100644 index 0000000..24e0fc3 --- /dev/null +++ b/SHA256SUMS @@ -0,0 +1,6 @@ +3395c27ad4158e70d47e7d002c141b1261f502875f2e7ac5b26ade51b7689138 install.sh +028d415f0c0aef0ed9a807573239079f9e7e8cb3ed796d9dec90f4be8cafdd05 compose/s.yml +fab213abb69e0141139c0d4234a7b1636588206f6588942a55c3380114069d92 compose/e.yml +c226c5e1feb5cd6948ee9b32897e77240e21793090d8f42234ca999450ec90b0 config/e-server.production.yaml +3e3dc775017577fad3b36b6c21036dfcb6e530793de4c18c825dda888f62e4e3 s-migrate.sh +07f432c281ba90565cd376b42967909b6ff2d19d04454a3ba4e3b184117d45af s-migrations.tar.gz diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..69be490 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2026.09.04 diff --git a/compose/e.yml b/compose/e.yml new file mode 100644 index 0000000..22602c3 --- /dev/null +++ b/compose/e.yml @@ -0,0 +1,126 @@ +name: overcooked-e + +x-logging: &logging + driver: json-file + options: + max-size: 10m + max-file: "5" + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: product_e + POSTGRES_USER: product_e + POSTGRES_PASSWORD: ${PRODUCT_E_POSTGRES_PASSWORD:?required} + POSTGRES_INITDB_ARGS: --data-checksums + volumes: + - e-postgres:/var/lib/postgresql/data + - e-backup-staging:/backup + healthcheck: + test: ["CMD-SHELL", "pg_isready -U product_e -d product_e"] + interval: 5s + timeout: 3s + retries: 20 + networks: [backend] + security_opt: [no-new-privileges:true] + logging: *logging + mem_limit: 2g + + valkey: + image: valkey/valkey:8-alpine + restart: unless-stopped + environment: + VALKEY_PASSWORD: ${PRODUCT_E_VALKEY_PASSWORD:?required} + command: ["sh", "-ec", "exec valkey-server --appendonly yes --save 60 1 --requirepass \"$${VALKEY_PASSWORD}\""] + volumes: + - e-valkey:/data + healthcheck: + test: ["CMD-SHELL", "valkey-cli -a \"$${VALKEY_PASSWORD}\" --no-auth-warning ping | grep -q PONG"] + interval: 5s + timeout: 3s + retries: 20 + networks: [backend] + security_opt: [no-new-privileges:true] + logging: *logging + mem_limit: 1g + + e-server: + image: ${PRODUCT_E_IMAGE:?required} + restart: unless-stopped + init: true + depends_on: + postgres: {condition: service_healthy} + valkey: {condition: service_healthy} + ports: + - ${PRODUCT_E_HTTP_PORT_MAPPING:-0.0.0.0:8096:8096} + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + volumes: + - e-data:/data + - e-plugin-data:/data/plugins + - ${OVERCOOKED_ROOT:-/opt/overcooked}/config/e-server.production.yaml:/etc/overcooked-e/config.yaml:ro + environment: + PRODUCT_E_CONFIG: /etc/overcooked-e/config.yaml + PRODUCT_E_ENVIRONMENT: production + PRODUCT_E_INSTANCE_ID: ${PRODUCT_E_INSTANCE_ID:-production-e-1} + PRODUCT_E_DATABASE_URL: ${PRODUCT_E_DATABASE_URL:?required} + PRODUCT_E_VALKEY_ADDRESS: valkey:6379 + PRODUCT_E_VALKEY_PASSWORD: ${PRODUCT_E_VALKEY_PASSWORD:?required} + PRODUCT_E_CONTROL_PLANE_URL: ${PRODUCT_E_CONTROL_PLANE_URL:?required} + PRODUCT_E_CONTROL_PLANE_KEY_ID: ${PRODUCT_E_CONTROL_PLANE_KEY_ID:?required} + PRODUCT_E_CONTROL_PLANE_SHARED_SECRET: ${PRODUCT_E_CONTROL_PLANE_SHARED_SECRET:?required} + PRODUCT_E_LICENSE_ENABLED: ${PRODUCT_E_LICENSE_ENABLED:-true} + PRODUCT_E_AUTH_URL: ${PRODUCT_E_AUTH_URL:?required} + PRODUCT_E_AUTH_ALLOW_INSECURE_HTTP: ${PRODUCT_E_AUTH_ALLOW_INSECURE_HTTP:-false} + PRODUCT_E_AUTH_ACTIVATION_CODE: ${PRODUCT_E_AUTH_ACTIVATION_CODE:-} + PRODUCT_E_AUTH_PUBLIC_KEYS: ${PRODUCT_E_AUTH_PUBLIC_KEYS:?required} + PRODUCT_E_PLAYBACK_TICKET_SECRET: ${PRODUCT_E_PLAYBACK_TICKET_SECRET:?required} + PRODUCT_E_PLAYBACK_TICKET_KEY_ID: ${PRODUCT_E_PLAYBACK_TICKET_KEY_ID:?required} + PRODUCT_E_PLAYBACK_TICKET_PREVIOUS_PUBLIC_KEYS: ${PRODUCT_E_PLAYBACK_TICKET_PREVIOUS_PUBLIC_KEYS:-{}} + PRODUCT_E_PLAYBACK_LEASE_KEY: ${PRODUCT_E_PLAYBACK_LEASE_KEY:?required} + PRODUCT_E_STORAGE_CREDENTIAL_KEY: ${PRODUCT_E_STORAGE_CREDENTIAL_KEY:?required} + PRODUCT_E_STORAGE_CREDENTIAL_KEY_ID: ${PRODUCT_E_STORAGE_CREDENTIAL_KEY_ID:?required} + PRODUCT_E_STORAGE_CREDENTIAL_PREVIOUS_KEYS: ${PRODUCT_E_STORAGE_CREDENTIAL_PREVIOUS_KEYS:-{}} + PRODUCT_E_PLUGIN_ENABLED: "true" + PRODUCT_E_PLUGIN_UNSIGNED_POLICY: require-signature + PRODUCT_E_PLUGIN_TRUSTED_PUBLISHERS: ${PRODUCT_E_PLUGIN_TRUSTED_PUBLISHERS:-{}} + PRODUCT_E_PLUGIN_SECRET_KEY_ID: ${PRODUCT_E_PLUGIN_SECRET_KEY_ID:?required} + PRODUCT_E_PLUGIN_SECRET_KEY: ${PRODUCT_E_PLUGIN_SECRET_KEY:?required} + PRODUCT_E_PLUGIN_PREVIOUS_SECRET_KEYS: ${PRODUCT_E_PLUGIN_PREVIOUS_SECRET_KEYS:-{}} + PRODUCT_E_TMDB_TOKEN: ${PRODUCT_E_TMDB_TOKEN:-} + PRODUCT_E_BANGUMI_TOKEN: ${PRODUCT_E_BANGUMI_TOKEN:-} + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8096/health/live || exit 1"] + interval: 10s + timeout: 3s + start_period: 20s + retries: 6 + stop_grace_period: 45s + networks: + backend: + control-plane: + aliases: [product-e] + egress: + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + logging: *logging + mem_limit: 4g + +networks: + backend: + internal: true + control-plane: + external: true + name: overcooked-control + egress: + +volumes: + e-postgres: + e-valkey: + e-data: + e-plugin-data: + e-backup-staging: + diff --git a/compose/s.yml b/compose/s.yml new file mode 100644 index 0000000..fd085a0 --- /dev/null +++ b/compose/s.yml @@ -0,0 +1,176 @@ +name: overcooked-s + +x-logging: &logging + driver: json-file + options: + max-size: 10m + max-file: "5" + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: product_s + POSTGRES_USER: product_s + POSTGRES_PASSWORD: ${S_POSTGRES_PASSWORD:?required} + POSTGRES_INITDB_ARGS: --data-checksums + volumes: + - s-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U product_s -d product_s"] + interval: 5s + timeout: 3s + retries: 20 + networks: [backend] + security_opt: [no-new-privileges:true] + logging: *logging + mem_limit: 512m + + valkey: + image: valkey/valkey:8-alpine + restart: unless-stopped + environment: + S_VALKEY_PASSWORD: ${S_VALKEY_PASSWORD:?required} + command: ["sh", "-ec", "exec valkey-server --appendonly yes --maxmemory-policy noeviction --requirepass \"$${S_VALKEY_PASSWORD}\""] + volumes: + - s-valkey:/data + healthcheck: + test: ["CMD-SHELL", "valkey-cli -a \"$${S_VALKEY_PASSWORD}\" --no-auth-warning ping | grep -q PONG"] + interval: 5s + timeout: 3s + retries: 20 + networks: [backend] + security_opt: [no-new-privileges:true] + logging: *logging + mem_limit: 128m + + s-migrate: + image: postgres:17-alpine + depends_on: + postgres: {condition: service_healthy} + environment: + PGHOST: postgres + PGUSER: product_s + PGDATABASE: product_s + PGPASSWORD: ${S_POSTGRES_PASSWORD:?required} + volumes: + - ${OVERCOOKED_ROOT:-/opt/overcooked}/migrations:/migrations:ro + - ${OVERCOOKED_ROOT:-/opt/overcooked}/s-migrate.sh:/migrate.sh:ro + command: ["sh", "/migrate.sh"] + networks: [backend] + security_opt: [no-new-privileges:true] + restart: "no" + logging: *logging + mem_limit: 128m + + s-control: + image: ${S_CONTROL_IMAGE:?required} + restart: unless-stopped + init: true + depends_on: + s-migrate: {condition: service_completed_successfully} + valkey: {condition: service_healthy} + ports: + - ${S_API_PORT_MAPPING:-0.0.0.0:18080:8080} + read_only: true + tmpfs: + - /tmp:size=32m,mode=1777 + environment: + S_HTTP_ADDR: :8080 + S_DATABASE_URL: postgres://product_s:${S_POSTGRES_PASSWORD}@postgres:5432/product_s?sslmode=disable + S_VALKEY_URL: redis://:${S_VALKEY_PASSWORD}@valkey:6379/0 + S_INTERNAL_KEY_ID: ${S_INTERNAL_KEY_ID:?required} + S_INTERNAL_SHARED_SECRET: ${S_INTERNAL_SHARED_SECRET:?required} + S_ACCESS_TOKEN_SECRET: ${S_ACCESS_TOKEN_SECRET:?required} + S_SETUP_TOKEN: ${S_SETUP_TOKEN:?required} + S_ACCESS_TOKEN_TTL: ${S_ACCESS_TOKEN_TTL:-15m} + S_EMBY_ACCESS_TOKEN_TTL: ${S_EMBY_ACCESS_TOKEN_TTL:-720h} + S_REFRESH_TOKEN_TTL: ${S_REFRESH_TOKEN_TTL:-720h} + S_PLAYBACK_SESSION_TTL: ${S_PLAYBACK_SESSION_TTL:-90s} + S_E_BASE_URL: ${S_E_BASE_URL:?required} + S_E_PUBLIC_URL: ${S_E_PUBLIC_URL:-} + S_E_KEY_ID: ${S_E_KEY_ID:?required} + S_E_INTERNAL_SECRET: ${S_E_INTERNAL_SECRET:?required} + S_E_ALLOW_INSECURE_HTTP: ${S_E_ALLOW_INSECURE_HTTP:-false} + S_AUTH_BASE_URL: ${S_AUTH_BASE_URL:?required} + S_AUTH_ALLOW_INSECURE_HTTP: ${S_AUTH_ALLOW_INSECURE_HTTP:-false} + S_AUTH_PUBLIC_KEYS: ${S_AUTH_PUBLIC_KEYS:?required} + S_AUTH_HEARTBEAT_INTERVAL: ${S_AUTH_HEARTBEAT_INTERVAL:-5m} + S_INSTANCE_ID: ${S_INSTANCE_ID:-s-production-1} + S_PLATFORM: ${S_PLATFORM:-linux-amd64} + S_VERSION: ${S_VERSION:-customer} + S_BACKUP_DIRECTORY: /var/lib/overcooked-s/backups + S_UPDATE_DIRECTORY: /var/lib/overcooked-s/updates + S_TURNSTILE_SITE_KEY: ${S_TURNSTILE_SITE_KEY:?required} + S_TURNSTILE_SECRET_KEY: ${S_TURNSTILE_SECRET_KEY:?required} + S_TURNSTILE_HOSTNAME: ${S_TURNSTILE_HOSTNAME:-} + S_TELEGRAM_MTPROTO_URL: ${S_TELEGRAM_MTPROTO_URL:-http://telegram-disabled.invalid:8098} + S_PORTAL_LOCATION: ${S_PORTAL_LOCATION:-Private Node} + S_PORTAL_SERVER_URL: ${S_PORTAL_SERVER_URL:-} + S_PORTAL_WEB_CLIENT_URL: ${S_PORTAL_WEB_CLIENT_URL:-} + S_PORTAL_ANNOUNCEMENT: ${S_PORTAL_ANNOUNCEMENT:-} + S_STORAGE_WEBHOOK_SECRET: ${S_STORAGE_WEBHOOK_SECRET:-} + S_NODE_IMAGE_ARCHIVE: /var/lib/overcooked-s/node-images/media-node.tar.gz + S_NODE_IMAGE_NAME: ${S_NODE_IMAGE_NAME:-overcooked-media-node:latest} + S_NODE_CONTROLLER_HOST: ${S_NODE_CONTROLLER_HOST:-} + volumes: + - s-backups:/var/lib/overcooked-s/backups + - s-updates:/var/lib/overcooked-s/updates + - ${OVERCOOKED_ROOT:-/opt/overcooked}/node-images:/var/lib/overcooked-s/node-images:ro + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/health/live || exit 1"] + interval: 10s + timeout: 3s + start_period: 15s + retries: 6 + networks: + backend: + control-plane: + aliases: [product-s] + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + logging: *logging + mem_limit: 384m + + webui: + image: ${S_WEBUI_IMAGE:?required} + restart: unless-stopped + init: true + depends_on: + s-control: {condition: service_healthy} + ports: + - ${S_WEB_PORT_MAPPING:-0.0.0.0:18083:3000} + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + - /app/.next/cache:size=64m,mode=0755,uid=1000,gid=1000 + networks: + backend: + frontend: + control-plane: + aliases: [product-s-web] + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/ || exit 1"] + interval: 10s + timeout: 3s + start_period: 20s + retries: 6 + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + logging: *logging + mem_limit: 384m + +networks: + backend: + internal: true + frontend: + control-plane: + external: true + name: overcooked-control + +volumes: + s-postgres: + s-valkey: + s-backups: + s-updates: diff --git a/config/e-server.production.yaml b/config/e-server.production.yaml new file mode 100644 index 0000000..ef1b19c --- /dev/null +++ b/config/e-server.production.yaml @@ -0,0 +1,35 @@ +service: + name: overcooked-e + instance_id: customer-e + environment: production +emby: + server_name: OverCooked + server_id: 10000000-0000-4000-8000-000000000001 + compatibility_version: 4.8.0.80 +control_plane: + base_url: https://s-control.invalid + key_id: customer-s + shared_secret: replace-by-environment + timeout: 10s +server: + address: 0.0.0.0:8096 +database: + url: postgres://product_e:replace-by-environment@postgres:5432/product_e?sslmode=disable +valkey: + address: valkey:6379 +observability: + log_level: info + trace_sample_ratio: 0.05 +playback: + node_id: 10000000-0000-4000-8000-000000000001 +plugins: + enabled: true + root: /data/plugins + sdk_socket: unix:///data/plugins/runtime/core.sock + core_version: 0.1.0 + unsigned_policy: require-signature + trusted_publishers: {} + secret_key_id: customer-plugin + previous_secret_keys: {} + network_allowed_domains: [] + network_allowed_cidrs: [] diff --git a/install.sh b/install.sh index d26fa41..3d42eda 100755 --- a/install.sh +++ b/install.sh @@ -1,118 +1,277 @@ #!/bin/sh set -eu +umask 077 -# Public customer bootstrap. Auth is an external service; no Auth image or -# private source is downloaded by this script. -REGISTRY="${OVERCOOKED_REGISTRY:-git.leee.xin/overcooked}" -VERSION="${OVERCOOKED_VERSION:-latest}" -ROOT="${OVERCOOKED_ROOT:-/opt/overcooked}" -SERVER_IP="${OVERCOOKED_SERVER_IP:-}" -ACTIVATION_CODE="${OVERCOOKED_ACTIVATION_CODE:-}" -# Central OverCooked Auth gateway. Override for private deployments. -AUTH_URL="${OVERCOOKED_AUTH_URL:-http://148.135.97.10:18091}" -AUTH_PUBLIC_KEYS="${OVERCOOKED_AUTH_PUBLIC_KEYS:-}" +# Standalone public customer bootstrap. It never checks out application source +# and it never pulls an Auth image. +REGISTRY=${OVERCOOKED_REGISTRY:-git.leee.xin/overcooked} +VERSION=${OVERCOOKED_VERSION:-latest} +ROOT=${OVERCOOKED_ROOT:-/opt/overcooked} +SERVER_IP=${OVERCOOKED_SERVER_IP:-} +ACTIVATION_CODE=${OVERCOOKED_ACTIVATION_CODE:-} +AUTH_URL=${OVERCOOKED_AUTH_URL:-http://148.135.97.10:18091} +AUTH_PUBLIC_KEYS=${OVERCOOKED_AUTH_PUBLIC_KEYS:-} +INSTALLER_BASE_URL=${OVERCOOKED_INSTALLER_BASE_URL:-https://git.leee.xin/OverCooked/OverCooked-Installer/raw/branch/main} +TURNSTILE_SITE_KEY=${OVERCOOKED_TURNSTILE_SITE_KEY:-} +TURNSTILE_SECRET_KEY=${OVERCOOKED_TURNSTILE_SECRET_KEY:-} +S_COMPOSE=$ROOT/compose/s.yml +E_COMPOSE=$ROOT/compose/e.yml +ENV_FILE=$ROOT/production.env +S_PROJECT=overcooked-s +E_PROJECT=overcooked-e +FAILURE_REPORTED=0 +S_POSTGRES_PASSWORD=not-generated +S_VALKEY_PASSWORD=not-generated +S_INTERNAL_SHARED_SECRET=not-generated +S_ACCESS_TOKEN_SECRET=not-generated +S_SETUP_TOKEN=not-generated +E_POSTGRES_PASSWORD=not-generated +E_PLAYBACK_TICKET_SECRET=not-generated +E_PLAYBACK_LEASE_KEY=not-generated +E_STORAGE_CREDENTIAL_KEY=not-generated +E_PLUGIN_SECRET_KEY=not-generated die() { echo "overcooked-install: $*" >&2; exit 1; } + +is_ipv4() { + old_ifs=$IFS; IFS=.; set -- $1; IFS=$old_ifs + [ "$#" = 4 ] || return 1 + for octet in "$@"; do + case "$octet" in ''|*[!0-9]*) return 1 ;; esac + [ "$octet" -le 255 ] 2>/dev/null || return 1 + done +} +is_private_ipv4() { + second=$(printf '%s\n' "$1" | cut -d. -f2) + case "$1" in + 0.*|10.*|127.*|169.254.*|192.168.*|100.64.*|100.65.*|100.66.*|100.67.*|100.68.*|100.69.*|100.70.*|100.71.*|100.72.*|100.73.*|100.74.*|100.75.*|100.76.*|100.77.*|100.78.*|100.79.*|100.8*.*|100.9*.*) return 0 ;; + 172.*) [ "$second" -ge 16 ] 2>/dev/null && [ "$second" -le 31 ] 2>/dev/null; return ;; + esac + return 1 +} +check_port() { + port=$1 + if command -v ss >/dev/null 2>&1 && ss -H -ltn 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" || $4 ~ p "\\]$" { found=1 } END { exit !found }'; then + die "端口 $port 已被占用,请停止占用进程或调整部署端口" + fi +} +redact_logs() { + sed -e "s|$S_POSTGRES_PASSWORD|[REDACTED]|g" \ + -e "s|$S_VALKEY_PASSWORD|[REDACTED]|g" \ + -e "s|$S_INTERNAL_SHARED_SECRET|[REDACTED]|g" \ + -e "s|$S_ACCESS_TOKEN_SECRET|[REDACTED]|g" \ + -e "s|$S_SETUP_TOKEN|[REDACTED]|g" \ + -e "s|$E_POSTGRES_PASSWORD|[REDACTED]|g" \ + -e "s|$E_PLAYBACK_TICKET_SECRET|[REDACTED]|g" \ + -e "s|$E_PLAYBACK_LEASE_KEY|[REDACTED]|g" \ + -e "s|$E_STORAGE_CREDENTIAL_KEY|[REDACTED]|g" \ + -e "s|$E_PLUGIN_SECRET_KEY|[REDACTED]|g" +} +show_failure() { + [ "$FAILURE_REPORTED" = 1 ] && return 0 + FAILURE_REPORTED=1 + [ -f "$ENV_FILE" ] || return 0 + echo "安装失败,以下是脱敏后的容器状态和最近日志:" >&2 + docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" ps >&2 || true + docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" ps >&2 || true + docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" logs --tail=100 2>&1 | redact_logs >&2 || true + docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" logs --tail=100 2>&1 | redact_logs >&2 || true +} +on_exit() { rc=$?; [ "$rc" -eq 0 ] || show_failure; exit "$rc"; } +trap on_exit EXIT + [ "$(id -u)" = 0 ] || die "请使用 sudo/root 执行" command -v docker >/dev/null 2>&1 || die "未找到 Docker Engine" docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2" -command -v git >/dev/null 2>&1 || die "未找到 git" -[ -n "$SERVER_IP" ] || SERVER_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}') -[ -n "$SERVER_IP" ] || SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}') -[ -n "$SERVER_IP" ] || die "无法识别服务器 IP,请设置 OVERCOOKED_SERVER_IP" -case "$SERVER_IP" in *[!0-9.]*|*[!0-9]*) die "仅支持 IPv4 地址:$SERVER_IP";; esac -[ -n "$AUTH_PUBLIC_KEYS" ] || die "请设置 OVERCOOKED_AUTH_PUBLIC_KEYS(Auth 公钥 JSON)" -[ -n "$ACTIVATION_CODE" ] || printf '请输入授权码: '; [ -n "$ACTIVATION_CODE" ] || read -r ACTIVATION_CODE -[ -n "$ACTIVATION_CODE" ] || die "授权码不能为空" - -install -d -m 700 "$ROOT" -for repo in OverCooked-S Overcooked-E; do - if [ ! -d "$ROOT/$repo/.git" ]; then - git clone --depth 1 "https://git.leee.xin/OverCooked/$repo.git" "$ROOT/$repo" - else - git -C "$ROOT/$repo" fetch --depth 1 origin && git -C "$ROOT/$repo" reset --hard FETCH_HEAD - fi -done -install -d -m 700 "$ROOT/config" +command -v curl >/dev/null 2>&1 || die "未找到 curl" command -v openssl >/dev/null 2>&1 || die "未找到 openssl" +command -v tar >/dev/null 2>&1 || die "未找到 tar" +command -v mktemp >/dev/null 2>&1 || die "未找到 mktemp" +for pair in "OVERCOOKED_REGISTRY:$REGISTRY" "OVERCOOKED_VERSION:$VERSION" "OVERCOOKED_ROOT:$ROOT" "OVERCOOKED_AUTH_URL:$AUTH_URL" "OVERCOOKED_INSTALLER_BASE_URL:$INSTALLER_BASE_URL"; do + name=${pair%%:*}; value=${pair#*:}; [ -n "$value" ] || die "$name 不能为空" + case "$value" in *' '*|*' '*|*'\r'*|*'\n'*) die "$name 包含非法空白字符" ;; esac +done +case "$REGISTRY" in *://*|*/|*//*|*=*) die "OVERCOOKED_REGISTRY 必须是镜像 Registry 路径" ;; esac +case "$VERSION" in */*|*:*|*[!A-Za-z0-9._-]*) die "OVERCOOKED_VERSION 格式无效" ;; esac +case "$ROOT" in /*) ;; *) die "OVERCOOKED_ROOT 必须是绝对路径" ;; esac +case "$INSTALLER_BASE_URL" in *://*) ;; *) die "Installer 地址必须是 HTTP(S) URL" ;; esac + +if [ -z "$SERVER_IP" ]; then + command -v ip >/dev/null 2>&1 || die "未找到 ip,请设置 OVERCOOKED_SERVER_IP" + SERVER_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}') + [ -n "$SERVER_IP" ] || die "无法识别服务器公网 IPv4,请设置 OVERCOOKED_SERVER_IP" + EXPLICIT_SERVER_IP=0 +else + EXPLICIT_SERVER_IP=1 +fi +is_ipv4 "$SERVER_IP" || die "仅支持有效 IPv4 地址:$SERVER_IP" +if [ "$EXPLICIT_SERVER_IP" = 0 ] && is_private_ipv4 "$SERVER_IP"; then + die "检测到私有地址,请设置 OVERCOOKED_SERVER_IP=公网IPv4" +fi +[ -n "$AUTH_PUBLIC_KEYS" ] || die "请设置 OVERCOOKED_AUTH_PUBLIC_KEYS(Auth 公钥 JSON)" +case "$AUTH_PUBLIC_KEYS" in \{*\}) ;; *) die "OVERCOOKED_AUTH_PUBLIC_KEYS 必须是 JSON 对象" ;; esac +case "$AUTH_PUBLIC_KEYS" in *'\n'*|*'\r'*) die "OVERCOOKED_AUTH_PUBLIC_KEYS 不得包含换行" ;; esac +if [ -z "$ACTIVATION_CODE" ]; then + [ -t 0 ] || die "授权码不能为空,请设置 OVERCOOKED_ACTIVATION_CODE" + printf '请输入授权码: ' >&2; IFS= read -r ACTIVATION_CODE +fi +[ -n "$ACTIVATION_CODE" ] || die "授权码不能为空" +if [ -z "$TURNSTILE_SITE_KEY" ] || [ -z "$TURNSTILE_SECRET_KEY" ]; then + die "当前 S 生产镜像需要 OVERCOOKED_TURNSTILE_SITE_KEY 和 OVERCOOKED_TURNSTILE_SECRET_KEY;请提供成对配置" +fi +check_port 18080; check_port 18083; check_port 8096 + +install -d -m 700 "$ROOT" "$ROOT/config" "$ROOT/compose" "$ROOT/migrations" "$ROOT/node-images" +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fetch_asset() { + relative=$1; destination=$2; source="$SCRIPT_DIR/$relative" + if [ -f "$source" ]; then install -m 600 "$source" "$destination"; return; fi + temporary=$(mktemp "$destination.download.XXXXXX") + curl -fsSL --retry 3 --connect-timeout 10 --max-time 120 "$INSTALLER_BASE_URL/$relative" -o "$temporary" || die "下载公开 Installer 资产失败:$relative" + [ -s "$temporary" ] || die "公开 Installer 资产为空:$relative" + install -m 600 "$temporary" "$destination"; rm -f "$temporary" +} +fetch_asset compose/s.yml "$S_COMPOSE" +fetch_asset compose/e.yml "$E_COMPOSE" +fetch_asset s-migrate.sh "$ROOT/s-migrate.sh" +fetch_asset config/e-server.production.yaml "$ROOT/config/e-server.production.yaml" +fetch_asset s-migrations.tar.gz "$ROOT/s-migrations.tar.gz" +tar -xzf "$ROOT/s-migrations.tar.gz" -C "$ROOT/migrations"; rm -f "$ROOT/s-migrations.tar.gz" +find "$ROOT/migrations" -type f ! -name '*.up.sql' -delete +find "$ROOT/migrations" -type f -name '*.up.sql' -exec chmod 600 {} \; + secret() { openssl rand -hex 32; } -s_db=$(secret); s_valkey=$(secret); internal=$(secret); access=$(secret); setup=$(secret) -e_db=$(secret); ticket=$(secret); lease=$(secret); storage=$(secret); plugin=$(secret) -cat >"$ROOT/production.env" </dev/null | tr -cd '[:alnum:]' | cut -c1-24); [ -n "$INSTANCE_SUFFIX" ] || INSTANCE_SUFFIX=customer + +cat > "$ENV_FILE" <"$ROOT/config/e-server.production.yaml" < "$ROOT/config/e-server.production.yaml" <"$ROOT/config/e-public.override.yml" <<'EOF' -services: - e-server: - ports: - - "0.0.0.0:8096:8096" -EOF + docker network inspect overcooked-control >/dev/null 2>&1 || docker network create overcooked-control >/dev/null -docker compose -p overcooked-e --env-file "$ROOT/production.env" -f "$ROOT/Overcooked-E/deploy/compose/production.yml" run --rm e-server migrate -docker compose -p overcooked-e --env-file "$ROOT/production.env" -f "$ROOT/Overcooked-E/deploy/compose/production.yml" -f "$ROOT/Overcooked-E/deploy/compose/control-plane-network.yml" -f "$ROOT/config/e-public.override.yml" up -d --no-build -docker compose -p overcooked-s --env-file "$ROOT/production.env" -f "$ROOT/OverCooked-S/deploy/s/docker-compose.production.yml" up -d --no-build -echo "配置已写入 $ROOT/production.env" +docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" config >/dev/null +docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" config >/dev/null + +for image in "$REGISTRY/overcooked-e:$VERSION" "$REGISTRY/overcooked-s-control:$VERSION" "$REGISTRY/overcooked-s-webui:$VERSION" "$REGISTRY/overcooked-media-node:$VERSION"; do + docker pull "$image" + docker image inspect "$image" >/dev/null 2>&1 || die "镜像校验失败:$image" +done + +docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d postgres valkey +docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" run --rm --no-deps e-server migrate -config /etc/overcooked-e/config.yaml +docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" up -d postgres valkey +docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" run --rm --no-deps s-migrate +docker compose --env-file "$ENV_FILE" -f "$E_COMPOSE" -p "$E_PROJECT" up -d e-server +docker compose --env-file "$ENV_FILE" -f "$S_COMPOSE" -p "$S_PROJECT" up -d s-control webui + +wait_for() { + label=$1; url=$2; attempt=1 + while [ "$attempt" -le 60 ]; do + curl -fsS --max-time 5 "$url" >/dev/null 2>&1 && return 0 + sleep 2; attempt=$((attempt + 1)) + done + die "$label 健康检查失败:$url" +} +wait_for "S live" http://127.0.0.1:18080/health/live +wait_for "S ready" http://127.0.0.1:18080/health/ready +wait_for "E live" http://127.0.0.1:8096/health/live +wait_for "WebUI" http://127.0.0.1:18083/ +wait_for "Setup" http://127.0.0.1:18083/setup + +echo "OverCooked 客户部署已通过容器健康检查。" echo "WebUI: http://$SERVER_IP:18083/" echo "API: http://$SERVER_IP:18080/" echo "E: http://$SERVER_IP:8096/" -echo "首次启动后打开 http://$SERVER_IP:18083/setup 输入授权码和页面显示的 Setup Token。" -echo "镜像来源:$REGISTRY(不包含 Auth 镜像)" +echo "首次初始化: http://$SERVER_IP:18083/setup" +echo "Setup Token 已保存于 $ENV_FILE(不会在终端显示,请限制文件访问权限)。" +echo "镜像来源:$REGISTRY(仅公开 E、S Control、S WebUI、Media Node;不包含 Auth 镜像)" diff --git a/s-migrate.sh b/s-migrate.sh new file mode 100755 index 0000000..163cf39 --- /dev/null +++ b/s-migrate.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +: "${PGHOST:?required}" +: "${PGUSER:?required}" +: "${PGDATABASE:?required}" +: "${PGPASSWORD:?required}" + +psql -v ON_ERROR_STOP=1 -c ' + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) +' + +for migration in /migrations/*.up.sql; do + version=$(basename "$migration" .up.sql) + case "$version" in + *[!0-9A-Za-z_-]*) echo "invalid migration name: $version" >&2; exit 1 ;; + esac + applied=$(psql -v ON_ERROR_STOP=1 -Atc "SELECT count(*) FROM schema_migrations WHERE version='$version'") + [ "$applied" = 1 ] && continue + { + printf 'BEGIN;\nSELECT pg_advisory_xact_lock(731006);\n' + cat "$migration" + printf "\nINSERT INTO schema_migrations(version) VALUES ('%s');\nCOMMIT;\n" "$version" + } | psql -v ON_ERROR_STOP=1 +done diff --git a/s-migrations.tar.gz b/s-migrations.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b5c7bd80a0b7c0a9c62f77a70430a854f4318fa7 GIT binary patch literal 17494 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 literal 0 HcmV?d00001 diff --git a/s-migrations/000001_s1_s3.up.sql b/s-migrations/000001_s1_s3.up.sql new file mode 100644 index 0000000..7139849 --- /dev/null +++ b/s-migrations/000001_s1_s3.up.sql @@ -0,0 +1,81 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + username text NOT NULL, + username_normalized text NOT NULL UNIQUE, + email text NOT NULL, + email_normalized text NOT NULL UNIQUE, + role text NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled','deleted')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +CREATE TABLE credentials ( + user_id uuid PRIMARY KEY REFERENCES users(id), + password_hash text NOT NULL, + password_changed_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE memberships ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id), + status text NOT NULL CHECK (status IN ('active','expired','revoked')), + starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL, + source text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), + CHECK (ends_at > starts_at) +); +CREATE INDEX memberships_active_user_idx ON memberships(user_id, ends_at DESC) WHERE status = 'active'; +CREATE TABLE refresh_token_families ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id), + revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE refresh_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), family_id uuid NOT NULL REFERENCES refresh_token_families(id), + token_hash bytea NOT NULL UNIQUE, expires_at timestamptz NOT NULL, consumed_at timestamptz, + replaced_by uuid, created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX refresh_tokens_family_idx ON refresh_tokens(family_id); +CREATE TABLE card_batches ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, card_type text NOT NULL CHECK (card_type IN ('registration','renewal')), + duration_days integer NOT NULL CHECK (duration_days > 0), created_by uuid REFERENCES users(id), + revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE cards ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), batch_id uuid NOT NULL REFERENCES card_batches(id), + code_hash bytea NOT NULL UNIQUE, remaining_uses integer NOT NULL DEFAULT 1 CHECK (remaining_uses >= 0), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked','exhausted')), + expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE card_usages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), card_id uuid NOT NULL REFERENCES cards(id), user_id uuid NOT NULL REFERENCES users(id), + used_at timestamptz NOT NULL DEFAULT now(), UNIQUE(card_id, user_id) +); +CREATE INDEX card_usages_user_idx ON card_usages(user_id, used_at DESC); +CREATE TABLE audit_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, actor_user_id uuid REFERENCES users(id), action text NOT NULL, + target_type text NOT NULL, target_id text NOT NULL, detail jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX audit_events_target_idx ON audit_events(target_type, target_id, created_at DESC); +CREATE TABLE policy_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_type text NOT NULL CHECK (subject_type IN ('global','membership','user')), + subject_id uuid, priority integer NOT NULL DEFAULT 0, enabled boolean NOT NULL DEFAULT true, + max_devices integer, max_online integer, max_playbacks integer, max_device_playbacks integer, max_bitrate_kbps integer, + downloads_allowed boolean, proxy_allowed boolean, transcode_allowed boolean, library_id text, + allowed_cidrs text[] NOT NULL DEFAULT '{}', countries text[] NOT NULL DEFAULT '{}', regions text[] NOT NULL DEFAULT '{}', + asns bigint[] NOT NULL DEFAULT '{}', carriers text[] NOT NULL DEFAULT '{}', route_id text, created_at timestamptz NOT NULL DEFAULT now(), + CHECK ((subject_type = 'global' AND subject_id IS NULL) OR (subject_type <> 'global' AND subject_id IS NOT NULL)) +); +CREATE INDEX policy_rules_subject_idx ON policy_rules(subject_type, subject_id, priority DESC) WHERE enabled; +CREATE TABLE devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id), fingerprint_hash bytea NOT NULL, + first_seen_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz, + UNIQUE(user_id, fingerprint_hash) +); +CREATE TABLE playback_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id), device_id uuid REFERENCES devices(id), + e_session_id text NOT NULL UNIQUE, library_id text NOT NULL, route_id text, state text NOT NULL CHECK (state IN ('reserved','active','released','expired')), + lease_token_hash bytea NOT NULL UNIQUE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), released_at timestamptz +); +CREATE INDEX playback_sessions_active_user_idx ON playback_sessions(user_id, expires_at) WHERE state IN ('reserved','active'); +CREATE INDEX playback_sessions_active_device_idx ON playback_sessions(device_id, expires_at) WHERE state IN ('reserved','active'); diff --git a/s-migrations/000002_s4_s7.up.sql b/s-migrations/000002_s4_s7.up.sql new file mode 100644 index 0000000..664dfea --- /dev/null +++ b/s-migrations/000002_s4_s7.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE e_command_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_user_id uuid NOT NULL REFERENCES users(id), + command text NOT NULL, target_id text NOT NULL, idempotency_key text NOT NULL UNIQUE, + state text NOT NULL CHECK (state IN ('accepted','completed','failed')), created_at timestamptz NOT NULL DEFAULT now(), completed_at timestamptz +); +CREATE INDEX e_command_requests_actor_idx ON e_command_requests(actor_user_id, created_at DESC); +CREATE TABLE e_event_inbox ( + event_id uuid PRIMARY KEY, event_type text NOT NULL, occurred_at timestamptz NOT NULL, + payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE invite_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), creator_user_id uuid NOT NULL REFERENCES users(id), + code_hash bytea NOT NULL UNIQUE, max_uses integer NOT NULL DEFAULT 1 CHECK (max_uses > 0), used_count integer NOT NULL DEFAULT 0 CHECK (used_count >= 0), + expires_at timestamptz, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE invite_relations ( + parent_user_id uuid NOT NULL REFERENCES users(id), child_user_id uuid NOT NULL REFERENCES users(id), invite_code_id uuid NOT NULL REFERENCES invite_codes(id), + risk_state text NOT NULL DEFAULT 'normal' CHECK (risk_state IN ('normal','review','blocked')), detached_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(parent_user_id, child_user_id), CHECK (parent_user_id <> child_user_id) +); +CREATE UNIQUE INDEX invite_relations_child_active_idx ON invite_relations(child_user_id) WHERE detached_at IS NULL; +CREATE TABLE media_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id), query_text text NOT NULL, + e_media_id uuid, availability text NOT NULL CHECK (availability IN ('unknown','available','unavailable')), status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','planned','fulfilled','rejected','cancelled')), + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX media_requests_status_idx ON media_requests(status, created_at DESC); +CREATE TABLE ticket_types (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL UNIQUE, enabled boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE tickets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), owner_user_id uuid NOT NULL REFERENCES users(id), type_id uuid REFERENCES ticket_types(id), subject text NOT NULL, + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','pending','resolved','closed')), priority text NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high')), + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), closed_at timestamptz +); +CREATE INDEX tickets_owner_idx ON tickets(owner_user_id, updated_at DESC); +CREATE INDEX tickets_status_idx ON tickets(status, updated_at DESC); +CREATE TABLE ticket_replies (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), ticket_id uuid NOT NULL REFERENCES tickets(id), author_user_id uuid NOT NULL REFERENCES users(id), body text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()); +CREATE INDEX ticket_replies_ticket_idx ON ticket_replies(ticket_id, created_at); +CREATE TABLE ticket_assignments (ticket_id uuid PRIMARY KEY REFERENCES tickets(id), admin_user_id uuid NOT NULL REFERENCES users(id), assigned_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE telegram_accounts (user_id uuid PRIMARY KEY REFERENCES users(id), telegram_user_id bigint NOT NULL UNIQUE, username text, bound_at timestamptz NOT NULL DEFAULT now(), disabled_at timestamptz); +CREATE TABLE telegram_bind_codes (code_hash bytea PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id), expires_at timestamptz NOT NULL, consumed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()); + +CREATE TABLE license_products ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, edition text NOT NULL, max_s_instances integer NOT NULL CHECK (max_s_instances > 0), + max_e_nodes integer NOT NULL CHECK (max_e_nodes >= 0), feature_flags text[] NOT NULL DEFAULT '{}', valid_days integer NOT NULL CHECK (valid_days > 0), offline_days integer NOT NULL DEFAULT 60 CHECK (offline_days BETWEEN 0 AND 60), created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE license_activations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), product_id uuid NOT NULL REFERENCES license_products(id), activation_hash bytea NOT NULL UNIQUE, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked','exhausted')), max_instances integer NOT NULL DEFAULT 1 CHECK (max_instances > 0), created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz +); +CREATE TABLE license_instances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activation_id uuid NOT NULL REFERENCES license_activations(id), instance_id uuid NOT NULL, + fingerprint_hash bytea NOT NULL, signed_license text NOT NULL, expires_at timestamptz NOT NULL, offline_until timestamptz NOT NULL, last_seen_at timestamptz NOT NULL DEFAULT now(), unbound_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(activation_id, instance_id) +); +CREATE INDEX license_instances_active_idx ON license_instances(activation_id) WHERE unbound_at IS NULL; + +CREATE TABLE outbox_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), topic text NOT NULL, aggregate_type text NOT NULL, aggregate_id text NOT NULL, + payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), published_at timestamptz, attempts integer NOT NULL DEFAULT 0, last_error text + , locked_by text, locked_until timestamptz +); +CREATE INDEX outbox_events_pending_idx ON outbox_events(created_at) WHERE published_at IS NULL; +CREATE TABLE runtime_config (key text PRIMARY KEY, value jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), updated_by uuid REFERENCES users(id)); diff --git a/s-migrations/000003_user_management.up.sql b/s-migrations/000003_user_management.up.sql new file mode 100644 index 0000000..f597795 --- /dev/null +++ b/s-migrations/000003_user_management.up.sql @@ -0,0 +1,21 @@ +ALTER TABLE users + ADD COLUMN ban_reason text, + ADD COLUMN banned_until timestamptz; + +CREATE TABLE user_permissions ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission text NOT NULL CHECK (permission IN ( + 'users.read', + 'users.write', + 'cards.manage', + 'operations.manage', + 'e.manage', + 'audit.read', + 'licenses.manage' + )), + granted_by uuid REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, permission) +); + +CREATE INDEX user_permissions_permission_idx ON user_permissions(permission, user_id); diff --git a/s-migrations/000004_access_revocation.up.sql b/s-migrations/000004_access_revocation.up.sql new file mode 100644 index 0000000..0bbe54e --- /dev/null +++ b/s-migrations/000004_access_revocation.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN auth_version bigint NOT NULL DEFAULT 1 CHECK (auth_version > 0); diff --git a/s-migrations/000005_internal_auth_nonces.up.sql b/s-migrations/000005_internal_auth_nonces.up.sql new file mode 100644 index 0000000..5f20f0a --- /dev/null +++ b/s-migrations/000005_internal_auth_nonces.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE internal_auth_nonces ( + nonce_hash bytea PRIMARY KEY, + expires_at timestamptz NOT NULL +); + +CREATE INDEX internal_auth_nonces_expiry_idx ON internal_auth_nonces(expires_at); diff --git a/s-migrations/000006_e_contract_playback_decisions.up.sql b/s-migrations/000006_e_contract_playback_decisions.up.sql new file mode 100644 index 0000000..1859a1b --- /dev/null +++ b/s-migrations/000006_e_contract_playback_decisions.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE playback_decisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + media_id uuid NOT NULL, + library_id uuid NOT NULL, + source_id uuid NOT NULL, + device_id text NOT NULL, + route_id text, + allowed boolean NOT NULL, + deny_code text, + max_bitrate bigint NOT NULL DEFAULT 0 CHECK (max_bitrate >= 0), + allow_download boolean NOT NULL DEFAULT false, + max_playbacks integer NOT NULL DEFAULT 0 CHECK (max_playbacks >= 0), + max_device_playbacks integer NOT NULL DEFAULT 0 CHECK (max_device_playbacks >= 0), + policy_revision bigint NOT NULL CHECK (policy_revision >= 0), + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX playback_decisions_expiry_idx ON playback_decisions(expires_at); +CREATE INDEX playback_decisions_user_idx ON playback_decisions(user_id, created_at DESC); diff --git a/s-migrations/000007_external_auth_license_cache.up.sql b/s-migrations/000007_external_auth_license_cache.up.sql new file mode 100644 index 0000000..72d99de --- /dev/null +++ b/s-migrations/000007_external_auth_license_cache.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE commercial_license_cache ( + instance_id text PRIMARY KEY, + license_id text NOT NULL, + signed_license text NOT NULL, + edition text NOT NULL, + features text[] NOT NULL DEFAULT '{}', + max_s_instances integer NOT NULL CHECK (max_s_instances > 0), + max_e_nodes integer NOT NULL CHECK (max_e_nodes > 0), + expires_at timestamptz NOT NULL, + offline_until timestamptz NOT NULL, + status text NOT NULL CHECK (status IN ('active','revoked','expired')), + last_verified_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX commercial_license_cache_status_expiry_idx ON commercial_license_cache(status,expires_at); diff --git a/s-migrations/000008_gateway_routing.up.sql b/s-migrations/000008_gateway_routing.up.sql new file mode 100644 index 0000000..e105676 --- /dev/null +++ b/s-migrations/000008_gateway_routing.up.sql @@ -0,0 +1,86 @@ +CREATE TABLE gateway_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + public_origin text NOT NULL DEFAULT '', + trusted_proxy_cidrs text[] NOT NULL DEFAULT '{}', + active_revision bigint, + updated_by uuid REFERENCES users(id), + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO gateway_settings(singleton) VALUES (true); + +CREATE TABLE gateway_service_pools ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code text NOT NULL UNIQUE CHECK (code ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 128), + enabled boolean NOT NULL DEFAULT true, + is_default boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX gateway_service_pools_one_default_idx ON gateway_service_pools(is_default) WHERE is_default; +INSERT INTO gateway_service_pools(code,name,is_default) VALUES ('default','Default',true); + +CREATE TABLE gateway_nodes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + node_id text NOT NULL UNIQUE CHECK (node_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 128), + gateway_url text NOT NULL CHECK (length(gateway_url) BETWEEN 8 AND 2048), + pool_id uuid NOT NULL REFERENCES gateway_service_pools(id), + country text NOT NULL DEFAULT '', + region text NOT NULL DEFAULT '', + city text NOT NULL DEFAULT '', + carrier text NOT NULL DEFAULT '', + asn bigint NOT NULL DEFAULT 0 CHECK (asn BETWEEN 0 AND 4294967295), + weight integer NOT NULL DEFAULT 100 CHECK (weight BETWEEN 1 AND 10000), + max_connections bigint NOT NULL DEFAULT 0 CHECK (max_connections >= 0), + max_playback_sessions bigint NOT NULL DEFAULT 0 CHECK (max_playback_sessions >= 0), + max_transcode_sessions bigint NOT NULL DEFAULT 0 CHECK (max_transcode_sessions >= 0), + bandwidth_capacity_mbps bigint NOT NULL DEFAULT 0 CHECK (bandwidth_capacity_mbps >= 0), + library_ids text[] NOT NULL DEFAULT '{}', + storage_ids text[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + allow_login boolean NOT NULL DEFAULT true, + allow_new_playback boolean NOT NULL DEFAULT true, + maintenance boolean NOT NULL DEFAULT false, + draining boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX gateway_nodes_pool_idx ON gateway_nodes(pool_id,enabled,node_id); +CREATE INDEX gateway_nodes_route_idx ON gateway_nodes(enabled,maintenance,draining,country,region,carrier,asn); + +CREATE TABLE gateway_user_routes ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + forced_node_id text REFERENCES gateway_nodes(node_id) ON UPDATE CASCADE, + forced_pool_id uuid REFERENCES gateway_service_pools(id), + preferred_country text NOT NULL DEFAULT '', + preferred_region text NOT NULL DEFAULT '', + updated_by uuid REFERENCES users(id), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE gateway_config_versions ( + revision bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + snapshot jsonb NOT NULL CHECK (jsonb_typeof(snapshot)='object'), + source_revision bigint REFERENCES gateway_config_versions(revision), + created_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX gateway_config_versions_created_idx ON gateway_config_versions(created_at DESC); +ALTER TABLE gateway_settings ADD CONSTRAINT gateway_settings_active_revision_fk FOREIGN KEY(active_revision) REFERENCES gateway_config_versions(revision); + +CREATE TABLE gateway_route_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + request_id text NOT NULL, + node_id text, + outcome text NOT NULL CHECK (outcome IN ('routed','rejected','upstream_failed')), + reason text NOT NULL DEFAULT '', + country text NOT NULL DEFAULT '', + region text NOT NULL DEFAULT '', + carrier text NOT NULL DEFAULT '', + asn bigint NOT NULL DEFAULT 0, + latency_ms integer NOT NULL DEFAULT 0 CHECK (latency_ms >= 0), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX gateway_route_events_recent_idx ON gateway_route_events(created_at DESC); +CREATE INDEX gateway_route_events_node_idx ON gateway_route_events(node_id,created_at DESC); diff --git a/s-migrations/000009_gateway_operations.up.sql b/s-migrations/000009_gateway_operations.up.sql new file mode 100644 index 0000000..946e637 --- /dev/null +++ b/s-migrations/000009_gateway_operations.up.sql @@ -0,0 +1,19 @@ +ALTER TABLE gateway_settings + ADD COLUMN routing_weights jsonb NOT NULL DEFAULT '{"country":1000,"region":100,"city":20,"carrier":10,"asn":10}'::jsonb, + ADD COLUMN recovery_ramp_seconds integer NOT NULL DEFAULT 300 CHECK (recovery_ramp_seconds BETWEEN 0 AND 3600), + ADD CONSTRAINT gateway_settings_routing_weights_object CHECK (jsonb_typeof(routing_weights) = 'object'); + +ALTER TABLE gateway_route_events + ADD COLUMN username text NOT NULL DEFAULT '', + ADD COLUMN device_hash text NOT NULL DEFAULT '', + ADD COLUMN resource_id text NOT NULL DEFAULT '', + ADD COLUMN path_class text NOT NULL DEFAULT 'other' + CHECK (path_class IN ('login','browse','image','playback','transcode','websocket','other')), + ADD COLUMN selection_source text NOT NULL DEFAULT ''; + +CREATE INDEX gateway_route_events_outcome_created_idx + ON gateway_route_events(outcome,created_at DESC); +CREATE INDEX gateway_route_events_location_created_idx + ON gateway_route_events(country,region,carrier,created_at DESC); +CREATE INDEX gateway_route_events_username_created_idx + ON gateway_route_events(username,created_at DESC) WHERE username <> ''; diff --git a/s-migrations/000010_license_entitlements.up.sql b/s-migrations/000010_license_entitlements.up.sql new file mode 100644 index 0000000..5d97a47 --- /dev/null +++ b/s-migrations/000010_license_entitlements.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE commercial_license_cache + ADD COLUMN customer_id text NOT NULL DEFAULT '', + ADD COLUMN product text NOT NULL DEFAULT 'overcooked', + ADD COLUMN max_users integer NOT NULL DEFAULT 0 CHECK (max_users >= 0), + ADD COLUMN max_service_pools integer NOT NULL DEFAULT 0 CHECK (max_service_pools >= 0), + ADD COLUMN not_before timestamptz NOT NULL DEFAULT now(), + ADD COLUMN maintenance_until timestamptz; + +CREATE INDEX commercial_license_cache_product_status_idx + ON commercial_license_cache(product,status,offline_until); diff --git a/s-migrations/000011_e_address_directory.up.sql b/s-migrations/000011_e_address_directory.up.sql new file mode 100644 index 0000000..e1dd3be --- /dev/null +++ b/s-migrations/000011_e_address_directory.up.sql @@ -0,0 +1,75 @@ +CREATE TABLE e_address_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + trusted_proxy_cidrs text[] NOT NULL DEFAULT '{}', + default_address_id uuid, + rule_revision bigint NOT NULL DEFAULT 1, + updated_by uuid REFERENCES users(id), + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO e_address_settings(singleton) VALUES (true); + +CREATE TABLE e_addresses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 128), + public_url text NOT NULL CHECK (public_url ~ '^https?://[^/?#[:space:]]+'), + country text NOT NULL DEFAULT '', + region text NOT NULL DEFAULT '', + city text NOT NULL DEFAULT '', + carrier text NOT NULL DEFAULT '', + asn bigint NOT NULL DEFAULT 0 CHECK (asn BETWEEN 0 AND 4294967295), + node_group text NOT NULL DEFAULT '', + priority integer NOT NULL DEFAULT 100 CHECK (priority BETWEEN -100000 AND 100000), + enabled boolean NOT NULL DEFAULT true, + healthy boolean NOT NULL DEFAULT false, + license_valid boolean NOT NULL DEFAULT false, + allow_user_publish boolean NOT NULL DEFAULT true, + maintenance boolean NOT NULL DEFAULT false, + description text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX e_addresses_public_url_idx ON e_addresses(public_url); +CREATE INDEX e_addresses_selection_idx ON e_addresses(enabled,healthy,license_valid,allow_user_publish,maintenance,priority); + +ALTER TABLE e_address_settings + ADD CONSTRAINT e_address_settings_default_fk FOREIGN KEY(default_address_id) REFERENCES e_addresses(id) ON DELETE SET NULL; + +CREATE TABLE e_address_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + priority integer NOT NULL DEFAULT 100 CHECK (priority BETWEEN -100000 AND 100000), + country text NOT NULL DEFAULT '', + region text NOT NULL DEFAULT '', + carrier text NOT NULL DEFAULT '', + asn bigint NOT NULL DEFAULT 0 CHECK (asn BETWEEN 0 AND 4294967295), + node_group text NOT NULL DEFAULT '', + address_id uuid NOT NULL REFERENCES e_addresses(id) ON DELETE CASCADE, + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX e_address_rules_match_idx ON e_address_rules(enabled,priority,country,region,carrier,asn,node_group); + +CREATE TABLE e_user_assignments ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + address_id uuid NOT NULL REFERENCES e_addresses(id), + fixed boolean NOT NULL DEFAULT false, + rule_revision bigint NOT NULL DEFAULT 1, + reason text NOT NULL DEFAULT '', + source_ip_hash text NOT NULL DEFAULT '', + assigned_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, + updated_by uuid REFERENCES users(id), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX e_user_assignments_expiry_idx ON e_user_assignments(expires_at); + +CREATE TABLE e_assignment_history ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + address_id uuid REFERENCES e_addresses(id) ON DELETE SET NULL, + source_ip_hash text NOT NULL DEFAULT '', + reason text NOT NULL DEFAULT '', + rule_revision bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX e_assignment_history_user_idx ON e_assignment_history(user_id,created_at DESC); diff --git a/s-migrations/000012_remove_gateway.up.sql b/s-migrations/000012_remove_gateway.up.sql new file mode 100644 index 0000000..53c7c6b --- /dev/null +++ b/s-migrations/000012_remove_gateway.up.sql @@ -0,0 +1,48 @@ +INSERT INTO e_addresses ( + id, name, public_url, country, region, city, carrier, asn, node_group, + priority, enabled, healthy, license_valid, allow_user_publish, + maintenance, description, created_at, updated_at +) +SELECT + node.id, + node.name, + rtrim(node.gateway_url, '/'), + node.country, + node.region, + node.city, + node.carrier, + node.asn, + pool.code, + node.weight, + node.enabled, + false, + false, + false, + node.maintenance OR node.draining, + 'Migrated from retired proxy node ' || node.node_id, + node.created_at, + node.updated_at +FROM gateway_nodes AS node +JOIN gateway_service_pools AS pool ON pool.id = node.pool_id +WHERE node.gateway_url ~ '^https?://[^/?#[:space:]]+' +ON CONFLICT (public_url) DO UPDATE SET + name = EXCLUDED.name, + country = EXCLUDED.country, + region = EXCLUDED.region, + city = EXCLUDED.city, + carrier = EXCLUDED.carrier, + asn = EXCLUDED.asn, + node_group = EXCLUDED.node_group, + priority = EXCLUDED.priority, + description = CASE + WHEN e_addresses.description = '' THEN EXCLUDED.description + ELSE e_addresses.description + END, + updated_at = GREATEST(e_addresses.updated_at, EXCLUDED.updated_at); + +DROP TABLE gateway_route_events; +DROP TABLE gateway_settings; +DROP TABLE gateway_config_versions; +DROP TABLE gateway_user_routes; +DROP TABLE gateway_nodes; +DROP TABLE gateway_service_pools; diff --git a/s-migrations/000013_e_address_observability.up.sql b/s-migrations/000013_e_address_observability.up.sql new file mode 100644 index 0000000..2aa9254 --- /dev/null +++ b/s-migrations/000013_e_address_observability.up.sql @@ -0,0 +1,22 @@ +ALTER TABLE e_address_rules + ADD COLUMN match_count bigint NOT NULL DEFAULT 0 CHECK (match_count >= 0), + ADD COLUMN last_matched_at timestamptz; + +CREATE TABLE e_address_selection_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + address_id uuid REFERENCES e_addresses(id) ON DELETE SET NULL, + rule_id uuid REFERENCES e_address_rules(id) ON DELETE SET NULL, + outcome text NOT NULL CHECK (outcome IN ('selected','unavailable','access_denied')), + reason text NOT NULL DEFAULT '', + source_ip_hash text NOT NULL DEFAULT '', + country text NOT NULL DEFAULT '', + region text NOT NULL DEFAULT '', + carrier text NOT NULL DEFAULT '', + asn bigint NOT NULL DEFAULT 0 CHECK (asn BETWEEN 0 AND 4294967295), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX e_address_selection_events_created_idx ON e_address_selection_events(created_at DESC); +CREATE INDEX e_address_selection_events_outcome_idx ON e_address_selection_events(outcome,created_at DESC); +CREATE INDEX e_address_selection_events_user_idx ON e_address_selection_events(user_id,created_at DESC); diff --git a/s-migrations/000014_operations_closure.up.sql b/s-migrations/000014_operations_closure.up.sql new file mode 100644 index 0000000..05487c8 --- /dev/null +++ b/s-migrations/000014_operations_closure.up.sql @@ -0,0 +1,218 @@ +ALTER TABLE users DROP CONSTRAINT users_role_check; +ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('user','owner','admin','operator','support','auditor')); + +ALTER TABLE user_permissions DROP CONSTRAINT user_permissions_permission_check; +ALTER TABLE user_permissions ADD CONSTRAINT user_permissions_permission_check CHECK (permission IN ( + 'users.read','users.write','memberships.manage','cards.manage','invites.manage', + 'requests.manage','tickets.manage','notifications.manage','security.manage', + 'operations.manage','e.manage','audit.read','logs.read','backups.manage', + 'updates.manage','settings.manage','licenses.manage' +)); + +CREATE TABLE membership_plans ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code text NOT NULL UNIQUE CHECK (code ~ '^[a-z0-9][a-z0-9._-]{0,63}$'), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 128), + duration_days integer NOT NULL CHECK (duration_days > 0), + max_playbacks integer NOT NULL DEFAULT 0 CHECK (max_playbacks >= 0), + max_devices integer NOT NULL DEFAULT 0 CHECK (max_devices >= 0), + max_bitrate_kbps integer NOT NULL DEFAULT 0 CHECK (max_bitrate_kbps >= 0), + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE memberships DROP CONSTRAINT memberships_status_check; +ALTER TABLE memberships ADD CONSTRAINT memberships_status_check CHECK (status IN ('active','paused','expired','revoked')); +ALTER TABLE memberships + ADD COLUMN plan_id uuid REFERENCES membership_plans(id) ON DELETE SET NULL, + ADD COLUMN paused_at timestamptz, + ADD COLUMN pause_reason text NOT NULL DEFAULT '', + ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE media_requests DROP CONSTRAINT media_requests_status_check; +UPDATE media_requests SET status='submitted' WHERE status='open'; +UPDATE media_requests SET status='accepted' WHERE status='planned'; +ALTER TABLE media_requests ADD CONSTRAINT media_requests_status_check CHECK (status IN ( + 'submitted','reviewing','accepted','existing','downloading','fulfilled','failed','rejected','cancelled' +)); +ALTER TABLE media_requests + ADD COLUMN media_type text NOT NULL DEFAULT 'other' CHECK (media_type IN ('movie','series','season','episode','other')), + ADD COLUMN release_year integer CHECK (release_year BETWEEN 1800 AND 3000), + ADD COLUMN priority text NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')), + ADD COLUMN assignee_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN public_reply text NOT NULL DEFAULT '', + ADD COLUMN internal_note text NOT NULL DEFAULT '', + ADD COLUMN merged_into uuid REFERENCES media_requests(id) ON DELETE SET NULL, + ADD COLUMN closed_at timestamptz; + +CREATE TABLE media_request_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_id uuid NOT NULL REFERENCES media_requests(id) ON DELETE CASCADE, + author_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + visibility text NOT NULL CHECK (visibility IN ('user','internal')), + body text NOT NULL CHECK (length(btrim(body)) BETWEEN 1 AND 5000), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX media_request_messages_request_idx ON media_request_messages(request_id,created_at); + +ALTER TABLE tickets DROP CONSTRAINT tickets_status_check; +UPDATE tickets SET status='in_progress' WHERE status='pending'; +ALTER TABLE tickets ADD CONSTRAINT tickets_status_check CHECK (status IN ('open','in_progress','waiting_user','resolved','closed')); +ALTER TABLE tickets DROP CONSTRAINT tickets_priority_check; +ALTER TABLE tickets ADD CONSTRAINT tickets_priority_check CHECK (priority IN ('low','normal','high','urgent')); +ALTER TABLE tickets + ADD COLUMN sla_due_at timestamptz, + ADD COLUMN last_user_reply_at timestamptz, + ADD COLUMN last_staff_reply_at timestamptz, + ADD COLUMN reopened_at timestamptz; +ALTER TABLE ticket_replies + ADD COLUMN visibility text NOT NULL DEFAULT 'user' CHECK (visibility IN ('user','internal')); + +CREATE TABLE announcements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title text NOT NULL CHECK (length(btrim(title)) BETWEEN 1 AND 200), + body text NOT NULL CHECK (length(btrim(body)) BETWEEN 1 AND 20000), + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','scheduled','published','withdrawn')), + audience text NOT NULL DEFAULT 'all' CHECK (audience IN ('all','active_members','staff')), + pinned boolean NOT NULL DEFAULT false, + publish_at timestamptz, + expires_at timestamptz, + created_by uuid NOT NULL REFERENCES users(id), + updated_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (expires_at IS NULL OR publish_at IS NULL OR expires_at > publish_at) +); +CREATE INDEX announcements_status_publish_idx ON announcements(status,publish_at DESC); + +CREATE TABLE announcement_reads ( + announcement_id uuid NOT NULL REFERENCES announcements(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + read_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (announcement_id,user_id) +); + +CREATE TABLE notification_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_type text NOT NULL, + target_user_id uuid REFERENCES users(id) ON DELETE CASCADE, + title text NOT NULL CHECK (length(btrim(title)) BETWEEN 1 AND 200), + body text NOT NULL CHECK (length(body) <= 10000), + link_path text NOT NULL DEFAULT '' CHECK (link_path='' OR link_path ~ '^/'), + related_type text NOT NULL DEFAULT '', + related_id text NOT NULL DEFAULT '', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX notification_events_target_idx ON notification_events(target_user_id,created_at DESC); + +CREATE TABLE notification_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_id uuid NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel text NOT NULL CHECK (channel IN ('in_app','email','telegram')), + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sending','sent','failed','cancelled')), + attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at timestamptz, + last_error text NOT NULL DEFAULT '', + read_at timestamptz, + deleted_at timestamptz, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(event_id,user_id,channel) +); +CREATE INDEX notification_deliveries_user_idx ON notification_deliveries(user_id,channel,created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX notification_deliveries_pending_idx ON notification_deliveries(next_attempt_at,created_at) WHERE status IN ('pending','failed'); + +CREATE TABLE risk_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 128), + event_type text NOT NULL CHECK (event_type IN ('concurrency','multi_region','device_churn','auth_failures','manual')), + window_seconds integer NOT NULL CHECK (window_seconds BETWEEN 60 AND 2592000), + threshold integer NOT NULL CHECK (threshold > 0), + action text NOT NULL DEFAULT 'review' CHECK (action IN ('observe','review','revoke_sessions','disable_user')), + enabled boolean NOT NULL DEFAULT true, + created_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE risk_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rule_id uuid REFERENCES risk_rules(id) ON DELETE SET NULL, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + event_type text NOT NULL, + severity text NOT NULL CHECK (severity IN ('info','low','medium','high','critical')), + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','reviewing','resolved','dismissed','appealed')), + evidence jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(evidence)='object'), + resolution text NOT NULL DEFAULT '', + resolved_by uuid REFERENCES users(id) ON DELETE SET NULL, + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX risk_events_status_idx ON risk_events(status,severity,created_at DESC); + +ALTER TABLE refresh_token_families + ADD COLUMN device_name text NOT NULL DEFAULT '', + ADD COLUMN client_name text NOT NULL DEFAULT '', + ADD COLUMN platform text NOT NULL DEFAULT '', + ADD COLUMN ip_hash text NOT NULL DEFAULT '', + ADD COLUMN last_seen_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE devices + ADD COLUMN device_name text NOT NULL DEFAULT '', + ADD COLUMN client_name text NOT NULL DEFAULT '', + ADD COLUMN platform text NOT NULL DEFAULT '', + ADD COLUMN client_version text NOT NULL DEFAULT '', + ADD COLUMN last_ip_hash text NOT NULL DEFAULT '', + ADD COLUMN trusted boolean NOT NULL DEFAULT false, + ADD COLUMN blocked_at timestamptz, + ADD COLUMN blocked_by uuid REFERENCES users(id) ON DELETE SET NULL; + +CREATE TABLE configuration_versions ( + revision bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + domain text NOT NULL CHECK (domain IN ('s','e')), + values jsonb NOT NULL CHECK (jsonb_typeof(values)='object'), + state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','published','superseded','failed')), + source_revision bigint REFERENCES configuration_versions(revision), + created_by uuid NOT NULL REFERENCES users(id), + published_at timestamptz, + failure_reason text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX configuration_versions_published_domain_idx ON configuration_versions(domain) WHERE state='published'; + +CREATE TABLE operation_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + job_type text NOT NULL, + resource_type text NOT NULL DEFAULT '', + resource_id text NOT NULL DEFAULT '', + idempotency_key text NOT NULL UNIQUE, + state text NOT NULL DEFAULT 'queued' CHECK (state IN ('queued','running','pausing','paused','cancelling','cancelled','succeeded','partial_failed','failed')), + progress_total bigint NOT NULL DEFAULT 0 CHECK (progress_total >= 0), + progress_completed bigint NOT NULL DEFAULT 0 CHECK (progress_completed >= 0), + progress_failed bigint NOT NULL DEFAULT 0 CHECK (progress_failed >= 0), + error_code text NOT NULL DEFAULT '', + error_summary text NOT NULL DEFAULT '', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, + CHECK (progress_completed + progress_failed <= progress_total OR progress_total=0) +); +CREATE INDEX operation_jobs_state_idx ON operation_jobs(state,created_at DESC); + +CREATE TABLE api_idempotency_keys ( + actor_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + operation text NOT NULL, + idempotency_key text NOT NULL, + resource_id text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours', + PRIMARY KEY (actor_user_id,operation,idempotency_key) +); +CREATE INDEX api_idempotency_keys_expiry_idx ON api_idempotency_keys(expires_at); diff --git a/s-migrations/000015_e_address_refresh_rate_limit.up.sql b/s-migrations/000015_e_address_refresh_rate_limit.up.sql new file mode 100644 index 0000000..2bbc67f --- /dev/null +++ b/s-migrations/000015_e_address_refresh_rate_limit.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE e_address_selection_events DROP CONSTRAINT e_address_selection_events_outcome_check; +ALTER TABLE e_address_selection_events ADD CONSTRAINT e_address_selection_events_outcome_check CHECK (outcome IN ('selected','unavailable','access_denied','rate_limited')); diff --git a/s-migrations/000016_system_operations.up.sql b/s-migrations/000016_system_operations.up.sql new file mode 100644 index 0000000..19ae0d7 --- /dev/null +++ b/s-migrations/000016_system_operations.up.sql @@ -0,0 +1,90 @@ +ALTER TABLE audit_events + ADD COLUMN request_id text NOT NULL DEFAULT ''; +CREATE INDEX audit_events_request_idx ON audit_events(request_id, created_at DESC) WHERE request_id <> ''; + +CREATE TABLE runtime_log_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + level text NOT NULL CHECK (level IN ('debug','info','warn','error')), + message text NOT NULL CHECK (length(message) BETWEEN 1 AND 256), + request_id text NOT NULL DEFAULT '', + method text NOT NULL DEFAULT '', + path text NOT NULL DEFAULT '', + status_code integer NOT NULL DEFAULT 0 CHECK (status_code BETWEEN 0 AND 599), + duration_ms bigint NOT NULL DEFAULT 0 CHECK (duration_ms >= 0), + detail jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(detail)='object'), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX runtime_log_events_time_idx ON runtime_log_events(created_at DESC); +CREATE INDEX runtime_log_events_request_idx ON runtime_log_events(request_id,created_at DESC) WHERE request_id <> ''; + +ALTER TABLE operation_jobs + ADD COLUMN schema_version integer NOT NULL DEFAULT 1, + ADD COLUMN stage text NOT NULL DEFAULT 'queued', + ADD COLUMN priority integer NOT NULL DEFAULT 0 CHECK (priority BETWEEN -100 AND 100), + ADD COLUMN progress_skipped bigint NOT NULL DEFAULT 0 CHECK (progress_skipped >= 0), + ADD COLUMN retryable boolean NOT NULL DEFAULT true, + ADD COLUMN next_retry_at timestamptz, + ADD COLUMN claimed_by text NOT NULL DEFAULT '', + ADD COLUMN payload jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(payload)='object'); + +CREATE TABLE backup_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + scope text NOT NULL CHECK (scope IN ('s','e','auth','all')), + enabled boolean NOT NULL DEFAULT true, + interval_hours integer NOT NULL DEFAULT 24 CHECK (interval_hours BETWEEN 1 AND 720), + retention_count integer NOT NULL DEFAULT 14 CHECK (retention_count BETWEEN 1 AND 365), + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(scope) +); + +CREATE TABLE backup_artifacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + scope text NOT NULL CHECK (scope IN ('s','e','auth','all')), + state text NOT NULL DEFAULT 'queued' CHECK (state IN ('queued','running','succeeded','failed','expired','deleted')), + storage_key text NOT NULL, + size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0), + sha256 text NOT NULL DEFAULT '' CHECK (sha256 = '' OR sha256 ~ '^[0-9a-f]{64}$'), + job_id uuid REFERENCES operation_jobs(id) ON DELETE SET NULL, + error_code text NOT NULL DEFAULT '', + error_summary text NOT NULL DEFAULT '', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + deleted_at timestamptz +); +CREATE INDEX backup_artifacts_scope_idx ON backup_artifacts(scope,created_at DESC); + +CREATE TABLE system_runtime_state ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + maintenance boolean NOT NULL DEFAULT false, + maintenance_reason text NOT NULL DEFAULT '', + maintenance_job_id uuid REFERENCES operation_jobs(id) ON DELETE SET NULL, + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO system_runtime_state(singleton) VALUES (true) ON CONFLICT DO NOTHING; + +CREATE TABLE update_artifacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + product text NOT NULL CHECK (product IN ('s','e','auth')), + platform text NOT NULL, + version text NOT NULL, + channel text NOT NULL CHECK (channel IN ('stable','preview')), + download_url text NOT NULL DEFAULT '', + sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + signature text NOT NULL DEFAULT '', + min_s_version text NOT NULL DEFAULT '', + min_e_version text NOT NULL DEFAULT '', + state text NOT NULL DEFAULT 'authorized' CHECK (state IN ('authorized','downloaded','verified','staged','applied','rolled_back','failed')), + storage_key text NOT NULL DEFAULT '', + job_id uuid REFERENCES operation_jobs(id) ON DELETE SET NULL, + error_code text NOT NULL DEFAULT '', + error_summary text NOT NULL DEFAULT '', + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(product,platform,version,channel) +); +CREATE INDEX update_artifacts_state_idx ON update_artifacts(product,state,updated_at DESC); diff --git a/s-migrations/000017_notifications_security_lifecycle.up.sql b/s-migrations/000017_notifications_security_lifecycle.up.sql new file mode 100644 index 0000000..8688762 --- /dev/null +++ b/s-migrations/000017_notifications_security_lifecycle.up.sql @@ -0,0 +1,124 @@ +ALTER TABLE users + ADD COLUMN deleted_by uuid REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN deletion_reason text NOT NULL DEFAULT '', + ADD COLUMN restored_at timestamptz; + +ALTER TABLE card_batches + ADD COLUMN membership_plan_id uuid REFERENCES membership_plans(id) ON DELETE SET NULL, + ADD COLUMN edition text NOT NULL DEFAULT '' CHECK (char_length(edition) <= 64), + ADD COLUMN max_uses integer NOT NULL DEFAULT 1 CHECK (max_uses > 0); + +ALTER TABLE card_batches DROP CONSTRAINT card_batches_card_type_check; +ALTER TABLE card_batches ADD CONSTRAINT card_batches_card_type_check CHECK (card_type IN ('registration','renewal','membership')); + +ALTER TABLE cards + ADD COLUMN label text NOT NULL DEFAULT '' CHECK (char_length(label) <= 128); + +CREATE TABLE notification_channel_configs ( + channel text PRIMARY KEY CHECK (channel IN ('smtp','telegram')), + enabled boolean NOT NULL DEFAULT false, + settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings)='object'), + secret_cipher bytea, + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO notification_channel_configs(channel) VALUES ('smtp'),('telegram'); + +CREATE TABLE notification_templates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + template_key text NOT NULL CHECK (template_key ~ '^[a-z0-9][a-z0-9._-]{0,127}$'), + channel text NOT NULL CHECK (channel IN ('email','telegram')), + subject text NOT NULL DEFAULT '' CHECK (char_length(subject) <= 300), + body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 20000), + enabled boolean NOT NULL DEFAULT true, + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(template_key,channel) +); + +CREATE TABLE notification_delivery_attempts ( + id bigserial PRIMARY KEY, + delivery_id uuid NOT NULL REFERENCES notification_deliveries(id) ON DELETE CASCADE, + attempt integer NOT NULL CHECK (attempt > 0), + status text NOT NULL CHECK (status IN ('sent','failed','skipped')), + error_code text NOT NULL DEFAULT '', + error_summary text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(delivery_id,attempt) +); + +CREATE TABLE telegram_runtime_state ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + update_offset bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO telegram_runtime_state(singleton) VALUES(true); + +CREATE TABLE user_ip_history ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ip_hash text NOT NULL CHECK (char_length(ip_hash)=64), + country text NOT NULL DEFAULT '', + region text NOT NULL DEFAULT '', + carrier text NOT NULL DEFAULT '', + asn bigint, + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + seen_count bigint NOT NULL DEFAULT 1 CHECK (seen_count > 0), + UNIQUE(user_id,ip_hash) +); +CREATE INDEX user_ip_history_user_time_idx ON user_ip_history(user_id,last_seen_at DESC); + +CREATE TABLE security_list_entries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + subject_type text NOT NULL CHECK (subject_type IN ('ip','cidr','device','user')), + mode text NOT NULL CHECK (mode IN ('allow','deny')), + subject_value text NOT NULL CHECK (char_length(subject_value) BETWEEN 1 AND 512), + reason text NOT NULL CHECK (char_length(btrim(reason)) BETWEEN 1 AND 1000), + expires_at timestamptz, + created_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, + revoked_by uuid REFERENCES users(id) ON DELETE SET NULL, + UNIQUE(subject_type,mode,subject_value) +); +CREATE INDEX security_list_active_idx ON security_list_entries(subject_type,mode,expires_at) WHERE revoked_at IS NULL; + +CREATE TABLE user_appeals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + risk_event_id uuid REFERENCES risk_events(id) ON DELETE SET NULL, + body text NOT NULL CHECK (char_length(btrim(body)) BETWEEN 1 AND 5000), + status text NOT NULL DEFAULT 'submitted' CHECK (status IN ('submitted','reviewing','accepted','rejected')), + resolution text NOT NULL DEFAULT '', + resolved_by uuid REFERENCES users(id) ON DELETE SET NULL, + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE media_request_callbacks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 128), + callback_url text NOT NULL CHECK (callback_url ~ '^https://'), + secret_cipher bytea NOT NULL, + enabled boolean NOT NULL DEFAULT true, + created_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE media_request_callback_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + callback_id uuid NOT NULL REFERENCES media_request_callbacks(id) ON DELETE CASCADE, + external_event_id text NOT NULL CHECK (char_length(external_event_id) BETWEEN 1 AND 256), + request_id uuid REFERENCES media_requests(id) ON DELETE SET NULL, + payload jsonb NOT NULL CHECK (jsonb_typeof(payload)='object'), + signature_valid boolean NOT NULL, + applied boolean NOT NULL DEFAULT false, + error_summary text NOT NULL DEFAULT '', + received_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(callback_id,external_event_id) +); diff --git a/s-migrations/000018_initial_setup.up.sql b/s-migrations/000018_initial_setup.up.sql new file mode 100644 index 0000000..78e1ffc --- /dev/null +++ b/s-migrations/000018_initial_setup.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE system_installation ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + installation_id uuid NOT NULL DEFAULT gen_random_uuid(), + setup_completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO system_installation(singleton) VALUES(true); diff --git a/s-migrations/000019_e_address_group_rules.up.sql b/s-migrations/000019_e_address_group_rules.up.sql new file mode 100644 index 0000000..0133fb3 --- /dev/null +++ b/s-migrations/000019_e_address_group_rules.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE e_address_rules + ALTER COLUMN address_id DROP NOT NULL; + +ALTER TABLE e_address_rules + ADD CONSTRAINT e_address_rules_target_check + CHECK (address_id IS NOT NULL OR length(btrim(node_group)) > 0); diff --git a/s-migrations/000020_playback_session_context.up.sql b/s-migrations/000020_playback_session_context.up.sql new file mode 100644 index 0000000..3fed86b --- /dev/null +++ b/s-migrations/000020_playback_session_context.up.sql @@ -0,0 +1,17 @@ +ALTER TABLE playback_decisions + ADD COLUMN device_name text NOT NULL DEFAULT '', + ADD COLUMN client_name text NOT NULL DEFAULT '', + ADD COLUMN client_version text NOT NULL DEFAULT '', + ADD COLUMN remote_ip_hash text NOT NULL DEFAULT ''; + +ALTER TABLE playback_sessions + ADD COLUMN media_id uuid, + ADD COLUMN source_id uuid, + ADD COLUMN node_id uuid, + ADD COLUMN position_ticks bigint NOT NULL DEFAULT 0 CHECK (position_ticks >= 0), + ADD COLUMN device_name text NOT NULL DEFAULT '', + ADD COLUMN client_name text NOT NULL DEFAULT '', + ADD COLUMN client_version text NOT NULL DEFAULT ''; + +CREATE INDEX playback_sessions_recent_idx + ON playback_sessions(updated_at DESC, id DESC); diff --git a/s-migrations/000021_subtitle_sources.up.sql b/s-migrations/000021_subtitle_sources.up.sql new file mode 100644 index 0000000..827dfbf --- /dev/null +++ b/s-migrations/000021_subtitle_sources.up.sql @@ -0,0 +1,55 @@ +CREATE TABLE subtitle_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 128), + source_type text NOT NULL DEFAULT 'json_rest_v1' CHECK (source_type IN ('json_rest_v1')), + base_url text NOT NULL CHECK (base_url ~ '^https://'), + api_key_cipher bytea, + enabled boolean NOT NULL DEFAULT true, + languages text[] NOT NULL DEFAULT '{}', + priority integer NOT NULL DEFAULT 100 CHECK (priority BETWEEN 1 AND 10000), + timeout_seconds integer NOT NULL DEFAULT 10 CHECK (timeout_seconds BETWEEN 2 AND 30), + created_by uuid NOT NULL REFERENCES users(id), + updated_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (name) +); + +CREATE INDEX subtitle_sources_enabled_idx + ON subtitle_sources(priority,id) WHERE enabled; + +CREATE TABLE subtitle_search_candidates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + source_id uuid NOT NULL REFERENCES subtitle_sources(id) ON DELETE CASCADE, + media_item_id uuid NOT NULL, + media_source_id uuid NOT NULL, + external_id text NOT NULL CHECK (char_length(external_id) BETWEEN 1 AND 512), + title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 512), + language text NOT NULL DEFAULT '' CHECK (char_length(language) <= 64), + codec text NOT NULL CHECK (codec IN ('srt','ass','ssa','vtt','sup','sub','idx')), + release_group text NOT NULL DEFAULT '' CHECK (char_length(release_group) <= 256), + score double precision NOT NULL DEFAULT 0 CHECK (score >= 0 AND score <= 1), + download_path text NOT NULL CHECK (char_length(download_path) BETWEEN 1 AND 2048), + hearing_impaired boolean NOT NULL DEFAULT false, + created_by uuid NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL DEFAULT (now() + interval '1 hour') +); + +CREATE INDEX subtitle_search_candidates_actor_idx + ON subtitle_search_candidates(created_by,created_at DESC); +CREATE INDEX subtitle_search_candidates_expiry_idx + ON subtitle_search_candidates(expires_at); + +CREATE TABLE subtitle_imports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + candidate_id uuid NOT NULL REFERENCES subtitle_search_candidates(id) ON DELETE CASCADE, + actor_user_id uuid NOT NULL REFERENCES users(id), + idempotency_key text NOT NULL CHECK (char_length(idempotency_key) BETWEEN 16 AND 128), + state text NOT NULL DEFAULT 'processing' CHECK (state IN ('processing','succeeded','failed')), + result jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(result)='object'), + error_code text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(actor_user_id,idempotency_key) +); diff --git a/s-migrations/000022_metadata_provider_credentials.up.sql b/s-migrations/000022_metadata_provider_credentials.up.sql new file mode 100644 index 0000000..c547c2f --- /dev/null +++ b/s-migrations/000022_metadata_provider_credentials.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE metadata_provider_credentials ( + provider text PRIMARY KEY CHECK (provider IN ('tmdb','bangumi')), + token_cipher bytea, + enabled boolean NOT NULL DEFAULT true, + updated_by uuid NOT NULL REFERENCES users(id), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX metadata_provider_credentials_enabled_idx + ON metadata_provider_credentials(provider) WHERE enabled; diff --git a/s-migrations/000023_user_engagement_and_bot.up.sql b/s-migrations/000023_user_engagement_and_bot.up.sql new file mode 100644 index 0000000..a083d93 --- /dev/null +++ b/s-migrations/000023_user_engagement_and_bot.up.sql @@ -0,0 +1,80 @@ +CREATE TABLE point_rules ( + rule_key text PRIMARY KEY CHECK (rule_key ~ '^[a-z0-9][a-z0-9._-]{0,63}$'), + name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 128), + points integer NOT NULL CHECK (points BETWEEN -100000 AND 100000), + enabled boolean NOT NULL DEFAULT true, + description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 1000), + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO point_rules(rule_key,name,points,description) VALUES + ('playback.completed','有效观看',5,'单次观看达到 10 分钟后奖励,每个播放会话仅奖励一次'), + ('feedback.accepted','有效反馈',10,'管理员确认媒体问题后奖励'); + +CREATE TABLE point_accounts ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + balance bigint NOT NULL DEFAULT 0 CHECK (balance >= 0), + lifetime_earned bigint NOT NULL DEFAULT 0 CHECK (lifetime_earned >= 0), + lifetime_spent bigint NOT NULL DEFAULT 0 CHECK (lifetime_spent >= 0), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE point_ledger ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + delta integer NOT NULL CHECK (delta <> 0 AND delta BETWEEN -100000 AND 100000), + balance_after bigint NOT NULL CHECK (balance_after >= 0), + reason text NOT NULL CHECK (char_length(btrim(reason)) BETWEEN 1 AND 200), + source_type text NOT NULL DEFAULT 'manual' CHECK (source_type IN ('manual','playback_session','media_feedback','redemption')), + source_id text NOT NULL DEFAULT '' CHECK (char_length(source_id) <= 128), + actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(user_id,source_type,source_id) +); +CREATE INDEX point_ledger_user_time_idx ON point_ledger(user_id,created_at DESC,id DESC); + +CREATE TABLE media_feedback ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + media_id uuid, + source_id uuid, + playback_session_id uuid REFERENCES playback_sessions(id) ON DELETE SET NULL, + category text NOT NULL CHECK (category IN ('playback','subtitle','audio','metadata','quality','other')), + title text NOT NULL CHECK (char_length(btrim(title)) BETWEEN 1 AND 200), + detail text NOT NULL CHECK (char_length(btrim(detail)) BETWEEN 1 AND 5000), + status text NOT NULL DEFAULT 'submitted' CHECK (status IN ('submitted','reviewing','resolved','rejected')), + priority text NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')), + resolution text NOT NULL DEFAULT '' CHECK (char_length(resolution) <= 5000), + assigned_to uuid REFERENCES users(id) ON DELETE SET NULL, + resolved_by uuid REFERENCES users(id) ON DELETE SET NULL, + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX media_feedback_user_time_idx ON media_feedback(user_id,created_at DESC); +CREATE INDEX media_feedback_queue_idx ON media_feedback(status,priority,updated_at DESC); + +CREATE TABLE telegram_bot_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + enabled boolean NOT NULL DEFAULT true, + commands_enabled boolean NOT NULL DEFAULT true, + rate_limit_per_minute integer NOT NULL DEFAULT 12 CHECK (rate_limit_per_minute BETWEEN 1 AND 120), + welcome_message text NOT NULL DEFAULT '欢迎使用站点助手,请先绑定账户。' CHECK (char_length(welcome_message) BETWEEN 1 AND 1000), + maintenance_message text NOT NULL DEFAULT '' CHECK (char_length(maintenance_message) <= 1000), + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO telegram_bot_settings(singleton) VALUES(true); + +CREATE TABLE telegram_command_logs ( + id bigserial PRIMARY KEY, + telegram_user_id bigint NOT NULL, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, + command text NOT NULL CHECK (char_length(command) BETWEEN 1 AND 64), + result text NOT NULL CHECK (char_length(result) BETWEEN 1 AND 64), + duration_ms integer NOT NULL DEFAULT 0 CHECK (duration_ms >= 0), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX telegram_command_logs_rate_idx ON telegram_command_logs(telegram_user_id,created_at DESC); +CREATE INDEX telegram_command_logs_admin_idx ON telegram_command_logs(created_at DESC,id DESC); diff --git a/s-migrations/000024_daily_checkins.up.sql b/s-migrations/000024_daily_checkins.up.sql new file mode 100644 index 0000000..86b22f7 --- /dev/null +++ b/s-migrations/000024_daily_checkins.up.sql @@ -0,0 +1,20 @@ +ALTER TABLE point_ledger DROP CONSTRAINT point_ledger_source_type_check; +ALTER TABLE point_ledger ADD CONSTRAINT point_ledger_source_type_check + CHECK (source_type IN ('manual','playback_session','media_feedback','redemption','daily_checkin')); + +INSERT INTO point_rules(rule_key,name,points,description) VALUES + ('daily_checkin.base','每日签到',5,'每天首次签到获得的积分'), + ('daily_checkin.streak_7','连续签到 7 天',10,'连续签到第 7 天的额外积分'), + ('daily_checkin.streak_30','连续签到 30 天',30,'连续签到第 30 天的额外积分') +ON CONFLICT(rule_key) DO NOTHING; + +CREATE TABLE daily_checkins ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + checkin_date date NOT NULL, + streak integer NOT NULL CHECK(streak > 0), + reward_points integer NOT NULL CHECK(reward_points >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(user_id,checkin_date) +); +CREATE INDEX daily_checkins_user_time_idx ON daily_checkins(user_id,checkin_date DESC); diff --git a/s-migrations/000025_user_store_calendar_monitoring.up.sql b/s-migrations/000025_user_store_calendar_monitoring.up.sql new file mode 100644 index 0000000..3fe88d1 --- /dev/null +++ b/s-migrations/000025_user_store_calendar_monitoring.up.sql @@ -0,0 +1,34 @@ +ALTER TABLE membership_plans + ADD COLUMN price_points integer NOT NULL DEFAULT 0 CHECK(price_points >= 0), + ADD COLUMN self_service boolean NOT NULL DEFAULT false; + +INSERT INTO membership_plans(code,name,duration_days,max_playbacks,max_devices,max_bitrate_kbps,price_points,self_service,enabled) +VALUES('points-30d','30 天会员',30,1,3,0,100,true,true) +ON CONFLICT(code) DO NOTHING; + +ALTER TABLE point_ledger DROP CONSTRAINT point_ledger_source_type_check; +ALTER TABLE point_ledger ADD CONSTRAINT point_ledger_source_type_check + CHECK (source_type IN ('manual','playback_session','media_feedback','redemption','daily_checkin','membership_purchase')); + +CREATE TABLE membership_purchases ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id uuid NOT NULL REFERENCES membership_plans(id), + price_points integer NOT NULL CHECK(price_points >= 0), + membership_id uuid REFERENCES memberships(id), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX membership_purchases_user_time_idx ON membership_purchases(user_id,created_at DESC); + +CREATE TABLE watch_calendar_entries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title text NOT NULL CHECK(length(btrim(title)) BETWEEN 1 AND 200), + episode_label text NOT NULL DEFAULT '' CHECK(length(episode_label) <= 100), + next_episode_at timestamptz NOT NULL, + notes text NOT NULL DEFAULT '' CHECK(length(notes) <= 1000), + completed boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX watch_calendar_user_time_idx ON watch_calendar_entries(user_id,completed,next_episode_at); diff --git a/s-migrations/000026_telegram_brand_and_chat_state.up.sql b/s-migrations/000026_telegram_brand_and_chat_state.up.sql new file mode 100644 index 0000000..63c49a4 --- /dev/null +++ b/s-migrations/000026_telegram_brand_and_chat_state.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE telegram_bot_settings + ADD COLUMN server_name text NOT NULL DEFAULT 'OverCooked' CHECK(char_length(btrim(server_name)) BETWEEN 1 AND 80), + ADD COLUMN icon text NOT NULL DEFAULT '🎬' CHECK(char_length(btrim(icon)) BETWEEN 1 AND 16); + +CREATE TABLE telegram_chat_states ( + chat_id bigint PRIMARY KEY, + message_id bigint NOT NULL CHECK(message_id > 0), + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/s-migrations/000027_site_brand_settings.up.sql b/s-migrations/000027_site_brand_settings.up.sql new file mode 100644 index 0000000..c7cacb8 --- /dev/null +++ b/s-migrations/000027_site_brand_settings.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE site_brand_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + server_name text NOT NULL DEFAULT 'OverCooked' CHECK (char_length(btrim(server_name)) BETWEEN 1 AND 80), + emby_display_name text NOT NULL DEFAULT 'OverCooked' CHECK (char_length(btrim(emby_display_name)) BETWEEN 1 AND 80), + icon_data_url text NOT NULL DEFAULT '' CHECK (char_length(icon_data_url) <= 700000), + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO site_brand_settings(singleton) VALUES(true); + +ALTER TABLE telegram_bot_settings + ADD COLUMN official_chat_id bigint NOT NULL DEFAULT 0, + ADD COLUMN official_chat_title text NOT NULL DEFAULT '' CHECK (char_length(official_chat_title) <= 120); + +ALTER TABLE telegram_chat_states + ADD COLUMN message_kind text NOT NULL DEFAULT 'text' CHECK (message_kind IN ('text','photo')); diff --git a/s-migrations/000028_media_request_metadata.up.sql b/s-migrations/000028_media_request_metadata.up.sql new file mode 100644 index 0000000..7e72cc9 --- /dev/null +++ b/s-migrations/000028_media_request_metadata.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE media_requests + ADD COLUMN provider text NOT NULL DEFAULT '', + ADD COLUMN external_id text NOT NULL DEFAULT '', + ADD COLUMN original_title text NOT NULL DEFAULT ''; + +ALTER TABLE media_requests + ADD CONSTRAINT media_requests_provider_check CHECK (provider IN ('', 'tmdb', 'bangumi')), + ADD CONSTRAINT media_requests_external_id_length CHECK (length(external_id) <= 512), + ADD CONSTRAINT media_requests_original_title_length CHECK (length(original_title) <= 300); + +CREATE INDEX media_requests_external_metadata_idx + ON media_requests(provider, external_id) + WHERE provider <> '' AND external_id <> ''; diff --git a/s-migrations/000029_support_attachments.up.sql b/s-migrations/000029_support_attachments.up.sql new file mode 100644 index 0000000..05e2585 --- /dev/null +++ b/s-migrations/000029_support_attachments.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE support_attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id uuid REFERENCES tickets(id) ON DELETE CASCADE, + media_feedback_id uuid REFERENCES media_feedback(id) ON DELETE CASCADE, + uploader_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename text NOT NULL CHECK (length(filename) BETWEEN 1 AND 255), + mime_type text NOT NULL CHECK (length(mime_type) BETWEEN 1 AND 128), + size_bytes bigint NOT NULL CHECK (size_bytes BETWEEN 1 AND 8388608), + content bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK ((ticket_id IS NOT NULL)::int + (media_feedback_id IS NOT NULL)::int = 1) +); + +CREATE INDEX support_attachments_ticket_idx ON support_attachments(ticket_id,created_at,id) WHERE ticket_id IS NOT NULL; +CREATE INDEX support_attachments_feedback_idx ON support_attachments(media_feedback_id,created_at,id) WHERE media_feedback_id IS NOT NULL; diff --git a/s-migrations/000030_user_password_and_banner.up.sql b/s-migrations/000030_user_password_and_banner.up.sql new file mode 100644 index 0000000..a7e53c1 --- /dev/null +++ b/s-migrations/000030_user_password_and_banner.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE site_brand_settings + ADD COLUMN banner_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN banner_image_data_url text NOT NULL DEFAULT '' CHECK (char_length(banner_image_data_url) <= 2800000), + ADD COLUMN banner_link_url text NOT NULL DEFAULT '' CHECK (char_length(banner_link_url) <= 2000); + +CREATE TABLE telegram_password_flows ( + telegram_user_id bigint PRIMARY KEY, + stage text NOT NULL CHECK (stage IN ('new_password','confirm_password')), + password_hash text NOT NULL DEFAULT '', + expires_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX telegram_password_flows_expiry_idx ON telegram_password_flows(expires_at); diff --git a/s-migrations/000031_login_background.up.sql b/s-migrations/000031_login_background.up.sql new file mode 100644 index 0000000..0ac3f59 --- /dev/null +++ b/s-migrations/000031_login_background.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE site_brand_settings + ADD COLUMN login_background_data_url text NOT NULL DEFAULT '' + CHECK (char_length(login_background_data_url) <= 5600000); diff --git a/s-migrations/000032_branding_image_limits.up.sql b/s-migrations/000032_branding_image_limits.up.sql new file mode 100644 index 0000000..1955a14 --- /dev/null +++ b/s-migrations/000032_branding_image_limits.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE site_brand_settings + DROP CONSTRAINT IF EXISTS site_brand_settings_banner_image_data_url_check, + DROP CONSTRAINT IF EXISTS site_brand_settings_login_background_data_url_check; + +ALTER TABLE site_brand_settings + ADD CONSTRAINT site_brand_settings_banner_image_data_url_check + CHECK (char_length(banner_image_data_url) <= 28000000), + ADD CONSTRAINT site_brand_settings_login_background_data_url_check + CHECK (char_length(login_background_data_url) <= 28000000); diff --git a/s-migrations/000033_negative_point_balances.up.sql b/s-migrations/000033_negative_point_balances.up.sql new file mode 100644 index 0000000..b287298 --- /dev/null +++ b/s-migrations/000033_negative_point_balances.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE point_accounts + DROP CONSTRAINT IF EXISTS point_accounts_balance_check; + +ALTER TABLE point_ledger + DROP CONSTRAINT IF EXISTS point_ledger_balance_after_check; diff --git a/s-migrations/000034_membership_limits_points_red_packets.up.sql b/s-migrations/000034_membership_limits_points_red_packets.up.sql new file mode 100644 index 0000000..905a360 --- /dev/null +++ b/s-migrations/000034_membership_limits_points_red_packets.up.sql @@ -0,0 +1,54 @@ +ALTER TABLE site_brand_settings + ADD COLUMN IF NOT EXISTS points_name text NOT NULL DEFAULT '积分' + CHECK (char_length(btrim(points_name)) BETWEEN 1 AND 20); + +UPDATE memberships +SET ends_at = timestamptz '9999-12-31 23:59:59+00' +WHERE ends_at > timestamptz '9999-12-31 23:59:59+00'; + +UPDATE card_batches SET duration_days = 365000 WHERE duration_days > 365000; +UPDATE membership_plans SET duration_days = 365000 WHERE duration_days > 365000; + +ALTER TABLE card_batches + ADD CONSTRAINT card_batches_duration_days_limit CHECK (duration_days BETWEEN 1 AND 365000); +ALTER TABLE membership_plans + ADD CONSTRAINT membership_plans_duration_days_limit CHECK (duration_days BETWEEN 1 AND 365000); + +ALTER TABLE point_ledger DROP CONSTRAINT IF EXISTS point_ledger_source_type_check; +ALTER TABLE point_ledger + ADD CONSTRAINT point_ledger_source_type_check CHECK (source_type IN ('manual','playback_session','media_feedback','redemption','daily_checkin','membership_purchase','telegram_red_packet')); + +CREATE TABLE telegram_red_packets ( + id uuid PRIMARY KEY, + chat_id bigint NOT NULL, + creator_user_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + creator_telegram_id bigint NOT NULL, + allocation_mode text NOT NULL CHECK (allocation_mode IN ('normal','random')), + claim_mode text NOT NULL CHECK (claim_mode IN ('button','password')), + total_points integer NOT NULL CHECK (total_points BETWEEN 1 AND 100000), + packet_count integer NOT NULL CHECK (packet_count BETWEEN 1 AND 1000), + remaining_points integer NOT NULL CHECK (remaining_points >= 0), + remaining_count integer NOT NULL CHECK (remaining_count >= 0), + password_hash text NOT NULL DEFAULT '', + message text NOT NULL DEFAULT '' CHECK (char_length(message) <= 200), + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','expired','closed')), + message_id bigint NOT NULL DEFAULT 0, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (remaining_count <= packet_count), + CHECK (remaining_points <= total_points) +); + +CREATE INDEX telegram_red_packets_chat_open_idx ON telegram_red_packets(chat_id, expires_at DESC) WHERE status = 'open'; + +CREATE TABLE telegram_red_packet_claims ( + id bigserial PRIMARY KEY, + packet_id uuid NOT NULL REFERENCES telegram_red_packets(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + telegram_user_id bigint NOT NULL, + amount integer NOT NULL CHECK (amount > 0), + claimed_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(packet_id, user_id), + UNIQUE(packet_id, telegram_user_id) +); diff --git a/s-migrations/000035_plugin_management_permission.up.sql b/s-migrations/000035_plugin_management_permission.up.sql new file mode 100644 index 0000000..0777f90 --- /dev/null +++ b/s-migrations/000035_plugin_management_permission.up.sql @@ -0,0 +1,17 @@ +ALTER TABLE user_permissions DROP CONSTRAINT user_permissions_permission_check; +ALTER TABLE user_permissions ADD CONSTRAINT user_permissions_permission_check CHECK (permission IN ( + 'users.read','users.write','memberships.manage','cards.manage','invites.manage', + 'requests.manage','tickets.manage','notifications.manage','security.manage', + 'operations.manage','e.manage','plugins.manage','audit.read','logs.read','backups.manage', + 'updates.manage','settings.manage','licenses.manage' +)); + +INSERT INTO user_permissions (user_id, permission, granted_by) +SELECT id, 'plugins.manage', id +FROM users +WHERE role = 'owner' +ON CONFLICT (user_id, permission) DO NOTHING; + +ALTER TABLE e_command_requests + ADD COLUMN response_status integer CHECK (response_status BETWEEN 100 AND 599), + ADD COLUMN response_body jsonb; diff --git a/s-migrations/000036_plugin_service_actor.up.sql b/s-migrations/000036_plugin_service_actor.up.sql new file mode 100644 index 0000000..fe0f235 --- /dev/null +++ b/s-migrations/000036_plugin_service_actor.up.sql @@ -0,0 +1,14 @@ +-- The plugin control plane is authenticated by E, but existing S transactions +-- require a UUID actor for foreign keys and role checks. This principal has no +-- credential row and is excluded from all user-management views. +INSERT INTO users (id, username, username_normalized, email, email_normalized, role, status) +VALUES ( + '00000000-0000-0000-0000-000000000036', + '__plugin_service__', + '__plugin_service__', + 'plugin-service@internal.invalid', + 'plugin-service@internal.invalid', + 'admin', + 'active' +) +ON CONFLICT (id) DO NOTHING; diff --git a/s-migrations/000037_user_groups.up.sql b/s-migrations/000037_user_groups.up.sql new file mode 100644 index 0000000..415d1b4 --- /dev/null +++ b/s-migrations/000037_user_groups.up.sql @@ -0,0 +1,34 @@ +CREATE TABLE user_groups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + name_normalized text NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + created_by uuid REFERENCES users(id), + updated_by uuid REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (char_length(btrim(name)) BETWEEN 1 AND 80), + CHECK (char_length(description) <= 1000) +); + +CREATE TABLE user_group_members ( + group_id uuid NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, user_id) +); +CREATE INDEX user_group_members_user_idx ON user_group_members(user_id, group_id); + +CREATE TABLE user_group_permissions ( + group_id uuid NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE, + permission text NOT NULL, + granted_by uuid REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, permission), + CHECK (permission IN ( + 'users.read','users.write','memberships.manage','cards.manage','invites.manage', + 'requests.manage','tickets.manage','notifications.manage','security.manage', + 'operations.manage','e.manage','plugins.manage','audit.read','logs.read', + 'backups.manage','updates.manage','settings.manage','licenses.manage' + )) +); diff --git a/s-migrations/000038_store_settings.up.sql b/s-migrations/000038_store_settings.up.sql new file mode 100644 index 0000000..47759b5 --- /dev/null +++ b/s-migrations/000038_store_settings.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE store_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + title text NOT NULL DEFAULT '会员商店', + introduction text NOT NULL DEFAULT '', + notice text NOT NULL DEFAULT '', + enabled boolean NOT NULL DEFAULT true, + allow_point_purchase boolean NOT NULL DEFAULT true, + updated_by text NOT NULL DEFAULT '', + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO store_settings(singleton) VALUES(true) ON CONFLICT (singleton) DO NOTHING; diff --git a/s-migrations/000039_telegram_command_auto_delete.up.sql b/s-migrations/000039_telegram_command_auto_delete.up.sql new file mode 100644 index 0000000..7854c4a --- /dev/null +++ b/s-migrations/000039_telegram_command_auto_delete.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE telegram_bot_settings + ADD COLUMN command_auto_delete_seconds integer NOT NULL DEFAULT 0 + CHECK (command_auto_delete_seconds BETWEEN 0 AND 3600); diff --git a/s-migrations/000040_enable_telegram_command_auto_delete.up.sql b/s-migrations/000040_enable_telegram_command_auto_delete.up.sql new file mode 100644 index 0000000..5358114 --- /dev/null +++ b/s-migrations/000040_enable_telegram_command_auto_delete.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE telegram_bot_settings + ALTER COLUMN command_auto_delete_seconds SET DEFAULT 60; + +-- The preceding migration introduced this setting with a disabled default. +-- Enable the requested cleanup for existing installations while retaining 0 +-- as the explicit opt-out value in the administration UI. +UPDATE telegram_bot_settings +SET command_auto_delete_seconds = 60 +WHERE singleton AND command_auto_delete_seconds = 0; diff --git a/s-migrations/000041_web_logins_not_playback_devices.up.sql b/s-migrations/000041_web_logins_not_playback_devices.up.sql new file mode 100644 index 0000000..6ad86db --- /dev/null +++ b/s-migrations/000041_web_logins_not_playback_devices.up.sql @@ -0,0 +1,10 @@ +-- WebUI refresh-token sessions are account sessions, not playback devices. +-- Retire legacy rows created by the old login path only when they have never +-- been used to reserve or play media, preserving real browser playback data. +UPDATE devices device +SET revoked_at = COALESCE(device.revoked_at, now()) +WHERE device.revoked_at IS NULL + AND lower(device.client_name) LIKE '%webui%' + AND NOT EXISTS ( + SELECT 1 FROM playback_sessions session WHERE session.device_id = device.id + ); diff --git a/s-migrations/000042_login_protection.up.sql b/s-migrations/000042_login_protection.up.sql new file mode 100644 index 0000000..0aeacc0 --- /dev/null +++ b/s-migrations/000042_login_protection.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE auth_login_failures ( + subject_type text NOT NULL CHECK (subject_type IN ('account','ip')), + subject_hash text NOT NULL, + failed_count integer NOT NULL DEFAULT 0 CHECK (failed_count >= 0), + window_started_at timestamptz NOT NULL DEFAULT now(), + locked_until timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (subject_type, subject_hash) +); + +CREATE INDEX auth_login_failures_cleanup_idx ON auth_login_failures(updated_at); diff --git a/s-migrations/000043_plugin_v11.up.sql b/s-migrations/000043_plugin_v11.up.sql new file mode 100644 index 0000000..a946153 --- /dev/null +++ b/s-migrations/000043_plugin_v11.up.sql @@ -0,0 +1,104 @@ +CREATE TABLE IF NOT EXISTS plugin_user_policies ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + is_disabled boolean NOT NULL DEFAULT false, + is_administrator boolean NOT NULL DEFAULT false, + allow_video_playback boolean NOT NULL DEFAULT true, + allow_audio_playback boolean NOT NULL DEFAULT true, + allow_content_downloading boolean NOT NULL DEFAULT false, + enable_remote_access boolean NOT NULL DEFAULT true, + enabled_library_ids text[] NOT NULL DEFAULT '{}', + blocked_library_ids text[] NOT NULL DEFAULT '{}', + max_concurrent_streams integer NOT NULL DEFAULT 0 CHECK (max_concurrent_streams >= 0), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE playback_decisions + ADD COLUMN IF NOT EXISTS media_type text NOT NULL DEFAULT '' CHECK (media_type IN ('','video','audio')); +ALTER TABLE playback_sessions + ADD COLUMN IF NOT EXISTS media_type text NOT NULL DEFAULT '' CHECK (media_type IN ('','video','audio')); +CREATE INDEX IF NOT EXISTS playback_sessions_plugin_report_idx ON playback_sessions(created_at DESC,user_id); + +CREATE TABLE IF NOT EXISTS user_group_media_policies ( + group_id uuid PRIMARY KEY REFERENCES user_groups(id) ON DELETE CASCADE, + enabled_library_ids text[] NOT NULL DEFAULT '{}', + blocked_library_ids text[] NOT NULL DEFAULT '{}', + updated_by uuid REFERENCES users(id), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (cardinality(enabled_library_ids) <= 500), + CHECK (cardinality(blocked_library_ids) <= 500) +); + +ALTER TABLE invite_codes + ADD COLUMN IF NOT EXISTS owner_reward_points integer NOT NULL DEFAULT 0 CHECK (owner_reward_points >= 0), + ADD COLUMN IF NOT EXISTS owner_reward_membership_days integer NOT NULL DEFAULT 0 CHECK (owner_reward_membership_days >= 0), + ADD COLUMN IF NOT EXISTS redeemer_reward_points integer NOT NULL DEFAULT 0 CHECK (redeemer_reward_points >= 0), + ADD COLUMN IF NOT EXISTS redeemer_reward_membership_days integer NOT NULL DEFAULT 0 CHECK (redeemer_reward_membership_days >= 0); + +CREATE TABLE IF NOT EXISTS invite_redemptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + invite_id uuid NOT NULL REFERENCES invite_codes(id) ON DELETE CASCADE, + redeemed_by_user_id uuid NOT NULL REFERENCES users(id), + idempotency_key text NOT NULL CHECK (char_length(idempotency_key) BETWEEN 1 AND 256), + owner_reward_points integer NOT NULL DEFAULT 0, + owner_reward_membership_days integer NOT NULL DEFAULT 0, + redeemer_reward_points integer NOT NULL DEFAULT 0, + redeemer_reward_membership_days integer NOT NULL DEFAULT 0, + redeemed_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (invite_id, redeemed_by_user_id), + UNIQUE (invite_id, idempotency_key) +); +CREATE INDEX IF NOT EXISTS invite_redemptions_user_time_idx ON invite_redemptions(redeemed_by_user_id,redeemed_at DESC); + +INSERT INTO membership_plans(code,name,duration_days,max_playbacks,max_devices,max_bitrate_kbps,price_points,self_service,enabled) +VALUES('points-7d','7 天会员',7,1,3,0,30,true,true) +ON CONFLICT(code) DO NOTHING; + +ALTER TABLE point_ledger DROP CONSTRAINT IF EXISTS point_ledger_source_type_check; +ALTER TABLE point_ledger ADD CONSTRAINT point_ledger_source_type_check CHECK (source_type IN ( + 'manual','playback_session','media_feedback','redemption','daily_checkin', + 'membership_purchase','telegram_red_packet','invite_reward' +)); + +CREATE TABLE IF NOT EXISTS plugin_telegram_contexts ( + token_hash bytea PRIMARY KEY, + plugin_id text NOT NULL, + tenant_id text NOT NULL, + telegram_user_id bigint NOT NULL, + chat_id bigint NOT NULL, + telegram_username text NOT NULL DEFAULT '', + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (expires_at > created_at) +); +CREATE INDEX IF NOT EXISTS plugin_telegram_contexts_expiry_idx ON plugin_telegram_contexts(expires_at) WHERE consumed_at IS NULL; + +CREATE TABLE IF NOT EXISTS plugin_telegram_sends ( + plugin_id text NOT NULL, + tenant_id text NOT NULL, + action text NOT NULL CHECK (action IN ('send_photo','send_document')), + idempotency_key text NOT NULL CHECK (char_length(idempotency_key) BETWEEN 1 AND 256), + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent')), + message_id bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (plugin_id,tenant_id,action,idempotency_key) +); +CREATE INDEX IF NOT EXISTS plugin_telegram_sends_pending_idx ON plugin_telegram_sends(updated_at) WHERE status='pending'; + +CREATE TABLE IF NOT EXISTS plugin_audit_records ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + plugin_id text NOT NULL, + plugin_version text NOT NULL, + tenant_id text NOT NULL, + actor_type text NOT NULL, + actor_id text NOT NULL, + action text NOT NULL, + target_type text NOT NULL, + target_id text NOT NULL DEFAULT '', + request_id text NOT NULL DEFAULT '', + trace_id text NOT NULL DEFAULT '', + result text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS plugin_audit_records_plugin_time_idx ON plugin_audit_records(plugin_id,tenant_id,created_at DESC,id DESC); diff --git a/s-migrations/000044_telegram_membership_signup.up.sql b/s-migrations/000044_telegram_membership_signup.up.sql new file mode 100644 index 0000000..385bce2 --- /dev/null +++ b/s-migrations/000044_telegram_membership_signup.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE telegram_membership_signup_flows ( + token_hash text PRIMARY KEY, + actor_user_id uuid NOT NULL REFERENCES users(id), + telegram_user_id bigint NOT NULL, + telegram_username text NOT NULL DEFAULT '', + started_at timestamptz, + consumed_at timestamptz, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX telegram_membership_signup_flows_active_target_idx + ON telegram_membership_signup_flows(telegram_user_id) + WHERE consumed_at IS NULL; diff --git a/s-migrations/000045_plugin_telegram_inbound_media.up.sql b/s-migrations/000045_plugin_telegram_inbound_media.up.sql new file mode 100644 index 0000000..d79fc81 --- /dev/null +++ b/s-migrations/000045_plugin_telegram_inbound_media.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE plugin_telegram_contexts + ADD COLUMN IF NOT EXISTS message_id bigint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS chat_type text NOT NULL DEFAULT 'private', + ADD COLUMN IF NOT EXISTS inbound_media jsonb NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE plugin_telegram_sends DROP CONSTRAINT IF EXISTS plugin_telegram_sends_action_check; +ALTER TABLE plugin_telegram_sends ADD CONSTRAINT plugin_telegram_sends_action_check + CHECK (action IN ('send_message','send_photo','send_document','resend_inbound_media','bot_api','chat_api')); diff --git a/s-migrations/000046_plugin_telegram_chat_type.up.sql b/s-migrations/000046_plugin_telegram_chat_type.up.sql new file mode 100644 index 0000000..e45c991 --- /dev/null +++ b/s-migrations/000046_plugin_telegram_chat_type.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE plugin_telegram_contexts + ADD COLUMN IF NOT EXISTS chat_type text NOT NULL DEFAULT 'private'; diff --git a/s-migrations/000047_plugin_telegram_send_message.up.sql b/s-migrations/000047_plugin_telegram_send_message.up.sql new file mode 100644 index 0000000..4fea51b --- /dev/null +++ b/s-migrations/000047_plugin_telegram_send_message.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE plugin_telegram_sends DROP CONSTRAINT IF EXISTS plugin_telegram_sends_action_check; +ALTER TABLE plugin_telegram_sends ADD CONSTRAINT plugin_telegram_sends_action_check + CHECK (action IN ('send_message','send_photo','send_document','resend_inbound_media','bot_api','chat_api')); diff --git a/s-migrations/000048_telegram_pro_user_duration.up.sql b/s-migrations/000048_telegram_pro_user_duration.up.sql new file mode 100644 index 0000000..f0f739f --- /dev/null +++ b/s-migrations/000048_telegram_pro_user_duration.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE telegram_membership_signup_flows + ADD COLUMN membership_duration_days integer NOT NULL DEFAULT 30 + CHECK (membership_duration_days BETWEEN 1 AND 365000); diff --git a/s-migrations/000049_telegram_group_cleanup.up.sql b/s-migrations/000049_telegram_group_cleanup.up.sql new file mode 100644 index 0000000..f90493d --- /dev/null +++ b/s-migrations/000049_telegram_group_cleanup.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE telegram_group_members ( + chat_id bigint NOT NULL, + telegram_user_id bigint NOT NULL, + username text NOT NULL DEFAULT '', + first_seen_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (chat_id, telegram_user_id) +); + +CREATE TABLE telegram_group_cleanup_flows ( + token_hash text PRIMARY KEY, + actor_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + chat_id bigint NOT NULL, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX telegram_group_cleanup_flows_active_idx + ON telegram_group_cleanup_flows(actor_user_id, chat_id) + WHERE consumed_at IS NULL; diff --git a/s-migrations/000050_telegram_rebind_after_unbind.up.sql b/s-migrations/000050_telegram_rebind_after_unbind.up.sql new file mode 100644 index 0000000..ead635a --- /dev/null +++ b/s-migrations/000050_telegram_rebind_after_unbind.up.sql @@ -0,0 +1,7 @@ +-- A disabled row records binding history only. It must not reserve the numeric +-- Telegram identity forever, otherwise a user who unbinds cannot bind a newly +-- registered panel account. +ALTER TABLE telegram_accounts DROP CONSTRAINT IF EXISTS telegram_accounts_telegram_user_id_key; +CREATE UNIQUE INDEX IF NOT EXISTS telegram_accounts_telegram_user_id_active_idx + ON telegram_accounts(telegram_user_id) + WHERE disabled_at IS NULL; diff --git a/s-migrations/000051_storage_webhook_events.up.sql b/s-migrations/000051_storage_webhook_events.up.sql new file mode 100644 index 0000000..171a107 --- /dev/null +++ b/s-migrations/000051_storage_webhook_events.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE storage_webhook_events ( + event_id text PRIMARY KEY, + library_id uuid NOT NULL, + event_type text NOT NULL, + paths jsonb NOT NULL, + task_ids jsonb NOT NULL DEFAULT '[]'::jsonb, + state text NOT NULL CHECK (state IN ('processing','accepted','failed')), + error_code text NOT NULL DEFAULT '', + received_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz +); + +CREATE INDEX storage_webhook_events_received_idx ON storage_webhook_events(received_at DESC); diff --git a/s-migrations/000052_user_agent_security_rules.up.sql b/s-migrations/000052_user_agent_security_rules.up.sql new file mode 100644 index 0000000..4120f08 --- /dev/null +++ b/s-migrations/000052_user_agent_security_rules.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE security_list_entries DROP CONSTRAINT security_list_entries_subject_type_check; +ALTER TABLE security_list_entries ADD CONSTRAINT security_list_entries_subject_type_check + CHECK (subject_type IN ('ip','cidr','device','user','user_agent')); diff --git a/s-migrations/000053_precise_client_access_rules.up.sql b/s-migrations/000053_precise_client_access_rules.up.sql new file mode 100644 index 0000000..c572ac6 --- /dev/null +++ b/s-migrations/000053_precise_client_access_rules.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE security_list_entries DROP CONSTRAINT security_list_entries_subject_type_check; +ALTER TABLE security_list_entries ADD CONSTRAINT security_list_entries_subject_type_check + CHECK (subject_type IN ('ip','cidr','device','user','user_agent','client_name','client_version')); diff --git a/s-migrations/000054_api_keys.up.sql b/s-migrations/000054_api_keys.up.sql new file mode 100644 index 0000000..bd280aa --- /dev/null +++ b/s-migrations/000054_api_keys.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE api_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 128), + prefix text NOT NULL UNIQUE CHECK (prefix ~ '^oc_[a-z0-9]{8}$'), + key_hash bytea NOT NULL UNIQUE, + expires_at timestamptz, + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz +); +CREATE INDEX api_keys_user_idx ON api_keys(user_id,created_at DESC); +CREATE INDEX api_keys_active_idx ON api_keys(prefix) WHERE enabled; diff --git a/s-migrations/000055_playback_traffic.up.sql b/s-migrations/000055_playback_traffic.up.sql new file mode 100644 index 0000000..97b640d --- /dev/null +++ b/s-migrations/000055_playback_traffic.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE playback_sessions ADD COLUMN IF NOT EXISTS bytes_served bigint NOT NULL DEFAULT 0 CHECK (bytes_served >= 0); +ALTER TABLE playback_sessions ADD COLUMN IF NOT EXISTS estimated_bytes bigint NOT NULL DEFAULT 0 CHECK (estimated_bytes >= 0); +CREATE INDEX IF NOT EXISTS playback_sessions_traffic_user_idx ON playback_sessions(user_id, updated_at DESC); diff --git a/s-migrations/000056_playback_estimate.up.sql b/s-migrations/000056_playback_estimate.up.sql new file mode 100644 index 0000000..dc0bca1 --- /dev/null +++ b/s-migrations/000056_playback_estimate.up.sql @@ -0,0 +1 @@ +ALTER TABLE playback_sessions ADD COLUMN IF NOT EXISTS estimated_bytes bigint NOT NULL DEFAULT 0 CHECK (estimated_bytes >= 0); diff --git a/s-migrations/000057_admin_agent.up.sql b/s-migrations/000057_admin_agent.up.sql new file mode 100644 index 0000000..ef635a7 --- /dev/null +++ b/s-migrations/000057_admin_agent.up.sql @@ -0,0 +1,60 @@ +CREATE TABLE agent_settings ( + id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled boolean NOT NULL DEFAULT false, + provider_name text NOT NULL DEFAULT '', + provider_type text NOT NULL DEFAULT 'openai_compatible' CHECK (provider_type IN ('openai_compatible','openai','custom_http')), + base_url text NOT NULL DEFAULT '', + model text NOT NULL DEFAULT '', + encrypted_api_key bytea, + timeout_ms integer NOT NULL DEFAULT 30000 CHECK (timeout_ms BETWEEN 1000 AND 120000), + max_tokens integer NOT NULL DEFAULT 2048 CHECK (max_tokens BETWEEN 1 AND 32768), + temperature double precision NOT NULL DEFAULT 0.2 CHECK (temperature BETWEEN 0 AND 2), + system_prompt text NOT NULL DEFAULT '', + max_history integer NOT NULL DEFAULT 20 CHECK (max_history BETWEEN 1 AND 100), + rate_limit integer NOT NULL DEFAULT 30 CHECK (rate_limit BETWEEN 1 AND 600), + request_logging boolean NOT NULL DEFAULT false, + tool_enabled boolean NOT NULL DEFAULT true, + confirmation_required boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by uuid REFERENCES users(id) +); + +CREATE TABLE agent_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + admin_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title text NOT NULL DEFAULT 'OverCooked Admin Agent', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL DEFAULT (now() + interval '24 hours') +); +CREATE INDEX agent_sessions_admin_idx ON agent_sessions(admin_user_id, updated_at DESC); + +CREATE TABLE agent_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ('system','user','assistant','tool')), + content text NOT NULL, + tool_name text, + tool_call_id text, + confirmation_state text NOT NULL DEFAULT 'none' CHECK (confirmation_state IN ('none','pending','confirmed','cancelled','expired')), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX agent_messages_session_idx ON agent_messages(session_id, created_at); + +CREATE TABLE agent_audit ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid REFERENCES agent_sessions(id) ON DELETE SET NULL, + admin_user_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + capability text NOT NULL, + action text NOT NULL, + target text NOT NULL DEFAULT '', + request_summary text NOT NULL DEFAULT '', + result jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL CHECK (status IN ('planned','pending_confirmation','success','cancelled','failed')), + error_code text NOT NULL DEFAULT '', + trace_id text NOT NULL DEFAULT '', + latency_ms bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX agent_audit_admin_idx ON agent_audit(admin_user_id, created_at DESC); diff --git a/test/static-check.sh b/test/static-check.sh new file mode 100755 index 0000000..3d3db98 --- /dev/null +++ b/test/static-check.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +sh -n install.sh +sh -n s-migrate.sh + +if rg -n 'git[[:space:]]+clone|OverCooked-S\.git|Overcooked-E\.git|Authorization:[[:space:]]*Bearer' . --glob '!.git/**'; then + echo 'forbidden source checkout or authorization header found' >&2 + exit 1 +fi + +test -f compose/s.yml +test -f compose/e.yml +test -f config/e-server.production.yaml +test -f s-migrations.tar.gz +test "$(find s-migrations -type f -name '*.up.sql' | wc -l)" -gt 0 +! rg -n '^([[:space:]]*)build:' compose +! rg -n 'overcooked-auth|overcooked-auth-webui' compose install.sh +echo 'static customer installer checks passed' +