From 40d484bede825f2811b2d9e13e9910085d9a9e7e Mon Sep 17 00:00:00 2001 From: space Date: Sat, 25 Jul 2026 00:18:08 +0200 Subject: [PATCH] feat: initial scaffold - backend, frontend, Prisma schema, Docker - Prisma schema: User, Session, RepoConfig, Preview, Job, WebhookToken, NoConfigComment, AdminSettings - Backend: auth (login/logout/me/first-user setup), webhook handler with HMAC verification, EC2 service, SSH service, deploy pipeline, job queue worker, cron workers - Frontend: Login with first-user detection, Dashboard, PreviewDetail with live log streaming, Settings, Repos config, Admin panel, SetupWizard, Privacy page - Docker Compose and Dockerfile for self-hosted deployment - Uses bcryptjs for Node 24 compatibility Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 6 + Dockerfile | 30 + SPEC.md | 478 ++++ backend/.env | 7 + backend/dist/index.js | 119 + backend/dist/index.js.map | 7 + backend/dist/lib/adminSettings.js | 36 + backend/dist/lib/adminSettings.js.map | 7 + backend/dist/lib/db.js | 33 + backend/dist/lib/db.js.map | 7 + backend/dist/lib/encryption.js | 50 + backend/dist/lib/encryption.js.map | 7 + backend/dist/lib/env.js | 58 + backend/dist/lib/env.js.map | 7 + backend/dist/lib/errors.js | 37 + backend/dist/lib/errors.js.map | 7 + backend/dist/lib/logger.js | 49 + backend/dist/lib/logger.js.map | 7 + backend/dist/lib/middlewares/auth.js | 88 + backend/dist/lib/middlewares/auth.js.map | 7 + backend/dist/lib/middlewares/cors.js | 40 + backend/dist/lib/middlewares/cors.js.map | 7 + backend/dist/lib/middlewares/main.js | 38 + backend/dist/lib/middlewares/main.js.map | 7 + backend/dist/lib/response.js | 64 + backend/dist/lib/response.js.map | 7 + backend/dist/routes/api/admin.js | 192 ++ backend/dist/routes/api/admin.js.map | 7 + backend/dist/routes/api/previews.js | 153 ++ backend/dist/routes/api/previews.js.map | 7 + backend/dist/routes/api/repos.js | 180 ++ backend/dist/routes/api/repos.js.map | 7 + backend/dist/routes/api/user.js | 204 ++ backend/dist/routes/api/user.js.map | 7 + backend/dist/routes/auth.js | 161 ++ backend/dist/routes/auth.js.map | 7 + backend/dist/routes/webhook.js | 279 ++ backend/dist/routes/webhook.js.map | 7 + backend/dist/services/deploy.js | 440 ++++ backend/dist/services/deploy.js.map | 7 + backend/dist/services/ec2.js | 249 ++ backend/dist/services/ec2.js.map | 7 + backend/dist/services/gitea.js | 170 ++ backend/dist/services/gitea.js.map | 7 + backend/dist/services/ssh.js | 115 + backend/dist/services/ssh.js.map | 7 + backend/dist/workers/cronWorker.js | 100 + backend/dist/workers/cronWorker.js.map | 7 + backend/dist/workers/jobWorker.js | 114 + backend/dist/workers/jobWorker.js.map | 7 + backend/package.json | 40 + backend/pnpm-lock.yaml | 2253 +++++++++++++++++ backend/src/index.ts | 130 + backend/src/lib/adminSettings.ts | 9 + backend/src/lib/db.ts | 6 + backend/src/lib/encryption.ts | 23 + backend/src/lib/env.ts | 26 + backend/src/lib/errors.ts | 9 + backend/src/lib/logger.ts | 14 + backend/src/lib/middlewares/auth.ts | 83 + backend/src/lib/middlewares/cors.ts | 16 + backend/src/lib/middlewares/main.ts | 15 + backend/src/lib/response.ts | 49 + backend/src/routes/api/admin.ts | 170 ++ backend/src/routes/api/previews.ts | 133 + backend/src/routes/api/repos.ts | 172 ++ backend/src/routes/api/user.ts | 182 ++ backend/src/routes/auth.ts | 134 + backend/src/routes/webhook.ts | 279 ++ backend/src/services/deploy.ts | 466 ++++ backend/src/services/ec2.ts | 242 ++ backend/src/services/gitea.ts | 133 + backend/src/services/ssh.ts | 90 + backend/src/workers/cronWorker.ts | 76 + backend/src/workers/jobWorker.ts | 99 + backend/tsconfig.json | 18 + docker-compose.yml | 43 + example.env | 21 + frontend/index.html | 13 + frontend/package.json | 28 + frontend/pnpm-lock.yaml | 1790 +++++++++++++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 91 + frontend/src/components/ConfirmDialog.tsx | 56 + frontend/src/components/Layout.tsx | 82 + frontend/src/components/LogViewer.tsx | 62 + frontend/src/components/StatusBadge.tsx | 21 + frontend/src/hooks/useAuth.ts | 54 + frontend/src/hooks/useTheme.ts | 16 + frontend/src/index.css | 43 + frontend/src/main.tsx | 13 + frontend/src/pages/Admin.tsx | 286 +++ frontend/src/pages/Dashboard.tsx | 129 + frontend/src/pages/Login.tsx | 122 + frontend/src/pages/PreviewDetail.tsx | 195 ++ frontend/src/pages/Privacy.tsx | 41 + frontend/src/pages/Repos.tsx | 346 +++ frontend/src/pages/Settings.tsx | 290 +++ frontend/src/pages/SetupWizard.tsx | 227 ++ frontend/src/services/api.ts | 74 + frontend/tailwind.config.js | 13 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 16 + package-lock.json | 438 ++++ package.json | 8 + .../20260724221403_init/migration.sql | 168 ++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 160 ++ 108 files changed, 13393 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 SPEC.md create mode 100644 backend/.env create mode 100644 backend/dist/index.js create mode 100644 backend/dist/index.js.map create mode 100644 backend/dist/lib/adminSettings.js create mode 100644 backend/dist/lib/adminSettings.js.map create mode 100644 backend/dist/lib/db.js create mode 100644 backend/dist/lib/db.js.map create mode 100644 backend/dist/lib/encryption.js create mode 100644 backend/dist/lib/encryption.js.map create mode 100644 backend/dist/lib/env.js create mode 100644 backend/dist/lib/env.js.map create mode 100644 backend/dist/lib/errors.js create mode 100644 backend/dist/lib/errors.js.map create mode 100644 backend/dist/lib/logger.js create mode 100644 backend/dist/lib/logger.js.map create mode 100644 backend/dist/lib/middlewares/auth.js create mode 100644 backend/dist/lib/middlewares/auth.js.map create mode 100644 backend/dist/lib/middlewares/cors.js create mode 100644 backend/dist/lib/middlewares/cors.js.map create mode 100644 backend/dist/lib/middlewares/main.js create mode 100644 backend/dist/lib/middlewares/main.js.map create mode 100644 backend/dist/lib/response.js create mode 100644 backend/dist/lib/response.js.map create mode 100644 backend/dist/routes/api/admin.js create mode 100644 backend/dist/routes/api/admin.js.map create mode 100644 backend/dist/routes/api/previews.js create mode 100644 backend/dist/routes/api/previews.js.map create mode 100644 backend/dist/routes/api/repos.js create mode 100644 backend/dist/routes/api/repos.js.map create mode 100644 backend/dist/routes/api/user.js create mode 100644 backend/dist/routes/api/user.js.map create mode 100644 backend/dist/routes/auth.js create mode 100644 backend/dist/routes/auth.js.map create mode 100644 backend/dist/routes/webhook.js create mode 100644 backend/dist/routes/webhook.js.map create mode 100644 backend/dist/services/deploy.js create mode 100644 backend/dist/services/deploy.js.map create mode 100644 backend/dist/services/ec2.js create mode 100644 backend/dist/services/ec2.js.map create mode 100644 backend/dist/services/gitea.js create mode 100644 backend/dist/services/gitea.js.map create mode 100644 backend/dist/services/ssh.js create mode 100644 backend/dist/services/ssh.js.map create mode 100644 backend/dist/workers/cronWorker.js create mode 100644 backend/dist/workers/cronWorker.js.map create mode 100644 backend/dist/workers/jobWorker.js create mode 100644 backend/dist/workers/jobWorker.js.map create mode 100644 backend/package.json create mode 100644 backend/pnpm-lock.yaml create mode 100644 backend/src/index.ts create mode 100644 backend/src/lib/adminSettings.ts create mode 100644 backend/src/lib/db.ts create mode 100644 backend/src/lib/encryption.ts create mode 100644 backend/src/lib/env.ts create mode 100644 backend/src/lib/errors.ts create mode 100644 backend/src/lib/logger.ts create mode 100644 backend/src/lib/middlewares/auth.ts create mode 100644 backend/src/lib/middlewares/cors.ts create mode 100644 backend/src/lib/middlewares/main.ts create mode 100644 backend/src/lib/response.ts create mode 100644 backend/src/routes/api/admin.ts create mode 100644 backend/src/routes/api/previews.ts create mode 100644 backend/src/routes/api/repos.ts create mode 100644 backend/src/routes/api/user.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/webhook.ts create mode 100644 backend/src/services/deploy.ts create mode 100644 backend/src/services/ec2.ts create mode 100644 backend/src/services/gitea.ts create mode 100644 backend/src/services/ssh.ts create mode 100644 backend/src/workers/cronWorker.ts create mode 100644 backend/src/workers/jobWorker.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 example.env create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/ConfirmDialog.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/LogViewer.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useTheme.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Admin.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/PreviewDetail.tsx create mode 100644 frontend/src/pages/Privacy.tsx create mode 100644 frontend/src/pages/Repos.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/SetupWizard.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 prisma/migrations/20260724221403_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9c5bf21d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +*/node_modules +*/dist +.git +*.md +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9ce2c9e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:24-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package.json frontend/pnpm-lock.yaml* ./ +RUN npm install -g pnpm && pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +FROM node:24-alpine AS backend-builder +WORKDIR /app/backend +COPY backend/package.json backend/pnpm-lock.yaml* ./ +RUN npm install -g pnpm && pnpm install --frozen-lockfile +COPY backend/ ./ +COPY prisma/ ../prisma/ +RUN pnpm run generate && pnpm run build + +FROM node:24-alpine AS runner +WORKDIR /app +RUN apk add --no-cache openssl + +COPY --from=backend-builder /app/backend/dist ./backend/dist +COPY --from=backend-builder /app/backend/node_modules ./backend/node_modules +COPY --from=backend-builder /app/backend/package.json ./backend/package.json +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist +COPY prisma/ ./prisma/ + +WORKDIR /app/backend + +EXPOSE 5000 + +CMD sh -c "npx prisma migrate deploy --schema=../prisma/schema.prisma && node dist/index.js" diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 00000000..272ef04d --- /dev/null +++ b/SPEC.md @@ -0,0 +1,478 @@ +# PR Previews (PP) +> PP is a self-hosted service that connects to a Gitea instance via webhook. When a PR is opened or updated, PP automatically provisions an AWS EC2 instance, builds and runs the project, and comments a live preview URL back on the PR. Designed for teams and agents iterating on code without needing local environments. + +## Purpose +- Provide a shareable live preview of any PR before it's merged. +- Remove the need for reviewers to pull and run code locally. +- Works well in agent-driven workflows where humans review from mobile or external environments. + +--- + +## Tech Stack +- **Backend:** `rj-web` from `../shsf` (same setup pattern), logging + auth middleware + Prisma from `../shsf` adapted to this project's needs. +- **Frontend:** React + Vite + Tailwind CSS (dark/light mode toggle, responsive). +- **Database:** PostgreSQL via Prisma (schema at `./prisma/schema.prisma`). +- **CI/CD:** Reuse `../shsf` CI/CD pipeline; build and push Docker image to the same registry under `/pp-previews/core`. + +## Project Structure +``` +./backend/ # Node backend (rj-web) +./frontend/ # React + Vite frontend +./prisma/schema.prisma # Prisma schema +./docker-compose.yml # Compose + Dockerfile for PP itself +``` + +--- + +## Implementation Notes +You're building this from scratch. Refer to `../shsf` for patterns on rj-web setup, logging, auth middleware, and Prisma client initialization. + +**Testing:** No automated tests for V1. Build to fully working state locally first. The first real test will be against a live Gitea instance with a real repo. Build as if it will be tested end-to-end immediately. + +--- + +## Data Model (Prisma) + +### User +PP account. +- id, username, passwordHash, isAdmin, isFounder, createdAt +- giteaUsername, giteaPAT (encrypted), giteaInstanceUrl (e.g. `https://gitea.example.com`, no trailing slash) +- awsAccessKeyId (encrypted), awsSecretAccessKey (encrypted), awsRegion + +### RepoConfig +Per-repo settings, one per user per repo. A repo can only be configured by one user — first to save wins; others receive an error. +- id, userId, repoOwner, repoName +- isEnabled (bool) +- giteaWebhookId (string, stored after auto-registration so PP can delete/update it later) +- denyList (string[], Gitea usernames to skip — e.g. dependabot) +- instanceType (string, default from AdminSettings.defaultInstanceType) +- inactivityHours (float, default 12, range 0.5–72) +- port (int, default 3000 — the port the app listens on inside the EC2 instance) +- envVars (JSON key-value) +- useDockerCompose (bool) +- composeFilePath (string, nullable — path within repo, default `docker-compose.yml`) +- aptPackages (string[]) +- setupCommands (string[]) +- buildCommands (string[]) +- postBuildCommands (string[]) +- runCommand (string, single — non-compose mode only) + +### Preview +One record per PR. Persists across pushes; the EC2 instance is reused on each new push (only build + run steps re-run). +- id, repoConfigId, prNumber, prTitle +- commitSha (updated on each push) +- instanceId (AWS EC2 instance ID) +- instanceIp (public IP) +- port (copied from RepoConfig at deploy time) +- status (enum: `PROVISIONING` | `BUILDING` | `RUNNING` | `FAILED` | `STOPPED` | `IGNORED`) +- logs (text, appended each redeploy, capped at AdminSettings.logSizeLimitBytes — oldest lines truncated with a `--- logs truncated ---` marker) +- pid (int, nullable — non-compose mode only, the background process PID on the EC2 instance) +- sshPrivateKey (encrypted — ephemeral, generated per launch, deleted on instance termination) +- sshKeyName (string — AWS key pair name, for deletion on terminate) +- giteaCommentId (int, nullable — the Gitea comment ID of PP's status comment, for in-place edits) +- lastActivityAt (DateTime — updated on each push webhook AND each PR comment webhook) +- createdAt, updatedAt, stoppedAt + +### Job +Durable async job queue for deployments and stops. +- id, previewId (nullable), type (enum: `DEPLOY` | `STOP` | `INACTIVITY_STOP`), status (enum: `PENDING` | `RUNNING` | `DONE` | `FAILED`) +- payload (JSON), error (text, nullable) +- createdAt, startedAt, finishedAt + +### WebhookToken +Per-user HMAC secret for verifying Gitea webhook payloads. +- id, userId, token (random secret), createdAt + +### NoConfigComment +Tracks which PRs have already received a "no previews configured" comment to avoid repeat spam. +- id, userId, repoOwner, repoName, prNumber, createdAt + +### AdminSettings +Single row global config (seeded on first run). +- id +- defaultInstanceType (string, default `t2.medium`) +- maxConcurrentInstancesPerUser (int, default 5) +- logSizeLimitBytes (int, default 1048576 — 1MB) +- previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged) +- webhookRateLimitPerMinute (int, default 10 — per user) +- contactEmail (string — shown in privacy policy footer) + +--- + +## Backend + +### Auth +- Session-based auth (reuse shsf middleware). +- Registration endpoint is disabled — only admins can create users via the admin panel. +- First registered user automatically becomes admin and is flagged as `isFounder = true`. The founder cannot be demoted or deleted. + +### Webhook Endpoint +`POST /webhook/:userId` + +**Rate limiting:** Each user is limited to `AdminSettings.webhookRateLimitPerMinute` webhook events per minute. Excess requests receive `429 Too Many Requests` and are dropped (not queued). + +**Signature verification:** Gitea sends `X-Gitea-Signature-256: sha256=`. PP verifies using the user's `WebhookToken.token` before doing anything else. Invalid signature → `401`. + +**Accepted Gitea events** (set via `X-Gitea-Event` header): + +- `pull_request` with action `opened` → trigger new deploy +- `pull_request` with action `synchronize` → trigger redeploy on existing Preview +- `pull_request` with action `reopened` → same as `opened` +- `pull_request` with action `closed` → stop and terminate the preview +- `issue_comment` with action `created` → parse for `/pp` commands (see PP Commands section) + +**Webhook handler returns `200 OK` immediately** after enqueueing a job. All processing is async. + +**Activity tracking:** Update `Preview.lastActivityAt = now` on both `synchronize` and `issue_comment` events. + +### PP Commands +PP listens for comments on PRs (via `issue_comment` webhook). A comment is a PP command if it starts with `/pp ` as the first line. Commands are only acted on if posted by: the PR author, or a Gitea user with `owner` or `admin` role on the repo (checked via `GET /api/v1/repos/{owner}/{repo}/teams` or collaborator endpoint). + +| Command | Behaviour | +|---|---| +| `/pp rebuild` | Re-runs build + start steps on the existing EC2 instance (same as a new push but without a git pull). | +| `/pp stop` | Stops and terminates the preview instance. Sets status to `STOPPED`. | +| `/pp start` | If `STOPPED`: re-provisions a new EC2 and deploys. If `IGNORED`: un-ignores AND immediately triggers a fresh deploy. | +| `/pp logs` | PP posts a new comment with the last 50 lines of `Preview.logs` in a fenced code block. | +| `/pp ignore` | Sets `Preview.status = IGNORED`. Future pushes and commands (except `/pp start`) are silently ignored. If no Preview exists yet, creates one in `IGNORED` state so future pushes are skipped. | + +PP does **not** react to its own comments (check that the commenter's username != `User.giteaUsername` for the configured user). + +### AWS EC2 Management + +**Required IAM permissions** (document in the setup wizard UI with a copy-pasteable JSON policy): +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "ec2:RunInstances", + "ec2:TerminateInstances", + "ec2:DescribeInstances", + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:DescribeSecurityGroups", + "ec2:ImportKeyPair", + "ec2:DeleteKeyPair", + "ec2:CreateTags", + "sts:GetCallerIdentity" + ], + "Resource": "*" + }] +} +``` + +**EC2 instance tagging** — every instance PP launches must be tagged: +``` +pp:managed = true +pp:userId = +pp:repo = / +pp:prNumber = +pp:previewId = +``` + +**EC2 setup per preview:** +1. Generate an ephemeral RSA key pair. Store the private key encrypted in `Preview.sshPrivateKey`. Import the public key to AWS as `pp-preview-` and store the name in `Preview.sshKeyName`. +2. Create a security group named `pp-preview-` in the default VPC. Allow inbound: TCP 22 (SSH) and TCP `` from `0.0.0.0/0`. +3. Launch instance: + - AMI: Ubuntu 22.04 LTS (hardcode a per-region AMI map, or resolve via SSM `resolve:ssm:/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id`) + - Instance type from `RepoConfig.instanceType` + - Key pair: `pp-preview-` + - Security group: `pp-preview-` + - User data bootstrap script (runs once on first boot): + ```bash + #!/bin/bash + apt-get update -y + apt-get install -y curl git unzip build-essential + # Docker + curl -fsSL https://get.docker.com | sh + systemctl enable docker + # Docker Compose v2 (plugin) + apt-get install -y docker-compose-plugin + # NVM + Node LTS + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh" + nvm install --lts + nvm alias default lts/* + ``` + - All instance tags as above. +4. Poll `DescribeInstances` until status = `running` and the instance has a public IP. +5. Poll TCP port 22 until it accepts connections (max 5 minutes, then fail). + +**Orphan cleanup on startup:** On PP startup, call `DescribeInstances` with filter `tag:pp:managed=true` and compare against all non-`STOPPED` Preview records. Any instance with no matching Preview, or whose matching Preview is `STOPPED`, is terminated and its security group deleted. + +**On instance termination:** +1. Delete the AWS key pair (`DeleteKeyPair` using `Preview.sshKeyName`). +2. Delete the security group `pp-preview-`. +3. Terminate the instance. +4. Null out `Preview.sshPrivateKey` and `Preview.sshKeyName` in the DB. + +### Deployment Flow + +Each PR has exactly one Preview record. The EC2 instance is provisioned once and reused on subsequent pushes. + +#### First push (PR opened / no existing Preview, or previous Preview is STOPPED) +Set Preview status to `PROVISIONING`. + +1. Check `AdminSettings.maxConcurrentInstancesPerUser` — count PROVISIONING + BUILDING + RUNNING previews for this user. If at limit: post a Gitea comment on the PR explaining the limit is reached, mark job FAILED, stop. +2. **Provision EC2** — as described above. +3. SSH in. All subsequent commands run over SSH. +4. **Install apt packages** (if any): `sudo apt-get install -y `. Only runs on first provision. +5. **Clone repo:** + - Normal PR: `git clone https://:@//.git /opt/app` + - Fork PR: clone from the fork's URL (`payload.pull_request.head.repo.clone_url`, injecting PAT auth). + - `cd /opt/app && git fetch origin pull//head:pp-pr && git checkout pp-pr` +6. **Detect Node version:** Check for `/opt/app/.nvmrc`. If present: `nvm install && nvm use`. Otherwise: `nvm use default`. +7. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`. +8. **Setup commands** (if any): run each in order. Only runs on first provision. +9. Set Preview status to `BUILDING`. +10. **Build commands:** run each in order. +11. **Post-build commands** (if any): run each in order. +12. **Start:** + - Docker Compose: `cd /opt/app && docker compose -f up -d --build --force-recreate` + - Non-compose: run `runCommand` in background via `nohup ... > /opt/app/pp.log 2>&1 &`, capture PID → save to `Preview.pid`. +13. Set Preview status to `RUNNING`. Store `instanceIp`, `port`, `commitSha`, `lastActivityAt = now`. +14. **Comment on PR** — post initial status comment (see PR Commenting section). Store the comment ID in `Preview.giteaCommentId`. + +#### Subsequent push (synchronize / existing RUNNING or FAILED Preview) +1. SSH into existing instance using `Preview.instanceIp` and decrypted `Preview.sshPrivateKey`. +2. **Stop current process:** + - Non-compose: `kill ` (SIGTERM, wait 5s, SIGKILL if still running). + - Docker Compose: `cd /opt/app && docker compose -f down`. +3. **Pull latest:** + ```bash + cd /opt/app && git fetch origin pull//head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD + ``` +4. **Re-detect Node version** (`.nvmrc` may have changed). +5. **Overwrite `.env`** — re-write from current `RepoConfig.envVars`. +6. Set Preview status to `BUILDING`. Append `\n--- Redeploy: ---\n` to `Preview.logs`. +7. **Build commands** — re-run each. +8. **Post-build commands** — re-run if defined. +9. **Start** — same as first provision start step. Update `Preview.pid`, `commitSha`, `lastActivityAt = now`. Set status to `RUNNING`. +10. **Update PR comment** — edit the existing comment in-place using `Preview.giteaCommentId` (see PR Commenting section). + +**If any step fails:** Set status to `FAILED`. Append the error output to `Preview.logs`. Edit the PR comment to show failure with the last few log lines. Stop processing. + +All log output is streamed to `Preview.logs` via WebSocket to the frontend (rj-web WebSocket support). Enforce log size cap: if `Preview.logs` exceeds `AdminSettings.logSizeLimitBytes`, drop oldest lines and prepend `--- logs truncated ---`. + +### Inactivity Detection +- `Preview.lastActivityAt` is updated on each `synchronize` webhook AND each `issue_comment` webhook for the PR. +- A background cron job runs every 30 minutes. Any Preview where `lastActivityAt + inactivityHours < now` and `status = RUNNING` gets a `STOP` job enqueued. +- On stop: terminate the EC2 instance, post a final PR comment, set status to `STOPPED`. + +### Job Queue +Webhook handler returns `200 OK` immediately and enqueues a Job. A background worker processes jobs: +- **Per-PR serialisation:** at most one active Job per `previewId` at a time. +- **New deploy cancels running deploy:** if a `DEPLOY` job is already `RUNNING` for a PR and a new `DEPLOY` arrives, the running job's SSH session is aborted and the new job takes over immediately. +- **On PP startup:** any Jobs left in `RUNNING` state (from a crash) are reset to `PENDING` and requeued. +- Job status (PENDING / RUNNING / DONE / FAILED) is visible on the preview detail page in the UI. + +### Repo Discovery & Webhook Auto-Registration +- When the user saves their Gitea credentials, PP fetches all repos via `GET /api/v1/repos/search?limit=50&token=` (paginate as needed). +- These appear in the Repo Configuration page for the user to enable. +- When the user **enables** a repo: PP calls `POST /api/v1/repos/{owner}/{repo}/hooks` to register a webhook with: + - `type: gitea` + - `config.url`: `https:///webhook/` + - `config.secret`: `WebhookToken.token` + - `config.content_type: json` + - `events: ["pull_request", "issue_comment"]` + - Store the returned hook ID in `RepoConfig.giteaWebhookId`. +- When the user **disables or deletes** a repo config: PP warns the user that the Gitea webhook will be deleted, and on confirmation calls `DELETE /api/v1/repos/{owner}/{repo}/hooks/`. +- **Repos without a RepoConfig** that trigger a webhook: PP checks `NoConfigComment` — if no entry exists for `userId + repoOwner + repoName + prNumber`, PP posts one comment ("No previews configured for this repo...") and creates a `NoConfigComment` record. +- **Repo ownership conflict:** If a user tries to enable a repo that already has a `RepoConfig` owned by another user, return an error: "This repo is already configured by another user." + +### Webhook Secret Rotation +When the user regenerates their webhook secret: +1. Generate a new `WebhookToken.token`. +2. For every `RepoConfig` belonging to this user that has a `giteaWebhookId`, call `PATCH /api/v1/repos/{owner}/{repo}/hooks/` to update the secret. +3. Old secret is invalid immediately. + +### AWS Credential Validation +When the user saves AWS credentials, PP calls `sts:GetCallerIdentity`. Show inline success ("Connected as arn:aws:iam::...") or error ("Invalid credentials"). + +### Gitea URL Validation +When the user saves their Gitea instance URL, PP calls `GET /api/v1/version`. Show inline success (Gitea version) or error. + +### DB Cleanup (background cron, daily) +Delete Preview records where `status IN (STOPPED, FAILED)` and `stoppedAt < now - AdminSettings.previewRetentionDays days`. Also delete associated Job records. + +--- + +## Admin Panel (`/admin`) + +Accessible only to `isAdmin` users. Has its own UI section, separate from the regular user dashboard. + +### User Management +- Create users (username + password). +- Edit username or password of any user. +- Delete users (cannot delete `isFounder` user). +- Promote users to admin (cannot demote `isFounder` user). +- Registration endpoint is always disabled — admin creates all accounts. + +### Global Settings +- **Default EC2 instance type** — shown as the default in all repo configs. +- **Max concurrent EC2 instances per user** — when a user hits this, new deploys are rejected with a PR comment. +- **Webhook rate limit** — max events per user per minute (default 10). +- **Log size cap** — max bytes per Preview.logs before truncation (default 1MB). +- **Preview retention** — days before STOPPED/FAILED records are purged (default 30). +- **Contact email** — shown in the privacy policy footer. + +### Admin Preview Dashboard +- Shows all previews across all users (separate from the user's own dashboard). +- Per preview: user, repo, PR number, status, instance IP, created at. +- Admin can stop any preview. + +--- + +## User Panel + +### Account Settings (`/settings`) +- **Username** — editable. +- **Password** — change password. +- **Gitea Instance URL** — e.g. `https://gitea.example.com`. Validated on save (`GET /api/v1/version`). +- **Gitea Username** — the account that PP will comment as. +- **Gitea PAT** — write-only display (show `••••••••` after save). Must have scopes: `repository` (read), `issue` (write), `admin:repo_hook` (for webhook management). Show a tooltip explaining required scopes with a link to Gitea's token settings page. +- **AWS Access Key ID** — write-only display after save. Validated on save via `sts:GetCallerIdentity`. +- **AWS Secret Access Key** — write-only display after save. +- **AWS Region** — dropdown of all AWS regions. +- **Webhook URL** — read-only. `https:///webhook/`. This is the "Target URL" in Gitea's webhook settings. Copy button included. +- **Webhook Secret** — read-only (separate from the URL). This is the "Secret" field in Gitea's webhook settings. Regenerate button (rotates secret and auto-updates all registered hooks in Gitea with a confirmation prompt). + +Note displayed in settings: _"PP will post PR comments as your Gitea account (@username). Make sure your PAT has 'issue' write permission."_ + +### Repo Configuration (`/repos`) +- List of repos fetched from Gitea (refresh button). Each repo shows a toggle to enable/disable previews. +- Enabling a repo auto-registers the webhook in Gitea. Disabling warns and then deletes it. +- A repo already configured by another PP user shows a lock icon and "Claimed by another user" — the toggle is disabled. +- Expanded per-repo config: + - **Deny list** — tag input of Gitea usernames to skip. + - **EC2 instance type** — searchable select (t2.micro, t2.medium, t3.medium, t3.large, m5.large, c5.large) + custom text input. Show estimated hourly cost next to each option. + - **Inactivity kill timer** — slider 0.5h to 72h with labelled stops. + - **App port** — number input (default 3000). + - **Environment variables** — key/value editor, values masked. Add/remove rows. + - **Docker Compose toggle** — if on: show compose file path input (default `docker-compose.yml`), hide manual command fields. + - **Commands** (shown for both modes unless noted): + - Apt packages — tag input (compose + non-compose) + - Setup commands — ordered list, add/remove/reorder (compose + non-compose, run once on first provision) + - Build commands — ordered list (non-compose only; compose uses `docker compose up --build`) + - Post-build commands — ordered list, optional (non-compose only) + - Run command — single text input (non-compose only) + +### Preview Dashboard (`/` or `/previews`) +- Cards for all of this user's previews across repos. +- Card shows: repo name, PR number + title, status badge, preview URL (clickable when RUNNING), commit SHA, last updated. +- Click into a preview to see: + - Full log viewer (WebSocket — live stream while active, monospace, auto-scroll, ANSI codes stripped). + - Current job status and queue position if PENDING. + - All previous redeploy separators visible inline in the log. + - Status history (PROVISIONING → BUILDING → RUNNING etc. with timestamps). + +--- + +## PR Commenting + +PP comments using the configured user's Gitea PAT (comments appear as their account). There is no bot account. + +**One comment per deploy cycle, edited in-place.** PP stores the comment ID in `Preview.giteaCommentId` and calls `PATCH /api/v1/repos/{owner}/{repo}/issues/comments/{id}` to update it. A new comment is posted fresh at the start of each new push/redeploy cycle (replacing `giteaCommentId` with the new comment's ID). + +**Comment format — Markdown:** + +```markdown +## 🚀 PR Preview — `/` # + +**Status:** 🟡 Provisioning EC2 instance... +**Commit:** `` +**Updated:** + +--- +_Powered by [PR Previews]()_ +``` + +Status line updates as the deploy progresses: +- `🟡 Provisioning EC2 instance...` +- `🟡 Building... (EC2 ready at )` +- `🟢 Live at http://:` +- `🔴 Failed — last log lines:` _(followed by a fenced code block with the last 10 lines of logs)_ +- `⚫ Stopped (inactivity timeout / PR closed / manual stop)` + +On a new redeploy, PP posts a **new** comment (fresh cycle) and updates `giteaCommentId`. + +--- + +## Setup Wizard (first login) + +Shown to a user on first login if any of the required fields (Gitea URL, PAT, AWS credentials) are unset. Skippable at any point. Steps: + +1. **Welcome** — brief explanation of what PP does. +2. **Gitea Connection** — Gitea instance URL + PAT. Validate on "Next". Show required PAT scopes with a link to Gitea settings. +3. **AWS Setup** — Access Key ID, Secret, Region. Show the required IAM policy JSON (copy button). Validate credentials on "Next". +4. **Your Webhook** — show the webhook URL and secret. Explain where to find these in Gitea's UI (per-repo or org-level webhook settings). Note: PP auto-registers per-repo webhooks when you enable a repo — this step is just for reference. +5. **Enable your first repo** — show the repo list, let the user enable one and fill in its config. On save, PP registers the webhook automatically. +6. **Done** — link to the preview dashboard. + +--- + +## UI Design + +- Dark/light mode toggle, persisted to localStorage. +- Animated route transitions and status badge updates. +- Status badges: ⚫ grey (stopped/ignored), 🟡 yellow (provisioning/building), 🟢 green (running), 🔴 red (failed). +- Log viewer: monospace font, auto-scroll to bottom, ANSI codes stripped, redeploy separator lines visually distinct (dimmed/italic). +- Responsive layout — usable on mobile for monitoring previews on the go. +- Toast notifications for save success/failure. +- Confirmation dialogs for: deleting a user, stopping a preview, regenerating webhook secret, disabling a repo (which deletes the Gitea webhook). +- Inline validation feedback on settings fields (Gitea URL, AWS credentials, PAT) that tests the connection on save. +- Contextual tooltips on instance type selector (cost estimates), PAT field (required scopes), inactivity timer (what happens when it fires). + +--- + +## Docker / Deployment + +`./docker-compose.yml` runs PP itself (not preview instances — those are EC2): +- `pp-backend` — Node backend. +- `pp-frontend` — Vite build served statically (or via backend). +- `pp-db` — PostgreSQL. +- Volume for Prisma migrations. + +**Required environment variables:** +| Variable | Purpose | +|---|---| +| `DATABASE_URL` | PostgreSQL connection string | +| `SESSION_SECRET` | Session signing secret | +| `PP_BASE_URL` | Public URL of this PP instance (used in webhook URLs and PR comment links) | +| `ENCRYPTION_KEY` | AES-256 key for encrypting PAT, AWS secrets, SSH private keys at rest | + +--- + +## Security Notes +- Gitea PAT, AWS credentials, and SSH private keys are encrypted at rest with AES-256 using `ENCRYPTION_KEY`. +- Webhook HMAC-SHA256 signature is verified before any payload is processed. Invalid signatures return `401`. +- Webhook rate limiting (default 10 req/min/user) prevents floods. +- EC2 security groups are scoped per preview — created on provision, deleted on termination. +- SSH keys are ephemeral per launch. Private key is stored encrypted in the DB and nulled out on instance termination. +- Users can only see and manage their own previews, repos, and credentials. +- Admins can see all previews in a dedicated admin UI section. +- The repo config ownership model (first-to-save) prevents two users from deploying conflicting previews for the same repo. + +--- + +## Privacy Policy +A generic self-hosted app privacy policy is included in the footer. It covers: +- What data PP stores (user credentials, Gitea PAT encrypted at rest, AWS credentials encrypted at rest, preview logs, PR metadata). +- That credentials are used only to operate the preview service and are never shared with third parties. +- That EC2 instances are launched in the user's own AWS account — PP does not have access to user data on those instances beyond what it deploys. +- Log retention policy (`previewRetentionDays`). +- How to contact the instance administrator (configurable contact email in admin settings). +- No analytics, no tracking, no external data sharing. + +--- + +## Out of Scope for V1 +- GitHub/GitLab support (Gitea only). +- Custom domains for previews (EC2 public IP + port only). +- Preview instance autoscaling. +- Billing or usage tracking. +- Automated tests (manual QA first, tests added in V2). +- Multi-port previews (one port per repo config only). diff --git a/backend/.env b/backend/.env new file mode 100644 index 00000000..f0705289 --- /dev/null +++ b/backend/.env @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://pp:pp_password@localhost:5432/pp +SESSION_SECRET=change_me_to_a_long_random_string_at_least_32_chars +PP_BASE_URL=http://localhost:5000 +ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 +PORT=5000 +LOG_LEVEL=info +NODE_ENV=development diff --git a/backend/dist/index.js b/backend/dist/index.js new file mode 100644 index 00000000..28ae4a05 --- /dev/null +++ b/backend/dist/index.js @@ -0,0 +1,119 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + server: () => server +}); +module.exports = __toCommonJS(index_exports); +var import_env = require("./lib/env"); +var import_rjweb_server = require("rjweb-server"); +var import_runtime_node = require("@rjweb/runtime-node"); +var import_node_fs = require("node:fs"); +var import_path = require("path"); +var import_db = require("./lib/db"); +var import_logger = require("./lib/logger"); +var import_cors = require("./lib/middlewares/cors"); +var import_main = require("./lib/middlewares/main"); +var import_auth = require("./lib/middlewares/auth"); +var import_response = require("./lib/response"); +var import_errors = require("./lib/errors"); +var import_jobWorker = require("./workers/jobWorker"); +var import_cronWorker = require("./workers/cronWorker"); +var import_adminSettings = require("./lib/adminSettings"); +var import_auth2 = require("./routes/auth"); +var import_webhook = require("./routes/webhook"); +var import_user = require("./routes/api/user"); +var import_repos = require("./routes/api/repos"); +var import_previews = require("./routes/api/previews"); +var import_admin = require("./routes/api/admin"); +const uiBuildPath = (0, import_path.join)(__dirname, "../../frontend/dist"); +const uiIndexPath = (0, import_path.join)(uiBuildPath, "index.html"); +const hasUiBuild = (0, import_node_fs.existsSync)(uiBuildPath); +const server = new import_rjweb_server.Server( + import_runtime_node.Runtime, + { + port: import_env.env.PORT, + bind: "0.0.0.0", + version: false, + performance: { lastModified: false, eTag: false }, + logging: { warn: true, debug: false, error: true } + }, + [ + import_cors.corsMiddleware.use({}), + import_main.mainMiddleware.use({}), + import_auth.authResolutionMiddleware.use({}), + import_auth.authEnforcementMiddleware.use({}) + ] +); +server.path( + "/api/auth", + (path) => path.http("POST", "/login", (http) => http.onRequest(import_auth2.loginHandler)).http("POST", "/logout", (http) => http.onRequest(import_auth2.logoutHandler)).http("GET", "/me", (http) => http.onRequest(import_auth2.meHandler)).http("GET", "/setup-status", (http) => http.onRequest(import_auth2.setupStatusHandler)).http("POST", "/first-user", (http) => http.onRequest(import_auth2.firstUserHandler)) +); +server.path( + "/webhook", + (path) => path.http("POST", "/:userId", (http) => http.onRequest(import_webhook.webhookHandler)) +); +server.path( + "/api/user", + (path) => path.http("GET", "/settings", (http) => http.onRequest(import_user.getUserSettings)).http("PATCH", "/username", (http) => http.onRequest(import_user.updateUsername)).http("PATCH", "/password", (http) => http.onRequest(import_user.updatePassword)).http("PUT", "/gitea", (http) => http.onRequest(import_user.updateGitea)).http("PUT", "/aws", (http) => http.onRequest(import_user.updateAws)).http("GET", "/webhook-secret", (http) => http.onRequest(import_user.getWebhookSecret)).http("POST", "/webhook-secret/regenerate", (http) => http.onRequest(import_user.regenerateWebhookSecret)) +); +server.path( + "/api/repos", + (path) => path.http("GET", "/", (http) => http.onRequest(import_repos.listRepos)).http("POST", "/config", (http) => http.onRequest(import_repos.saveRepoConfig)).http("POST", "/toggle", (http) => http.onRequest(import_repos.toggleRepoEnabled)).http("GET", "/:owner/:repo/config", (http) => http.onRequest(import_repos.getRepoConfig)) +); +server.path( + "/api/previews", + (path) => path.http("GET", "/", (http) => http.onRequest(import_previews.listPreviews)).http("GET", "/:id", (http) => http.onRequest(import_previews.getPreview)).http("POST", "/:id/stop", (http) => http.onRequest(import_previews.stopPreviewRoute)).ws( + "/:id/logs", + (ws) => ws.onOpen(import_previews.previewLogsWs).onMessage(async () => { + }).onClose(async () => { + }) + ) +); +server.path( + "/api/admin", + (path) => path.http("GET", "/users", (http) => http.onRequest(import_admin.listUsers)).http("POST", "/users", (http) => http.onRequest(import_admin.createUser)).http("PATCH", "/users/:id", (http) => http.onRequest(import_admin.updateUser)).http("DELETE", "/users/:id", (http) => http.onRequest(import_admin.deleteUser)).http("GET", "/settings", (http) => http.onRequest(import_admin.getSettings)).http("PUT", "/settings", (http) => http.onRequest(import_admin.updateSettings)).http("GET", "/previews", (http) => http.onRequest(import_admin.adminListPreviews)).http("POST", "/previews/:id/stop", (http) => http.onRequest(import_admin.adminStopPreview)) +); +if (hasUiBuild) { + server.path("/", (path) => path.static(uiBuildPath)); +} +server.notFound(async (ctr) => { + const STATIC_EXT = /\.(js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|json|txt|xml|webp|avif)(\?.*)?$/i; + if (!ctr.url.path.startsWith("/api") && !STATIC_EXT.test(ctr.url.path) && (0, import_node_fs.existsSync)(uiIndexPath)) { + return ctr.status(200).printFile(uiIndexPath, { addTypes: true }); + } + return (0, import_response.makeResponse)({ ctr, content: { code: import_errors.ERROR_MESSAGES.NOT_FOUND.code, message: import_errors.ERROR_MESSAGES.NOT_FOUND.message } }); +}); +server.error("httpRequest", async (ctr, error) => { + import_logger.logger.error(error, "Unhandled HTTP request error"); + return (0, import_response.makeResponse)({ ctr, content: { code: import_errors.ERROR_MESSAGES.INTERNAL_SERVER_ERROR.code } }); +}); +server.start().then(async (port) => { + await import_db.prisma.$connect(); + import_logger.logger.info({ port }, "PP backend running"); + await (0, import_adminSettings.getAdminSettings)(); + (0, import_jobWorker.startJobWorker)(); + (0, import_cronWorker.startCronWorkers)(); + import_logger.logger.info("All workers started"); +}).catch((err) => import_logger.logger.error(err, "Server failed to start")); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + server +}); +//# sourceMappingURL=index.js.map diff --git a/backend/dist/index.js.map b/backend/dist/index.js.map new file mode 100644 index 00000000..715ec66f --- /dev/null +++ b/backend/dist/index.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../src/index.ts"], + "sourcesContent": ["import { env } from \"./lib/env\";\nimport { Server, Cookie } from \"rjweb-server\";\nimport { Runtime } from \"@rjweb/runtime-node\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"path\";\nimport { prisma } from \"./lib/db\";\nimport { logger } from \"./lib/logger\";\nimport { corsMiddleware } from \"./lib/middlewares/cors\";\nimport { mainMiddleware } from \"./lib/middlewares/main\";\nimport { authResolutionMiddleware, authEnforcementMiddleware } from \"./lib/middlewares/auth\";\nimport { makeResponse } from \"./lib/response\";\nimport { ERROR_MESSAGES } from \"./lib/errors\";\nimport { startJobWorker } from \"./workers/jobWorker\";\nimport { startCronWorkers } from \"./workers/cronWorker\";\nimport { getAdminSettings } from \"./lib/adminSettings\";\n\nimport { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from \"./routes/auth\";\nimport { webhookHandler } from \"./routes/webhook\";\nimport { getUserSettings, updateUsername, updatePassword, updateGitea, updateAws, getWebhookSecret, regenerateWebhookSecret } from \"./routes/api/user\";\nimport { listRepos, saveRepoConfig, toggleRepoEnabled, getRepoConfig } from \"./routes/api/repos\";\nimport { listPreviews, getPreview, stopPreviewRoute, previewLogsWs } from \"./routes/api/previews\";\nimport { listUsers, createUser, updateUser, deleteUser, getSettings, updateSettings, adminListPreviews, adminStopPreview } from \"./routes/api/admin\";\n\nconst uiBuildPath = join(__dirname, \"../../frontend/dist\");\nconst uiIndexPath = join(uiBuildPath, \"index.html\");\nconst hasUiBuild = existsSync(uiBuildPath);\n\nexport const server = new Server(\n Runtime,\n {\n port: env.PORT,\n bind: \"0.0.0.0\",\n version: false,\n performance: { lastModified: false, eTag: false },\n logging: { warn: true, debug: false, error: true },\n },\n [\n corsMiddleware.use({}),\n mainMiddleware.use({}),\n authResolutionMiddleware.use({}),\n authEnforcementMiddleware.use({}),\n ],\n);\n\n// Auth\nserver.path(\"/api/auth\", (path) => path\n .http(\"POST\", \"/login\", (http) => http.onRequest(loginHandler))\n .http(\"POST\", \"/logout\", (http) => http.onRequest(logoutHandler))\n .http(\"GET\", \"/me\", (http) => http.onRequest(meHandler))\n .http(\"GET\", \"/setup-status\", (http) => http.onRequest(setupStatusHandler))\n .http(\"POST\", \"/first-user\", (http) => http.onRequest(firstUserHandler))\n);\n\n// Webhook\nserver.path(\"/webhook\", (path) => path\n .http(\"POST\", \"/:userId\", (http) => http.onRequest(webhookHandler))\n);\n\n// User settings\nserver.path(\"/api/user\", (path) => path\n .http(\"GET\", \"/settings\", (http) => http.onRequest(getUserSettings))\n .http(\"PATCH\", \"/username\", (http) => http.onRequest(updateUsername))\n .http(\"PATCH\", \"/password\", (http) => http.onRequest(updatePassword))\n .http(\"PUT\", \"/gitea\", (http) => http.onRequest(updateGitea))\n .http(\"PUT\", \"/aws\", (http) => http.onRequest(updateAws))\n .http(\"GET\", \"/webhook-secret\", (http) => http.onRequest(getWebhookSecret))\n .http(\"POST\", \"/webhook-secret/regenerate\", (http) => http.onRequest(regenerateWebhookSecret))\n);\n\n// Repos\nserver.path(\"/api/repos\", (path) => path\n .http(\"GET\", \"/\", (http) => http.onRequest(listRepos))\n .http(\"POST\", \"/config\", (http) => http.onRequest(saveRepoConfig))\n .http(\"POST\", \"/toggle\", (http) => http.onRequest(toggleRepoEnabled))\n .http(\"GET\", \"/:owner/:repo/config\", (http) => http.onRequest(getRepoConfig))\n);\n\n// Previews\nserver.path(\"/api/previews\", (path) => path\n .http(\"GET\", \"/\", (http) => http.onRequest(listPreviews))\n .http(\"GET\", \"/:id\", (http) => http.onRequest(getPreview))\n .http(\"POST\", \"/:id/stop\", (http) => http.onRequest(stopPreviewRoute))\n .ws(\"/:id/logs\", (ws) => ws\n .onOpen(previewLogsWs)\n .onMessage(async () => {})\n .onClose(async () => {})\n )\n);\n\n// Admin\nserver.path(\"/api/admin\", (path) => path\n .http(\"GET\", \"/users\", (http) => http.onRequest(listUsers))\n .http(\"POST\", \"/users\", (http) => http.onRequest(createUser))\n .http(\"PATCH\", \"/users/:id\", (http) => http.onRequest(updateUser))\n .http(\"DELETE\", \"/users/:id\", (http) => http.onRequest(deleteUser))\n .http(\"GET\", \"/settings\", (http) => http.onRequest(getSettings))\n .http(\"PUT\", \"/settings\", (http) => http.onRequest(updateSettings))\n .http(\"GET\", \"/previews\", (http) => http.onRequest(adminListPreviews))\n .http(\"POST\", \"/previews/:id/stop\", (http) => http.onRequest(adminStopPreview))\n);\n\n// Static UI\nif (hasUiBuild) {\n server.path(\"/\", (path) => path.static(uiBuildPath));\n}\n\nserver.notFound(async (ctr) => {\n const STATIC_EXT = /\\.(js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|json|txt|xml|webp|avif)(\\?.*)?$/i;\n if (!ctr.url.path.startsWith(\"/api\") && !STATIC_EXT.test(ctr.url.path) && existsSync(uiIndexPath)) {\n return ctr.status(200).printFile(uiIndexPath, { addTypes: true });\n }\n return makeResponse({ ctr, content: { code: ERROR_MESSAGES.NOT_FOUND.code, message: ERROR_MESSAGES.NOT_FOUND.message } });\n});\n\nserver.error(\"httpRequest\", async (ctr, error) => {\n logger.error(error, \"Unhandled HTTP request error\");\n return makeResponse({ ctr, content: { code: ERROR_MESSAGES.INTERNAL_SERVER_ERROR.code } });\n});\n\nserver\n .start()\n .then(async (port) => {\n await prisma.$connect();\n logger.info({ port }, \"PP backend running\");\n await getAdminSettings();\n startJobWorker();\n startCronWorkers();\n logger.info(\"All workers started\");\n })\n .catch((err) => logger.error(err, \"Server failed to start\"));\n"], + "mappings": "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", + "names": ["import_auth"] +} diff --git a/backend/dist/lib/adminSettings.js b/backend/dist/lib/adminSettings.js new file mode 100644 index 00000000..f5faea37 --- /dev/null +++ b/backend/dist/lib/adminSettings.js @@ -0,0 +1,36 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var adminSettings_exports = {}; +__export(adminSettings_exports, { + getAdminSettings: () => getAdminSettings +}); +module.exports = __toCommonJS(adminSettings_exports); +var import_db = require("./db"); +async function getAdminSettings() { + let settings = await import_db.prisma.adminSettings.findUnique({ where: { id: 1 } }); + if (!settings) { + settings = await import_db.prisma.adminSettings.create({ data: { id: 1 } }); + } + return settings; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + getAdminSettings +}); +//# sourceMappingURL=adminSettings.js.map diff --git a/backend/dist/lib/adminSettings.js.map b/backend/dist/lib/adminSettings.js.map new file mode 100644 index 00000000..0acef296 --- /dev/null +++ b/backend/dist/lib/adminSettings.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/lib/adminSettings.ts"], + "sourcesContent": ["import { prisma } from \"./db\";\n\nexport async function getAdminSettings() {\n let settings = await prisma.adminSettings.findUnique({ where: { id: 1 } });\n if (!settings) {\n settings = await prisma.adminSettings.create({ data: { id: 1 } });\n }\n return settings;\n}\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAAuB;AAEvB,eAAsB,mBAAmB;AACvC,MAAI,WAAW,MAAM,iBAAO,cAAc,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,CAAC;AACzE,MAAI,CAAC,UAAU;AACb,eAAW,MAAM,iBAAO,cAAc,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;AAAA,EAClE;AACA,SAAO;AACT;", + "names": [] +} diff --git a/backend/dist/lib/db.js b/backend/dist/lib/db.js new file mode 100644 index 00000000..f896300c --- /dev/null +++ b/backend/dist/lib/db.js @@ -0,0 +1,33 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var db_exports = {}; +__export(db_exports, { + prisma: () => prisma +}); +module.exports = __toCommonJS(db_exports); +var import_client = require("@prisma/client"); +const prisma = new import_client.PrismaClient({ + log: ["error", "warn"], + errorFormat: "pretty" +}); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + prisma +}); +//# sourceMappingURL=db.js.map diff --git a/backend/dist/lib/db.js.map b/backend/dist/lib/db.js.map new file mode 100644 index 00000000..3d3514f0 --- /dev/null +++ b/backend/dist/lib/db.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/lib/db.ts"], + "sourcesContent": ["import { PrismaClient } from \"@prisma/client\";\n\nexport const prisma = new PrismaClient({\n log: [\"error\", \"warn\"],\n errorFormat: \"pretty\",\n});\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAA6B;AAEtB,MAAM,SAAS,IAAI,2BAAa;AAAA,EACrC,KAAK,CAAC,SAAS,MAAM;AAAA,EACrB,aAAa;AACf,CAAC;", + "names": [] +} diff --git a/backend/dist/lib/encryption.js b/backend/dist/lib/encryption.js new file mode 100644 index 00000000..204001fc --- /dev/null +++ b/backend/dist/lib/encryption.js @@ -0,0 +1,50 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var encryption_exports = {}; +__export(encryption_exports, { + decrypt: () => decrypt, + encrypt: () => encrypt +}); +module.exports = __toCommonJS(encryption_exports); +var import_crypto = require("crypto"); +var import_env = require("./env"); +const ALGORITHM = "aes-256-gcm"; +const KEY = Buffer.from(import_env.env.ENCRYPTION_KEY, "hex"); +function encrypt(plaintext) { + const iv = (0, import_crypto.randomBytes)(12); + const cipher = (0, import_crypto.createCipheriv)(ALGORITHM, KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString("base64"); +} +function decrypt(ciphertext) { + const buf = Buffer.from(ciphertext, "base64"); + const iv = buf.slice(0, 12); + const authTag = buf.slice(12, 28); + const encrypted = buf.slice(28); + const decipher = (0, import_crypto.createDecipheriv)(ALGORITHM, KEY, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + decrypt, + encrypt +}); +//# sourceMappingURL=encryption.js.map diff --git a/backend/dist/lib/encryption.js.map b/backend/dist/lib/encryption.js.map new file mode 100644 index 00000000..cf82a7c1 --- /dev/null +++ b/backend/dist/lib/encryption.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/lib/encryption.ts"], + "sourcesContent": ["import { createCipheriv, createDecipheriv, randomBytes } from \"crypto\";\nimport { env } from \"./env\";\n\nconst ALGORITHM = \"aes-256-gcm\";\nconst KEY = Buffer.from(env.ENCRYPTION_KEY, \"hex\");\n\nexport function encrypt(plaintext: string): string {\n const iv = randomBytes(12);\n const cipher = createCipheriv(ALGORITHM, KEY, iv);\n const encrypted = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n const authTag = cipher.getAuthTag();\n return Buffer.concat([iv, authTag, encrypted]).toString(\"base64\");\n}\n\nexport function decrypt(ciphertext: string): string {\n const buf = Buffer.from(ciphertext, \"base64\");\n const iv = buf.slice(0, 12);\n const authTag = buf.slice(12, 28);\n const encrypted = buf.slice(28);\n const decipher = createDecipheriv(ALGORITHM, KEY, iv);\n decipher.setAuthTag(authTag);\n return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(\"utf8\");\n}\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAA8D;AAC9D,iBAAoB;AAEpB,MAAM,YAAY;AAClB,MAAM,MAAM,OAAO,KAAK,eAAI,gBAAgB,KAAK;AAE1C,SAAS,QAAQ,WAA2B;AACjD,QAAM,SAAK,2BAAY,EAAE;AACzB,QAAM,aAAS,8BAAe,WAAW,KAAK,EAAE;AAChD,QAAM,YAAY,OAAO,OAAO,CAAC,OAAO,OAAO,WAAW,MAAM,GAAG,OAAO,MAAM,CAAC,CAAC;AAClF,QAAM,UAAU,OAAO,WAAW;AAClC,SAAO,OAAO,OAAO,CAAC,IAAI,SAAS,SAAS,CAAC,EAAE,SAAS,QAAQ;AAClE;AAEO,SAAS,QAAQ,YAA4B;AAClD,QAAM,MAAM,OAAO,KAAK,YAAY,QAAQ;AAC5C,QAAM,KAAK,IAAI,MAAM,GAAG,EAAE;AAC1B,QAAM,UAAU,IAAI,MAAM,IAAI,EAAE;AAChC,QAAM,YAAY,IAAI,MAAM,EAAE;AAC9B,QAAM,eAAW,gCAAiB,WAAW,KAAK,EAAE;AACpD,WAAS,WAAW,OAAO;AAC3B,SAAO,OAAO,OAAO,CAAC,SAAS,OAAO,SAAS,GAAG,SAAS,MAAM,CAAC,CAAC,EAAE,SAAS,MAAM;AACtF;", + "names": [] +} diff --git a/backend/dist/lib/env.js b/backend/dist/lib/env.js new file mode 100644 index 00000000..17e7eb1e --- /dev/null +++ b/backend/dist/lib/env.js @@ -0,0 +1,58 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var env_exports = {}; +__export(env_exports, { + env: () => env +}); +module.exports = __toCommonJS(env_exports); +var import_dotenv = __toESM(require("dotenv")); +var import_path = require("path"); +var import_zod = require("zod"); +import_dotenv.default.config({ path: (0, import_path.join)(__dirname, "../../.env") }); +const schema = import_zod.z.object({ + NODE_ENV: import_zod.z.enum(["development", "production", "test"]).default("development"), + DATABASE_URL: import_zod.z.string().min(1), + PORT: import_zod.z.coerce.number().int().positive().default(5e3), + SESSION_SECRET: import_zod.z.string().min(16), + PP_BASE_URL: import_zod.z.string().min(1), + ENCRYPTION_KEY: import_zod.z.string().length(64, "ENCRYPTION_KEY must be 64 hex chars (32 bytes AES-256)"), + LOG_LEVEL: import_zod.z.string().default("info") +}); +const result = schema.safeParse(process.env); +if (!result.success) { + const formatted = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n"); + throw new Error(`Invalid environment variables: +${formatted}`); +} +const env = result.data; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + env +}); +//# sourceMappingURL=env.js.map diff --git a/backend/dist/lib/env.js.map b/backend/dist/lib/env.js.map new file mode 100644 index 00000000..337420fa --- /dev/null +++ b/backend/dist/lib/env.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/lib/env.ts"], + "sourcesContent": ["import dotenv from \"dotenv\";\nimport { join } from \"path\";\nimport { z } from \"zod\";\n\ndotenv.config({ path: join(__dirname, \"../../.env\") });\n\nconst schema = z.object({\n NODE_ENV: z.enum([\"development\", \"production\", \"test\"]).default(\"development\"),\n DATABASE_URL: z.string().min(1),\n PORT: z.coerce.number().int().positive().default(5000),\n SESSION_SECRET: z.string().min(16),\n PP_BASE_URL: z.string().min(1),\n ENCRYPTION_KEY: z.string().length(64, \"ENCRYPTION_KEY must be 64 hex chars (32 bytes AES-256)\"),\n LOG_LEVEL: z.string().default(\"info\"),\n});\n\nconst result = schema.safeParse(process.env);\n\nif (!result.success) {\n const formatted = result.error.issues\n .map((i) => ` ${i.path.join(\".\")}: ${i.message}`)\n .join(\"\\n\");\n throw new Error(`Invalid environment variables:\\n${formatted}`);\n}\n\nexport const env = result.data;\n"], + "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAAmB;AACnB,kBAAqB;AACrB,iBAAkB;AAElB,cAAAA,QAAO,OAAO,EAAE,UAAM,kBAAK,WAAW,YAAY,EAAE,CAAC;AAErD,MAAM,SAAS,aAAE,OAAO;AAAA,EACtB,UAAU,aAAE,KAAK,CAAC,eAAe,cAAc,MAAM,CAAC,EAAE,QAAQ,aAAa;AAAA,EAC7E,cAAc,aAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC9B,MAAM,aAAE,OAAO,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,GAAI;AAAA,EACrD,gBAAgB,aAAE,OAAO,EAAE,IAAI,EAAE;AAAA,EACjC,aAAa,aAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC7B,gBAAgB,aAAE,OAAO,EAAE,OAAO,IAAI,wDAAwD;AAAA,EAC9F,WAAW,aAAE,OAAO,EAAE,QAAQ,MAAM;AACtC,CAAC;AAED,MAAM,SAAS,OAAO,UAAU,QAAQ,GAAG;AAE3C,IAAI,CAAC,OAAO,SAAS;AACnB,QAAM,YAAY,OAAO,MAAM,OAC5B,IAAI,CAAC,MAAM,KAAK,EAAE,KAAK,KAAK,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,EAChD,KAAK,IAAI;AACZ,QAAM,IAAI,MAAM;AAAA,EAAmC,SAAS,EAAE;AAChE;AAEO,MAAM,MAAM,OAAO;", + "names": ["dotenv"] +} diff --git a/backend/dist/lib/errors.js b/backend/dist/lib/errors.js new file mode 100644 index 00000000..a586d80c --- /dev/null +++ b/backend/dist/lib/errors.js @@ -0,0 +1,37 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var errors_exports = {}; +__export(errors_exports, { + ERROR_MESSAGES: () => ERROR_MESSAGES +}); +module.exports = __toCommonJS(errors_exports); +const ERROR_MESSAGES = { + UNAUTHORIZED: { code: 401, message: "You are not authorized to access this resource." }, + FORBIDDEN: { code: 403, message: "You do not have permission to access this resource." }, + NOT_FOUND: { code: 404, message: "The requested resource was not found." }, + INTERNAL_SERVER_ERROR: { code: 500, message: "An unexpected server error has occurred." }, + BAD_REQUEST: { code: 400, message: "The request was invalid or malformed." }, + CONFLICT: { code: 409, message: "The request conflicts with the current state of the resource." }, + TOO_MANY_REQUESTS: { code: 429, message: "Too many requests. Please try again later." } +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + ERROR_MESSAGES +}); +//# sourceMappingURL=errors.js.map diff --git a/backend/dist/lib/errors.js.map b/backend/dist/lib/errors.js.map new file mode 100644 index 00000000..0ce1085e --- /dev/null +++ b/backend/dist/lib/errors.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/lib/errors.ts"], + "sourcesContent": ["export const ERROR_MESSAGES = {\n UNAUTHORIZED: { code: 401, message: \"You are not authorized to access this resource.\" },\n FORBIDDEN: { code: 403, message: \"You do not have permission to access this resource.\" },\n NOT_FOUND: { code: 404, message: \"The requested resource was not found.\" },\n INTERNAL_SERVER_ERROR: { code: 500, message: \"An unexpected server error has occurred.\" },\n BAD_REQUEST: { code: 400, message: \"The request was invalid or malformed.\" },\n CONFLICT: { code: 409, message: \"The request conflicts with the current state of the resource.\" },\n TOO_MANY_REQUESTS: { code: 429, message: \"Too many requests. Please try again later.\" },\n} as const;\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAO,MAAM,iBAAiB;AAAA,EAC5B,cAAc,EAAE,MAAM,KAAK,SAAS,kDAAkD;AAAA,EACtF,WAAW,EAAE,MAAM,KAAK,SAAS,sDAAsD;AAAA,EACvF,WAAW,EAAE,MAAM,KAAK,SAAS,wCAAwC;AAAA,EACzE,uBAAuB,EAAE,MAAM,KAAK,SAAS,2CAA2C;AAAA,EACxF,aAAa,EAAE,MAAM,KAAK,SAAS,wCAAwC;AAAA,EAC3E,UAAU,EAAE,MAAM,KAAK,SAAS,gEAAgE;AAAA,EAChG,mBAAmB,EAAE,MAAM,KAAK,SAAS,6CAA6C;AACxF;", + "names": [] +} diff --git a/backend/dist/lib/logger.js b/backend/dist/lib/logger.js new file mode 100644 index 00000000..105abfa7 --- /dev/null +++ b/backend/dist/lib/logger.js @@ -0,0 +1,49 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var logger_exports = {}; +__export(logger_exports, { + createLogger: () => createLogger, + logger: () => logger +}); +module.exports = __toCommonJS(logger_exports); +var import_env = require("./env"); +var import_pino = __toESM(require("pino")); +const logger = (0, import_pino.default)({ + level: import_env.env.LOG_LEVEL, + transport: import_env.env.NODE_ENV !== "production" ? { target: "pino-pretty", options: { colorize: true } } : void 0 +}); +function createLogger(component) { + return logger.child({ component }); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + createLogger, + logger +}); +//# sourceMappingURL=logger.js.map diff --git a/backend/dist/lib/logger.js.map b/backend/dist/lib/logger.js.map new file mode 100644 index 00000000..8221eee6 --- /dev/null +++ b/backend/dist/lib/logger.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/lib/logger.ts"], + "sourcesContent": ["import { env } from \"./env\";\nimport pino from \"pino\";\n\nexport const logger = pino({\n level: env.LOG_LEVEL,\n transport:\n env.NODE_ENV !== \"production\"\n ? { target: \"pino-pretty\", options: { colorize: true } }\n : undefined,\n});\n\nexport function createLogger(component: string) {\n return logger.child({ component });\n}\n"], + "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,iBAAoB;AACpB,kBAAiB;AAEV,MAAM,aAAS,YAAAA,SAAK;AAAA,EACzB,OAAO,eAAI;AAAA,EACX,WACE,eAAI,aAAa,eACb,EAAE,QAAQ,eAAe,SAAS,EAAE,UAAU,KAAK,EAAE,IACrD;AACR,CAAC;AAEM,SAAS,aAAa,WAAmB;AAC9C,SAAO,OAAO,MAAM,EAAE,UAAU,CAAC;AACnC;", + "names": ["pino"] +} diff --git a/backend/dist/lib/middlewares/auth.js b/backend/dist/lib/middlewares/auth.js new file mode 100644 index 00000000..12f1869c --- /dev/null +++ b/backend/dist/lib/middlewares/auth.js @@ -0,0 +1,88 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var auth_exports = {}; +__export(auth_exports, { + COOKIE_NAME_EXPORT: () => COOKIE_NAME_EXPORT, + authEnforcementMiddleware: () => authEnforcementMiddleware, + authResolutionMiddleware: () => authResolutionMiddleware +}); +module.exports = __toCommonJS(auth_exports); +var import_rjweb_server = require("rjweb-server"); +var import_db = require("../db"); +var import_logger = require("../logger"); +var import_errors = require("../errors"); +const log = (0, import_logger.createLogger)("AUTH"); +const COOKIE_NAME = "pp_session"; +const authResolutionMiddleware = new import_rjweb_server.Middleware( + "Auth Resolution Middleware", + "1.0.0" +).load(() => { + log.info("Auth resolution middleware loaded"); +}).httpRequest(async (_config, _server, context, ctr) => { + const cookieToken = ctr.cookies.get(COOKIE_NAME); + const tokenProvided = Boolean(cookieToken); + const data = context.data(authResolutionMiddleware); + if (!cookieToken) { + data.auth = { success: false, message: "No session", tokenProvided: false }; + return; + } + const session = await import_db.prisma.session.findFirst({ + where: { hash: cookieToken }, + include: { user: true } + }); + if (!session) { + data.auth = { success: false, message: "Invalid session", tokenProvided }; + return; + } + data.auth = { success: true, user: session.user, sessionId: session.id }; +}).httpRequestContext( + (_config, Original) => class extends Original { + getAuth() { + const data = this.context.data(authResolutionMiddleware); + if (!data.auth) { + return { success: false, message: "Auth not resolved", tokenProvided: false }; + } + return data.auth; + } + } +).export(); +const authEnforcementMiddleware = new import_rjweb_server.Middleware( + "Auth Enforcement Middleware", + "1.0.0" +).httpRequest(async (_config, _server, context, ctr, end) => { + const data = context.data(authResolutionMiddleware); + const auth = data.auth; + if (!auth || auth.success || !auth.tokenProvided) { + return; + } + return end( + ctr.status(import_errors.ERROR_MESSAGES.UNAUTHORIZED.code).print({ + status: "FAILED", + message: auth.message + }) + ); +}).export(); +const COOKIE_NAME_EXPORT = COOKIE_NAME; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + COOKIE_NAME_EXPORT, + authEnforcementMiddleware, + authResolutionMiddleware +}); +//# sourceMappingURL=auth.js.map diff --git a/backend/dist/lib/middlewares/auth.js.map b/backend/dist/lib/middlewares/auth.js.map new file mode 100644 index 00000000..35f61100 --- /dev/null +++ b/backend/dist/lib/middlewares/auth.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../../src/lib/middlewares/auth.ts"], + "sourcesContent": ["import { Middleware } from \"rjweb-server\";\nimport { type User } from \"@prisma/client\";\nimport { prisma } from \"../db\";\nimport { createLogger } from \"../logger\";\nimport { ERROR_MESSAGES } from \"../errors\";\n\nconst log = createLogger(\"AUTH\");\n\nconst COOKIE_NAME = \"pp_session\";\n\nexport type AuthState =\n | { success: true; user: User; sessionId: number }\n | { success: false; message: string; tokenProvided: boolean };\n\ntype AuthContext = {\n auth?: AuthState;\n};\n\nexport const authResolutionMiddleware = new Middleware<{}, AuthContext>(\n \"Auth Resolution Middleware\",\n \"1.0.0\",\n)\n .load(() => {\n log.info(\"Auth resolution middleware loaded\");\n })\n .httpRequest(async (_config, _server, context, ctr) => {\n const cookieToken = ctr.cookies.get(COOKIE_NAME);\n const tokenProvided = Boolean(cookieToken);\n const data = context.data(authResolutionMiddleware);\n\n if (!cookieToken) {\n data.auth = { success: false, message: \"No session\", tokenProvided: false };\n return;\n }\n\n const session = await prisma.session.findFirst({\n where: { hash: cookieToken },\n include: { user: true },\n });\n\n if (!session) {\n data.auth = { success: false, message: \"Invalid session\", tokenProvided };\n return;\n }\n\n data.auth = { success: true, user: session.user, sessionId: session.id };\n })\n .httpRequestContext(\n (_config, Original) =>\n class extends Original {\n getAuth(): AuthState {\n const data = this.context.data(authResolutionMiddleware);\n if (!data.auth) {\n return { success: false, message: \"Auth not resolved\", tokenProvided: false };\n }\n return data.auth;\n }\n },\n )\n .export();\n\nexport const authEnforcementMiddleware = new Middleware<{}, {}>(\n \"Auth Enforcement Middleware\",\n \"1.0.0\",\n)\n .httpRequest(async (_config, _server, context, ctr, end) => {\n const data = context.data(authResolutionMiddleware) as AuthContext;\n const auth = data.auth;\n\n if (!auth || auth.success || !auth.tokenProvided) {\n return;\n }\n\n return end(\n ctr.status(ERROR_MESSAGES.UNAUTHORIZED.code).print({\n status: \"FAILED\",\n message: auth.message,\n }),\n );\n })\n .export();\n\nexport const COOKIE_NAME_EXPORT = COOKIE_NAME;\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAA2B;AAE3B,gBAAuB;AACvB,oBAA6B;AAC7B,oBAA+B;AAE/B,MAAM,UAAM,4BAAa,MAAM;AAE/B,MAAM,cAAc;AAUb,MAAM,2BAA2B,IAAI;AAAA,EAC1C;AAAA,EACA;AACF,EACG,KAAK,MAAM;AACV,MAAI,KAAK,mCAAmC;AAC9C,CAAC,EACA,YAAY,OAAO,SAAS,SAAS,SAAS,QAAQ;AACrD,QAAM,cAAc,IAAI,QAAQ,IAAI,WAAW;AAC/C,QAAM,gBAAgB,QAAQ,WAAW;AACzC,QAAM,OAAO,QAAQ,KAAK,wBAAwB;AAElD,MAAI,CAAC,aAAa;AAChB,SAAK,OAAO,EAAE,SAAS,OAAO,SAAS,cAAc,eAAe,MAAM;AAC1E;AAAA,EACF;AAEA,QAAM,UAAU,MAAM,iBAAO,QAAQ,UAAU;AAAA,IAC7C,OAAO,EAAE,MAAM,YAAY;AAAA,IAC3B,SAAS,EAAE,MAAM,KAAK;AAAA,EACxB,CAAC;AAED,MAAI,CAAC,SAAS;AACZ,SAAK,OAAO,EAAE,SAAS,OAAO,SAAS,mBAAmB,cAAc;AACxE;AAAA,EACF;AAEA,OAAK,OAAO,EAAE,SAAS,MAAM,MAAM,QAAQ,MAAM,WAAW,QAAQ,GAAG;AACzE,CAAC,EACA;AAAA,EACC,CAAC,SAAS,aACR,cAAc,SAAS;AAAA,IACrB,UAAqB;AACnB,YAAM,OAAO,KAAK,QAAQ,KAAK,wBAAwB;AACvD,UAAI,CAAC,KAAK,MAAM;AACd,eAAO,EAAE,SAAS,OAAO,SAAS,qBAAqB,eAAe,MAAM;AAAA,MAC9E;AACA,aAAO,KAAK;AAAA,IACd;AAAA,EACF;AACJ,EACC,OAAO;AAEH,MAAM,4BAA4B,IAAI;AAAA,EAC3C;AAAA,EACA;AACF,EACG,YAAY,OAAO,SAAS,SAAS,SAAS,KAAK,QAAQ;AAC1D,QAAM,OAAO,QAAQ,KAAK,wBAAwB;AAClD,QAAM,OAAO,KAAK;AAElB,MAAI,CAAC,QAAQ,KAAK,WAAW,CAAC,KAAK,eAAe;AAChD;AAAA,EACF;AAEA,SAAO;AAAA,IACL,IAAI,OAAO,6BAAe,aAAa,IAAI,EAAE,MAAM;AAAA,MACjD,QAAQ;AAAA,MACR,SAAS,KAAK;AAAA,IAChB,CAAC;AAAA,EACH;AACF,CAAC,EACA,OAAO;AAEH,MAAM,qBAAqB;", + "names": [] +} diff --git a/backend/dist/lib/middlewares/cors.js b/backend/dist/lib/middlewares/cors.js new file mode 100644 index 00000000..e3091703 --- /dev/null +++ b/backend/dist/lib/middlewares/cors.js @@ -0,0 +1,40 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var cors_exports = {}; +__export(cors_exports, { + corsMiddleware: () => corsMiddleware +}); +module.exports = __toCommonJS(cors_exports); +var import_rjweb_server = require("rjweb-server"); +var import_env = require("../env"); +const ALLOWED_ORIGINS = /* @__PURE__ */ new Set([import_env.env.PP_BASE_URL]); +const corsMiddleware = new import_rjweb_server.Middleware("CORS Middleware", "1.0.0").httpRequest(async (_config, _server, _context, ctr) => { + const origin = ctr.headers.get("origin") || ""; + if (ALLOWED_ORIGINS.has(origin) || import_env.env.NODE_ENV === "development") { + ctr.headers.set("Access-Control-Allow-Origin", origin || "*"); + ctr.headers.set("Access-Control-Allow-Credentials", "true"); + ctr.headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); + ctr.headers.set("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Requested-With"); + } +}).export(); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + corsMiddleware +}); +//# sourceMappingURL=cors.js.map diff --git a/backend/dist/lib/middlewares/cors.js.map b/backend/dist/lib/middlewares/cors.js.map new file mode 100644 index 00000000..a817b741 --- /dev/null +++ b/backend/dist/lib/middlewares/cors.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../../src/lib/middlewares/cors.ts"], + "sourcesContent": ["import { Middleware } from \"rjweb-server\";\nimport { env } from \"../env\";\n\nconst ALLOWED_ORIGINS = new Set([env.PP_BASE_URL]);\n\nexport const corsMiddleware = new Middleware<{}, {}>(\"CORS Middleware\", \"1.0.0\")\n .httpRequest(async (_config, _server, _context, ctr) => {\n const origin = ctr.headers.get(\"origin\") || \"\";\n if (ALLOWED_ORIGINS.has(origin) || env.NODE_ENV === \"development\") {\n ctr.headers.set(\"Access-Control-Allow-Origin\", origin || \"*\");\n ctr.headers.set(\"Access-Control-Allow-Credentials\", \"true\");\n ctr.headers.set(\"Access-Control-Allow-Methods\", \"GET,POST,PUT,PATCH,DELETE,OPTIONS\");\n ctr.headers.set(\"Access-Control-Allow-Headers\", \"Content-Type,Authorization,X-Requested-With\");\n }\n })\n .export();\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAA2B;AAC3B,iBAAoB;AAEpB,MAAM,kBAAkB,oBAAI,IAAI,CAAC,eAAI,WAAW,CAAC;AAE1C,MAAM,iBAAiB,IAAI,+BAAmB,mBAAmB,OAAO,EAC5E,YAAY,OAAO,SAAS,SAAS,UAAU,QAAQ;AACtD,QAAM,SAAS,IAAI,QAAQ,IAAI,QAAQ,KAAK;AAC5C,MAAI,gBAAgB,IAAI,MAAM,KAAK,eAAI,aAAa,eAAe;AACjE,QAAI,QAAQ,IAAI,+BAA+B,UAAU,GAAG;AAC5D,QAAI,QAAQ,IAAI,oCAAoC,MAAM;AAC1D,QAAI,QAAQ,IAAI,gCAAgC,mCAAmC;AACnF,QAAI,QAAQ,IAAI,gCAAgC,6CAA6C;AAAA,EAC/F;AACF,CAAC,EACA,OAAO;", + "names": [] +} diff --git a/backend/dist/lib/middlewares/main.js b/backend/dist/lib/middlewares/main.js new file mode 100644 index 00000000..830aa54c --- /dev/null +++ b/backend/dist/lib/middlewares/main.js @@ -0,0 +1,38 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var main_exports = {}; +__export(main_exports, { + mainMiddleware: () => mainMiddleware +}); +module.exports = __toCommonJS(main_exports); +var import_rjweb_server = require("rjweb-server"); +var import_logger = require("../logger"); +const log = (0, import_logger.createLogger)("HTTP"); +const mainMiddleware = new import_rjweb_server.Middleware("Main Middleware", "1.0.0").load(() => { + log.info("Main middleware loaded"); +}).httpRequest(async (_config, _server, _context, ctr) => { + if (ctr.url.method === "OPTIONS") { + ctr.status(204).print(""); + } +}).export(); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + mainMiddleware +}); +//# sourceMappingURL=main.js.map diff --git a/backend/dist/lib/middlewares/main.js.map b/backend/dist/lib/middlewares/main.js.map new file mode 100644 index 00000000..3ebecafa --- /dev/null +++ b/backend/dist/lib/middlewares/main.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../../src/lib/middlewares/main.ts"], + "sourcesContent": ["import { Middleware } from \"rjweb-server\";\nimport { createLogger } from \"../logger\";\n\nconst log = createLogger(\"HTTP\");\n\nexport const mainMiddleware = new Middleware<{}, {}>(\"Main Middleware\", \"1.0.0\")\n .load(() => {\n log.info(\"Main middleware loaded\");\n })\n .httpRequest(async (_config, _server, _context, ctr) => {\n if (ctr.url.method === \"OPTIONS\") {\n ctr.status(204).print(\"\");\n }\n })\n .export();\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0BAA2B;AAC3B,oBAA6B;AAE7B,MAAM,UAAM,4BAAa,MAAM;AAExB,MAAM,iBAAiB,IAAI,+BAAmB,mBAAmB,OAAO,EAC5E,KAAK,MAAM;AACV,MAAI,KAAK,wBAAwB;AACnC,CAAC,EACA,YAAY,OAAO,SAAS,SAAS,UAAU,QAAQ;AACtD,MAAI,IAAI,IAAI,WAAW,WAAW;AAChC,QAAI,OAAO,GAAG,EAAE,MAAM,EAAE;AAAA,EAC1B;AACF,CAAC,EACA,OAAO;", + "names": [] +} diff --git a/backend/dist/lib/response.js b/backend/dist/lib/response.js new file mode 100644 index 00000000..4dd6c7c8 --- /dev/null +++ b/backend/dist/lib/response.js @@ -0,0 +1,64 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var response_exports = {}; +__export(response_exports, { + endResponse: () => endResponse, + makeResponse: () => makeResponse +}); +module.exports = __toCommonJS(response_exports); +var import_errors = require("./errors"); +function resolve(content) { + const code = "code" in content ? content.code : content.status; + const message = code >= 500 ? import_errors.ERROR_MESSAGES.INTERNAL_SERVER_ERROR.message : content.message; + return { code, message }; +} +function buildBody(code, message, data) { + if (code >= 400) { + return { status: "FAILED", message }; + } + return { + status: "OK", + ...message !== void 0 ? { message } : {}, + ...data !== void 0 ? { data } : {} + }; +} +async function makeResponse({ + ctr, + content +}) { + const { code, message } = resolve(content); + const data = "data" in content ? content.data : void 0; + return ctr.status(code).print(buildBody(code, message, data)); +} +async function endResponse({ + ctr, + end, + content +}) { + const { code, message } = resolve(content); + const data = "data" in content ? content.data : void 0; + ctr.status(code).print(buildBody(code, message, data)); + end(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + endResponse, + makeResponse +}); +//# sourceMappingURL=response.js.map diff --git a/backend/dist/lib/response.js.map b/backend/dist/lib/response.js.map new file mode 100644 index 00000000..5aa2aa99 --- /dev/null +++ b/backend/dist/lib/response.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/lib/response.ts"], + "sourcesContent": ["import { ERROR_MESSAGES } from \"./errors\";\n\ntype ResponseContent =\n | { code: number; message?: string; data?: unknown }\n | { status: number; message?: string; data?: unknown };\n\nfunction resolve(content: ResponseContent) {\n const code = \"code\" in content ? content.code : content.status;\n const message = code >= 500 ? ERROR_MESSAGES.INTERNAL_SERVER_ERROR.message : content.message;\n return { code, message };\n}\n\nfunction buildBody(code: number, message: string | undefined, data: unknown) {\n if (code >= 400) {\n return { status: \"FAILED\", message };\n }\n return {\n status: \"OK\",\n ...(message !== undefined ? { message } : {}),\n ...(data !== undefined ? { data } : {}),\n };\n}\n\nexport async function makeResponse({\n ctr,\n content,\n}: {\n ctr: any;\n content: ResponseContent;\n}) {\n const { code, message } = resolve(content);\n const data = \"data\" in content ? content.data : undefined;\n return ctr.status(code).print(buildBody(code, message, data));\n}\n\nexport async function endResponse({\n ctr,\n end,\n content,\n}: {\n ctr: any;\n end: () => void;\n content: ResponseContent;\n}) {\n const { code, message } = resolve(content);\n const data = \"data\" in content ? content.data : undefined;\n ctr.status(code).print(buildBody(code, message, data));\n end();\n}\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAA+B;AAM/B,SAAS,QAAQ,SAA0B;AACzC,QAAM,OAAO,UAAU,UAAU,QAAQ,OAAO,QAAQ;AACxD,QAAM,UAAU,QAAQ,MAAM,6BAAe,sBAAsB,UAAU,QAAQ;AACrF,SAAO,EAAE,MAAM,QAAQ;AACzB;AAEA,SAAS,UAAU,MAAc,SAA6B,MAAe;AAC3E,MAAI,QAAQ,KAAK;AACf,WAAO,EAAE,QAAQ,UAAU,QAAQ;AAAA,EACrC;AACA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,GAAI,YAAY,SAAY,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3C,GAAI,SAAS,SAAY,EAAE,KAAK,IAAI,CAAC;AAAA,EACvC;AACF;AAEA,eAAsB,aAAa;AAAA,EACjC;AAAA,EACA;AACF,GAGG;AACD,QAAM,EAAE,MAAM,QAAQ,IAAI,QAAQ,OAAO;AACzC,QAAM,OAAO,UAAU,UAAU,QAAQ,OAAO;AAChD,SAAO,IAAI,OAAO,IAAI,EAAE,MAAM,UAAU,MAAM,SAAS,IAAI,CAAC;AAC9D;AAEA,eAAsB,YAAY;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,EAAE,MAAM,QAAQ,IAAI,QAAQ,OAAO;AACzC,QAAM,OAAO,UAAU,UAAU,QAAQ,OAAO;AAChD,MAAI,OAAO,IAAI,EAAE,MAAM,UAAU,MAAM,SAAS,IAAI,CAAC;AACrD,MAAI;AACN;", + "names": [] +} diff --git a/backend/dist/routes/api/admin.js b/backend/dist/routes/api/admin.js new file mode 100644 index 00000000..292820e1 --- /dev/null +++ b/backend/dist/routes/api/admin.js @@ -0,0 +1,192 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var admin_exports = {}; +__export(admin_exports, { + adminListPreviews: () => adminListPreviews, + adminStopPreview: () => adminStopPreview, + createUser: () => createUser, + deleteUser: () => deleteUser, + getSettings: () => getSettings, + listUsers: () => listUsers, + updateSettings: () => updateSettings, + updateUser: () => updateUser +}); +module.exports = __toCommonJS(admin_exports); +var import_bcryptjs = __toESM(require("bcryptjs")); +var import_db = require("../../lib/db"); +var import_response = require("../../lib/response"); +var import_errors = require("../../lib/errors"); +var import_adminSettings = require("../../lib/adminSettings"); +function requireAdmin(ctr) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + if (!auth.user.isAdmin) return null; + return auth.user; +} +async function listUsers(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const users = await import_db.prisma.user.findMany({ + select: { id: true, username: true, isAdmin: true, isFounder: true, createdAt: true, giteaInstanceUrl: true }, + orderBy: { createdAt: "asc" } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: users } }); +} +async function createUser(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const body = await ctr.body(); + const { username, password } = body || {}; + if (!username || !password) return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Username and password required" } }); + const existing = await import_db.prisma.user.findUnique({ where: { username } }); + if (existing) return (0, import_response.makeResponse)({ ctr, content: { code: 409, message: "Username already taken" } }); + const userCount = await import_db.prisma.user.count(); + const isFirst = userCount === 0; + const hash = await import_bcryptjs.default.hash(password, 12); + const user = await import_db.prisma.user.create({ + data: { username, passwordHash: hash, isAdmin: isFirst, isFounder: isFirst }, + select: { id: true, username: true, isAdmin: true, isFounder: true } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 201, data: user } }); +} +async function updateUser(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const id = parseInt(ctr.params.get("id") || "0", 10); + const target = await import_db.prisma.user.findUnique({ where: { id } }); + if (!target) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: import_errors.ERROR_MESSAGES.NOT_FOUND.message } }); + const body = await ctr.body(); + const { username, password, isAdmin } = body || {}; + const data = {}; + if (username) { + const existing = await import_db.prisma.user.findFirst({ where: { username, id: { not: id } } }); + if (existing) return (0, import_response.makeResponse)({ ctr, content: { code: 409, message: "Username taken" } }); + data.username = username; + } + if (password) data.passwordHash = await import_bcryptjs.default.hash(password, 12); + if (isAdmin !== void 0 && !target.isFounder) data.isAdmin = Boolean(isAdmin); + await import_db.prisma.user.update({ where: { id }, data }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "User updated" } }); +} +async function deleteUser(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const id = parseInt(ctr.params.get("id") || "0", 10); + const target = await import_db.prisma.user.findUnique({ where: { id } }); + if (!target) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: import_errors.ERROR_MESSAGES.NOT_FOUND.message } }); + if (target.isFounder) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: "Cannot delete founder" } }); + if (id === admin.id) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: "Cannot delete yourself" } }); + await import_db.prisma.user.delete({ where: { id } }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "User deleted" } }); +} +async function getSettings(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const settings = await (0, import_adminSettings.getAdminSettings)(); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: settings } }); +} +async function updateSettings(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const body = await ctr.body(); + const { + defaultInstanceType, + maxConcurrentInstancesPerUser, + logSizeLimitBytes, + previewRetentionDays, + webhookRateLimitPerMinute, + contactEmail + } = body || {}; + const data = {}; + if (defaultInstanceType) data.defaultInstanceType = defaultInstanceType; + if (maxConcurrentInstancesPerUser) data.maxConcurrentInstancesPerUser = Number(maxConcurrentInstancesPerUser); + if (logSizeLimitBytes) data.logSizeLimitBytes = Number(logSizeLimitBytes); + if (previewRetentionDays) data.previewRetentionDays = Number(previewRetentionDays); + if (webhookRateLimitPerMinute) data.webhookRateLimitPerMinute = Number(webhookRateLimitPerMinute); + if (contactEmail !== void 0) data.contactEmail = contactEmail; + await import_db.prisma.adminSettings.upsert({ + where: { id: 1 }, + update: data, + create: { id: 1, ...data } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Settings updated" } }); +} +async function adminListPreviews(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const previews = await import_db.prisma.preview.findMany({ + include: { + repoConfig: { include: { user: { select: { id: true, username: true } } } } + }, + orderBy: { updatedAt: "desc" }, + take: 200 + }); + return (0, import_response.makeResponse)({ + ctr, + content: { + code: 200, + data: previews.map((p) => ({ + id: p.id, + prNumber: p.prNumber, + prTitle: p.prTitle, + status: p.status, + instanceIp: p.instanceIp, + port: p.port, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + repoOwner: p.repoConfig.repoOwner, + repoName: p.repoConfig.repoName, + user: p.repoConfig.user + })) + } + }); +} +async function adminStopPreview(ctr) { + const admin = requireAdmin(ctr); + if (!admin) return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: import_errors.ERROR_MESSAGES.FORBIDDEN.message } }); + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await import_db.prisma.preview.findUnique({ where: { id } }); + if (!preview) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: import_errors.ERROR_MESSAGES.NOT_FOUND.message } }); + await import_db.prisma.job.create({ + data: { previewId: id, type: "STOP", status: "PENDING", payload: { reason: "Admin stop" } } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Stop job enqueued" } }); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + adminListPreviews, + adminStopPreview, + createUser, + deleteUser, + getSettings, + listUsers, + updateSettings, + updateUser +}); +//# sourceMappingURL=admin.js.map diff --git a/backend/dist/routes/api/admin.js.map b/backend/dist/routes/api/admin.js.map new file mode 100644 index 00000000..f7a5e769 --- /dev/null +++ b/backend/dist/routes/api/admin.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../../src/routes/api/admin.ts"], + "sourcesContent": ["import bcrypt from \"bcryptjs\";\r\nimport { prisma } from \"../../lib/db\";\r\nimport { makeResponse } from \"../../lib/response\";\r\nimport { ERROR_MESSAGES } from \"../../lib/errors\";\r\nimport { getAdminSettings } from \"../../lib/adminSettings\";\r\n\r\nfunction requireAdmin(ctr: any) {\r\n const auth = ctr.getAuth?.();\r\n if (!auth?.success) return null;\r\n if (!auth.user.isAdmin) return null;\r\n return auth.user;\r\n}\r\n\r\nexport async function listUsers(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const users = await prisma.user.findMany({\r\n select: { id: true, username: true, isAdmin: true, isFounder: true, createdAt: true, giteaInstanceUrl: true },\r\n orderBy: { createdAt: \"asc\" },\r\n });\r\n\r\n return makeResponse({ ctr, content: { code: 200, data: users } });\r\n}\r\n\r\nexport async function createUser(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const body = await ctr.body();\r\n const { username, password } = body || {};\r\n if (!username || !password) return makeResponse({ ctr, content: { code: 400, message: \"Username and password required\" } });\r\n\r\n const existing = await prisma.user.findUnique({ where: { username } });\r\n if (existing) return makeResponse({ ctr, content: { code: 409, message: \"Username already taken\" } });\r\n\r\n const userCount = await prisma.user.count();\r\n const isFirst = userCount === 0;\r\n const hash = await bcrypt.hash(password, 12);\r\n\r\n const user = await prisma.user.create({\r\n data: { username, passwordHash: hash, isAdmin: isFirst, isFounder: isFirst },\r\n select: { id: true, username: true, isAdmin: true, isFounder: true },\r\n });\r\n\r\n return makeResponse({ ctr, content: { code: 201, data: user } });\r\n}\r\n\r\nexport async function updateUser(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const id = parseInt(ctr.params.get(\"id\") || \"0\", 10);\r\n const target = await prisma.user.findUnique({ where: { id } });\r\n if (!target) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });\r\n\r\n const body = await ctr.body();\r\n const { username, password, isAdmin } = body || {};\r\n const data: any = {};\r\n\r\n if (username) {\r\n const existing = await prisma.user.findFirst({ where: { username, id: { not: id } } });\r\n if (existing) return makeResponse({ ctr, content: { code: 409, message: \"Username taken\" } });\r\n data.username = username;\r\n }\r\n if (password) data.passwordHash = await bcrypt.hash(password, 12);\r\n if (isAdmin !== undefined && !target.isFounder) data.isAdmin = Boolean(isAdmin);\r\n\r\n await prisma.user.update({ where: { id }, data });\r\n return makeResponse({ ctr, content: { code: 200, message: \"User updated\" } });\r\n}\r\n\r\nexport async function deleteUser(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const id = parseInt(ctr.params.get(\"id\") || \"0\", 10);\r\n const target = await prisma.user.findUnique({ where: { id } });\r\n if (!target) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });\r\n if (target.isFounder) return makeResponse({ ctr, content: { code: 403, message: \"Cannot delete founder\" } });\r\n if (id === admin.id) return makeResponse({ ctr, content: { code: 403, message: \"Cannot delete yourself\" } });\r\n\r\n await prisma.user.delete({ where: { id } });\r\n return makeResponse({ ctr, content: { code: 200, message: \"User deleted\" } });\r\n}\r\n\r\nexport async function getSettings(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const settings = await getAdminSettings();\r\n return makeResponse({ ctr, content: { code: 200, data: settings } });\r\n}\r\n\r\nexport async function updateSettings(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const body = await ctr.body();\r\n const {\r\n defaultInstanceType,\r\n maxConcurrentInstancesPerUser,\r\n logSizeLimitBytes,\r\n previewRetentionDays,\r\n webhookRateLimitPerMinute,\r\n contactEmail,\r\n } = body || {};\r\n\r\n const data: any = {};\r\n if (defaultInstanceType) data.defaultInstanceType = defaultInstanceType;\r\n if (maxConcurrentInstancesPerUser) data.maxConcurrentInstancesPerUser = Number(maxConcurrentInstancesPerUser);\r\n if (logSizeLimitBytes) data.logSizeLimitBytes = Number(logSizeLimitBytes);\r\n if (previewRetentionDays) data.previewRetentionDays = Number(previewRetentionDays);\r\n if (webhookRateLimitPerMinute) data.webhookRateLimitPerMinute = Number(webhookRateLimitPerMinute);\r\n if (contactEmail !== undefined) data.contactEmail = contactEmail;\r\n\r\n await prisma.adminSettings.upsert({\r\n where: { id: 1 },\r\n update: data,\r\n create: { id: 1, ...data },\r\n });\r\n\r\n return makeResponse({ ctr, content: { code: 200, message: \"Settings updated\" } });\r\n}\r\n\r\nexport async function adminListPreviews(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const previews = await prisma.preview.findMany({\r\n include: {\r\n repoConfig: { include: { user: { select: { id: true, username: true } } } },\r\n },\r\n orderBy: { updatedAt: \"desc\" },\r\n take: 200,\r\n });\r\n\r\n return makeResponse({\r\n ctr, content: {\r\n code: 200, data: previews.map(p => ({\r\n id: p.id,\r\n prNumber: p.prNumber,\r\n prTitle: p.prTitle,\r\n status: p.status,\r\n instanceIp: p.instanceIp,\r\n port: p.port,\r\n createdAt: p.createdAt,\r\n updatedAt: p.updatedAt,\r\n repoOwner: p.repoConfig.repoOwner,\r\n repoName: p.repoConfig.repoName,\r\n user: (p.repoConfig as any).user,\r\n }))\r\n }\r\n });\r\n}\r\n\r\nexport async function adminStopPreview(ctr: any) {\r\n const admin = requireAdmin(ctr);\r\n if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });\r\n\r\n const id = parseInt(ctr.params.get(\"id\") || \"0\", 10);\r\n const preview = await prisma.preview.findUnique({ where: { id } });\r\n if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });\r\n\r\n await prisma.job.create({\r\n data: { previewId: id, type: \"STOP\", status: \"PENDING\", payload: { reason: \"Admin stop\" } },\r\n });\r\n\r\n return makeResponse({ ctr, content: { code: 200, message: \"Stop job enqueued\" } });\r\n}\r\n"], + "mappings": "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", + "names": ["bcrypt"] +} diff --git a/backend/dist/routes/api/previews.js b/backend/dist/routes/api/previews.js new file mode 100644 index 00000000..a00921ae --- /dev/null +++ b/backend/dist/routes/api/previews.js @@ -0,0 +1,153 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var previews_exports = {}; +__export(previews_exports, { + getPreview: () => getPreview, + listPreviews: () => listPreviews, + previewLogsWs: () => previewLogsWs, + stopPreviewRoute: () => stopPreviewRoute +}); +module.exports = __toCommonJS(previews_exports); +var import_db = require("../../lib/db"); +var import_response = require("../../lib/response"); +var import_errors = require("../../lib/errors"); +var import_deploy = require("../../services/deploy"); +function requireAuth(ctr) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + return auth.user; +} +async function listPreviews(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const previews = await import_db.prisma.preview.findMany({ + where: { repoConfig: { userId: user.id } }, + include: { repoConfig: { select: { repoOwner: true, repoName: true } } }, + orderBy: { updatedAt: "desc" } + }); + return (0, import_response.makeResponse)({ + ctr, + content: { + code: 200, + data: previews.map((p) => ({ + id: p.id, + prNumber: p.prNumber, + prTitle: p.prTitle, + commitSha: p.commitSha, + status: p.status, + instanceIp: p.instanceIp, + port: p.port, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + lastActivityAt: p.lastActivityAt, + repoOwner: p.repoConfig.repoOwner, + repoName: p.repoConfig.repoName + })) + } + }); +} +async function getPreview(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await import_db.prisma.preview.findFirst({ + where: { id, repoConfig: { userId: user.id } }, + include: { + repoConfig: { select: { repoOwner: true, repoName: true } }, + jobs: { orderBy: { createdAt: "desc" }, take: 10 } + } + }); + if (!preview) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: import_errors.ERROR_MESSAGES.NOT_FOUND.message } }); + return (0, import_response.makeResponse)({ + ctr, + content: { + code: 200, + data: { + id: preview.id, + prNumber: preview.prNumber, + prTitle: preview.prTitle, + commitSha: preview.commitSha, + status: preview.status, + instanceIp: preview.instanceIp, + port: preview.port, + logs: preview.logs, + createdAt: preview.createdAt, + updatedAt: preview.updatedAt, + stoppedAt: preview.stoppedAt, + lastActivityAt: preview.lastActivityAt, + repoOwner: preview.repoConfig.repoOwner, + repoName: preview.repoConfig.repoName, + jobs: preview.jobs.map((j) => ({ + id: j.id, + type: j.type, + status: j.status, + createdAt: j.createdAt, + startedAt: j.startedAt, + finishedAt: j.finishedAt, + error: j.error + })) + } + } + }); +} +async function stopPreviewRoute(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await import_db.prisma.preview.findFirst({ + where: { id, repoConfig: { userId: user.id } } + }); + if (!preview) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: import_errors.ERROR_MESSAGES.NOT_FOUND.message } }); + if (preview.status === "STOPPED") return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Already stopped" } }); + await import_db.prisma.job.create({ + data: { previewId: id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop via UI" } } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Stop job enqueued" } }); +} +async function previewLogsWs(ctr) { + const auth = ctr.getAuth?.(); + if (!auth?.success) { + ctr.close(1008, "Unauthorized"); + return; + } + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await import_db.prisma.preview.findFirst({ + where: { id, repoConfig: { userId: auth.user.id } } + }); + if (!preview) { + ctr.close(1008, "Not found"); + return; + } + await ctr.print(JSON.stringify({ type: "init", logs: preview.logs })); + const unsub = (0, import_deploy.subscribeToLogs)(id, (text) => { + try { + ctr.print(JSON.stringify({ type: "append", text })); + } catch { + } + }); + ctr.$abort(unsub); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + getPreview, + listPreviews, + previewLogsWs, + stopPreviewRoute +}); +//# sourceMappingURL=previews.js.map diff --git a/backend/dist/routes/api/previews.js.map b/backend/dist/routes/api/previews.js.map new file mode 100644 index 00000000..4b936544 --- /dev/null +++ b/backend/dist/routes/api/previews.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../../src/routes/api/previews.ts"], + "sourcesContent": ["import { prisma } from \"../../lib/db\";\nimport { makeResponse } from \"../../lib/response\";\nimport { ERROR_MESSAGES } from \"../../lib/errors\";\nimport { subscribeToLogs } from \"../../services/deploy\";\n\nfunction requireAuth(ctr: any) {\n const auth = ctr.getAuth?.();\n if (!auth?.success) return null;\n return auth.user;\n}\n\nexport async function listPreviews(ctr: any) {\n const user = requireAuth(ctr);\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\n\n const previews = await prisma.preview.findMany({\n where: { repoConfig: { userId: user.id } },\n include: { repoConfig: { select: { repoOwner: true, repoName: true } } },\n orderBy: { updatedAt: \"desc\" },\n });\n\n return makeResponse({\n ctr, content: {\n code: 200, data: previews.map(p => ({\n id: p.id,\n prNumber: p.prNumber,\n prTitle: p.prTitle,\n commitSha: p.commitSha,\n status: p.status,\n instanceIp: p.instanceIp,\n port: p.port,\n createdAt: p.createdAt,\n updatedAt: p.updatedAt,\n lastActivityAt: p.lastActivityAt,\n repoOwner: p.repoConfig.repoOwner,\n repoName: p.repoConfig.repoName,\n }))\n }\n });\n}\n\nexport async function getPreview(ctr: any) {\n const user = requireAuth(ctr);\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\n\n const id = parseInt(ctr.params.get(\"id\") || \"0\", 10);\n const preview = await prisma.preview.findFirst({\n where: { id, repoConfig: { userId: user.id } },\n include: {\n repoConfig: { select: { repoOwner: true, repoName: true } },\n jobs: { orderBy: { createdAt: \"desc\" }, take: 10 },\n },\n });\n\n if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });\n\n return makeResponse({\n ctr, content: {\n code: 200, data: {\n id: preview.id,\n prNumber: preview.prNumber,\n prTitle: preview.prTitle,\n commitSha: preview.commitSha,\n status: preview.status,\n instanceIp: preview.instanceIp,\n port: preview.port,\n logs: preview.logs,\n createdAt: preview.createdAt,\n updatedAt: preview.updatedAt,\n stoppedAt: preview.stoppedAt,\n lastActivityAt: preview.lastActivityAt,\n repoOwner: preview.repoConfig.repoOwner,\n repoName: preview.repoConfig.repoName,\n jobs: preview.jobs.map(j => ({\n id: j.id,\n type: j.type,\n status: j.status,\n createdAt: j.createdAt,\n startedAt: j.startedAt,\n finishedAt: j.finishedAt,\n error: j.error,\n })),\n }\n }\n });\n}\n\nexport async function stopPreviewRoute(ctr: any) {\n const user = requireAuth(ctr);\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\n\n const id = parseInt(ctr.params.get(\"id\") || \"0\", 10);\n const preview = await prisma.preview.findFirst({\n where: { id, repoConfig: { userId: user.id } },\n });\n\n if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });\n if (preview.status === \"STOPPED\") return makeResponse({ ctr, content: { code: 400, message: \"Already stopped\" } });\n\n await prisma.job.create({\n data: { previewId: id, type: \"STOP\", status: \"PENDING\", payload: { reason: \"Manual stop via UI\" } },\n });\n\n return makeResponse({ ctr, content: { code: 200, message: \"Stop job enqueued\" } });\n}\n\nexport async function previewLogsWs(ctr: any) {\n const auth = ctr.getAuth?.();\n if (!auth?.success) {\n ctr.close(1008, \"Unauthorized\");\n return;\n }\n\n const id = parseInt(ctr.params.get(\"id\") || \"0\", 10);\n const preview = await prisma.preview.findFirst({\n where: { id, repoConfig: { userId: auth.user.id } },\n });\n\n if (!preview) {\n ctr.close(1008, \"Not found\");\n return;\n }\n\n await ctr.print(JSON.stringify({ type: \"init\", logs: preview.logs }));\n\n const unsub = subscribeToLogs(id, (text) => {\n try {\n ctr.print(JSON.stringify({ type: \"append\", text }));\n } catch {}\n });\n\n ctr.$abort(unsub);\n}\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAAuB;AACvB,sBAA6B;AAC7B,oBAA+B;AAC/B,oBAAgC;AAEhC,SAAS,YAAY,KAAU;AAC7B,QAAM,OAAO,IAAI,UAAU;AAC3B,MAAI,CAAC,MAAM,QAAS,QAAO;AAC3B,SAAO,KAAK;AACd;AAEA,eAAsB,aAAa,KAAU;AAC3C,QAAM,OAAO,YAAY,GAAG;AAC5B,MAAI,CAAC,KAAM,YAAO,8BAAa,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK,SAAS,6BAAe,aAAa,QAAQ,EAAE,CAAC;AAE5G,QAAM,WAAW,MAAM,iBAAO,QAAQ,SAAS;AAAA,IAC7C,OAAO,EAAE,YAAY,EAAE,QAAQ,KAAK,GAAG,EAAE;AAAA,IACzC,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,MAAM,UAAU,KAAK,EAAE,EAAE;AAAA,IACvE,SAAS,EAAE,WAAW,OAAO;AAAA,EAC/B,CAAC;AAED,aAAO,8BAAa;AAAA,IAClB;AAAA,IAAK,SAAS;AAAA,MACZ,MAAM;AAAA,MAAK,MAAM,SAAS,IAAI,QAAM;AAAA,QAClC,IAAI,EAAE;AAAA,QACN,UAAU,EAAE;AAAA,QACZ,SAAS,EAAE;AAAA,QACX,WAAW,EAAE;AAAA,QACb,QAAQ,EAAE;AAAA,QACV,YAAY,EAAE;AAAA,QACd,MAAM,EAAE;AAAA,QACR,WAAW,EAAE;AAAA,QACb,WAAW,EAAE;AAAA,QACb,gBAAgB,EAAE;AAAA,QAClB,WAAW,EAAE,WAAW;AAAA,QACxB,UAAU,EAAE,WAAW;AAAA,MACzB,EAAE;AAAA,IACJ;AAAA,EACF,CAAC;AACH;AAEA,eAAsB,WAAW,KAAU;AACzC,QAAM,OAAO,YAAY,GAAG;AAC5B,MAAI,CAAC,KAAM,YAAO,8BAAa,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK,SAAS,6BAAe,aAAa,QAAQ,EAAE,CAAC;AAE5G,QAAM,KAAK,SAAS,IAAI,OAAO,IAAI,IAAI,KAAK,KAAK,EAAE;AACnD,QAAM,UAAU,MAAM,iBAAO,QAAQ,UAAU;AAAA,IAC7C,OAAO,EAAE,IAAI,YAAY,EAAE,QAAQ,KAAK,GAAG,EAAE;AAAA,IAC7C,SAAS;AAAA,MACP,YAAY,EAAE,QAAQ,EAAE,WAAW,MAAM,UAAU,KAAK,EAAE;AAAA,MAC1D,MAAM,EAAE,SAAS,EAAE,WAAW,OAAO,GAAG,MAAM,GAAG;AAAA,IACnD;AAAA,EACF,CAAC;AAED,MAAI,CAAC,QAAS,YAAO,8BAAa,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK,SAAS,6BAAe,UAAU,QAAQ,EAAE,CAAC;AAE5G,aAAO,8BAAa;AAAA,IAClB;AAAA,IAAK,SAAS;AAAA,MACZ,MAAM;AAAA,MAAK,MAAM;AAAA,QACf,IAAI,QAAQ;AAAA,QACZ,UAAU,QAAQ;AAAA,QAClB,SAAS,QAAQ;AAAA,QACjB,WAAW,QAAQ;AAAA,QACnB,QAAQ,QAAQ;AAAA,QAChB,YAAY,QAAQ;AAAA,QACpB,MAAM,QAAQ;AAAA,QACd,MAAM,QAAQ;AAAA,QACd,WAAW,QAAQ;AAAA,QACnB,WAAW,QAAQ;AAAA,QACnB,WAAW,QAAQ;AAAA,QACnB,gBAAgB,QAAQ;AAAA,QACxB,WAAW,QAAQ,WAAW;AAAA,QAC9B,UAAU,QAAQ,WAAW;AAAA,QAC7B,MAAM,QAAQ,KAAK,IAAI,QAAM;AAAA,UAC3B,IAAI,EAAE;AAAA,UACN,MAAM,EAAE;AAAA,UACR,QAAQ,EAAE;AAAA,UACV,WAAW,EAAE;AAAA,UACb,WAAW,EAAE;AAAA,UACb,YAAY,EAAE;AAAA,UACd,OAAO,EAAE;AAAA,QACX,EAAE;AAAA,MACJ;AAAA,IACF;AAAA,EACF,CAAC;AACH;AAEA,eAAsB,iBAAiB,KAAU;AAC/C,QAAM,OAAO,YAAY,GAAG;AAC5B,MAAI,CAAC,KAAM,YAAO,8BAAa,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK,SAAS,6BAAe,aAAa,QAAQ,EAAE,CAAC;AAE5G,QAAM,KAAK,SAAS,IAAI,OAAO,IAAI,IAAI,KAAK,KAAK,EAAE;AACnD,QAAM,UAAU,MAAM,iBAAO,QAAQ,UAAU;AAAA,IAC7C,OAAO,EAAE,IAAI,YAAY,EAAE,QAAQ,KAAK,GAAG,EAAE;AAAA,EAC/C,CAAC;AAED,MAAI,CAAC,QAAS,YAAO,8BAAa,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK,SAAS,6BAAe,UAAU,QAAQ,EAAE,CAAC;AAC5G,MAAI,QAAQ,WAAW,UAAW,YAAO,8BAAa,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK,SAAS,kBAAkB,EAAE,CAAC;AAEjH,QAAM,iBAAO,IAAI,OAAO;AAAA,IACtB,MAAM,EAAE,WAAW,IAAI,MAAM,QAAQ,QAAQ,WAAW,SAAS,EAAE,QAAQ,qBAAqB,EAAE;AAAA,EACpG,CAAC;AAED,aAAO,8BAAa,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK,SAAS,oBAAoB,EAAE,CAAC;AACnF;AAEA,eAAsB,cAAc,KAAU;AAC5C,QAAM,OAAO,IAAI,UAAU;AAC3B,MAAI,CAAC,MAAM,SAAS;AAClB,QAAI,MAAM,MAAM,cAAc;AAC9B;AAAA,EACF;AAEA,QAAM,KAAK,SAAS,IAAI,OAAO,IAAI,IAAI,KAAK,KAAK,EAAE;AACnD,QAAM,UAAU,MAAM,iBAAO,QAAQ,UAAU;AAAA,IAC7C,OAAO,EAAE,IAAI,YAAY,EAAE,QAAQ,KAAK,KAAK,GAAG,EAAE;AAAA,EACpD,CAAC;AAED,MAAI,CAAC,SAAS;AACZ,QAAI,MAAM,MAAM,WAAW;AAC3B;AAAA,EACF;AAEA,QAAM,IAAI,MAAM,KAAK,UAAU,EAAE,MAAM,QAAQ,MAAM,QAAQ,KAAK,CAAC,CAAC;AAEpE,QAAM,YAAQ,+BAAgB,IAAI,CAAC,SAAS;AAC1C,QAAI;AACF,UAAI,MAAM,KAAK,UAAU,EAAE,MAAM,UAAU,KAAK,CAAC,CAAC;AAAA,IACpD,QAAQ;AAAA,IAAC;AAAA,EACX,CAAC;AAED,MAAI,OAAO,KAAK;AAClB;", + "names": [] +} diff --git a/backend/dist/routes/api/repos.js b/backend/dist/routes/api/repos.js new file mode 100644 index 00000000..9e4bd6ca --- /dev/null +++ b/backend/dist/routes/api/repos.js @@ -0,0 +1,180 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var repos_exports = {}; +__export(repos_exports, { + getRepoConfig: () => getRepoConfig, + listRepos: () => listRepos, + saveRepoConfig: () => saveRepoConfig, + toggleRepoEnabled: () => toggleRepoEnabled +}); +module.exports = __toCommonJS(repos_exports); +var import_db = require("../../lib/db"); +var import_response = require("../../lib/response"); +var import_errors = require("../../lib/errors"); +var import_gitea = require("../../services/gitea"); +var import_adminSettings = require("../../lib/adminSettings"); +var import_env = require("../../lib/env"); +function requireAuth(ctr) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + return auth.user; +} +async function listRepos(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const fullUser = await import_db.prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Gitea credentials not configured" } }); + } + const giteaRepos = await (0, import_gitea.fetchUserRepos)(fullUser); + const configs = await import_db.prisma.repoConfig.findMany({ where: { userId: user.id } }); + const configMap = new Map(configs.map((c) => [`${c.repoOwner}/${c.repoName}`, c])); + const allOwners = [...new Set(giteaRepos.map((r) => r.full_name?.split("/")[0]).filter(Boolean))]; + const allConfigs = await import_db.prisma.repoConfig.findMany({ + where: { repoOwner: { in: allOwners } }, + select: { repoOwner: true, repoName: true, userId: true } + }); + const claimedByOthers = new Set( + allConfigs.filter((c) => c.userId !== user.id).map((c) => `${c.repoOwner}/${c.repoName}`) + ); + const result = giteaRepos.map((r) => { + const [owner, name] = (r.full_name || "").split("/"); + const key = `${owner}/${name}`; + const config = configMap.get(key); + const { giteaWebhookId, ...safeConfig } = config || {}; + return { + owner, + name, + fullName: r.full_name, + htmlUrl: r.html_url, + isEnabled: config?.isEnabled ?? false, + claimedByOther: claimedByOthers.has(key), + config: config ? safeConfig : null + }; + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: result } }); +} +async function getRepoConfig(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const owner = ctr.params.get("owner"); + const repo = ctr.params.get("repo"); + const config = await import_db.prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName: repo, userId: user.id } + }); + if (!config) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: "Repo config not found" } }); + const { giteaWebhookId, ...safeConfig } = config; + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: safeConfig } }); +} +async function saveRepoConfig(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { owner, repo, ...configData } = body || {}; + if (!owner || !repo) return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "owner and repo required" } }); + const existing = await import_db.prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName: repo } + }); + if (existing && existing.userId !== user.id) { + return (0, import_response.makeResponse)({ ctr, content: { code: 409, message: "This repo is already configured by another user." } }); + } + const settings = await (0, import_adminSettings.getAdminSettings)(); + const sanitized = { + repoOwner: owner, + repoName: repo, + userId: user.id, + instanceType: configData.instanceType ?? settings.defaultInstanceType, + inactivityHours: Math.min(72, Math.max(0.5, Number(configData.inactivityHours ?? 12))), + port: Number(configData.port ?? 3e3), + envVars: configData.envVars ?? {}, + useDockerCompose: Boolean(configData.useDockerCompose ?? false), + composeFilePath: configData.composeFilePath ?? null, + aptPackages: Array.isArray(configData.aptPackages) ? configData.aptPackages : [], + setupCommands: Array.isArray(configData.setupCommands) ? configData.setupCommands : [], + buildCommands: Array.isArray(configData.buildCommands) ? configData.buildCommands : [], + postBuildCommands: Array.isArray(configData.postBuildCommands) ? configData.postBuildCommands : [], + runCommand: configData.runCommand ?? null, + denyList: Array.isArray(configData.denyList) ? configData.denyList : [] + }; + let config; + if (existing) { + config = await import_db.prisma.repoConfig.update({ where: { id: existing.id }, data: sanitized }); + } else { + config = await import_db.prisma.repoConfig.create({ data: sanitized }); + } + const { giteaWebhookId, ...safeConfig } = config; + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: safeConfig } }); +} +async function toggleRepoEnabled(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { owner, repo, enabled } = body || {}; + if (!owner || !repo || enabled === void 0) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "owner, repo, enabled required" } }); + } + const fullUser = await import_db.prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Gitea credentials not configured" } }); + } + let config = await import_db.prisma.repoConfig.findFirst({ where: { repoOwner: owner, repoName: repo } }); + if (config && config.userId !== user.id) { + return (0, import_response.makeResponse)({ ctr, content: { code: 409, message: "This repo is already configured by another user." } }); + } + const webhookToken = await import_db.prisma.webhookToken.findUnique({ where: { userId: user.id } }); + if (!webhookToken) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "No webhook token configured. Go to Settings to generate one." } }); + } + if (enabled) { + if (!config) { + config = await import_db.prisma.repoConfig.create({ + data: { repoOwner: owner, repoName: repo, userId: user.id, isEnabled: false } + }); + } + const webhookUrl = `${import_env.env.PP_BASE_URL}/webhook/${user.id}`; + const hookId = await (0, import_gitea.registerWebhook)(fullUser, owner, repo, webhookUrl, webhookToken.token); + await import_db.prisma.repoConfig.update({ + where: { id: config.id }, + data: { isEnabled: true, giteaWebhookId: String(hookId) } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Repo enabled and webhook registered" } }); + } else { + if (!config) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: "Repo config not found" } }); + if (config.giteaWebhookId) { + try { + await (0, import_gitea.deleteWebhook)(fullUser, owner, repo, config.giteaWebhookId); + } catch (e) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: `Failed to delete Gitea webhook: ${e.message}` } }); + } + } + await import_db.prisma.repoConfig.update({ + where: { id: config.id }, + data: { isEnabled: false, giteaWebhookId: null } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Repo disabled and webhook removed" } }); + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + getRepoConfig, + listRepos, + saveRepoConfig, + toggleRepoEnabled +}); +//# sourceMappingURL=repos.js.map diff --git a/backend/dist/routes/api/repos.js.map b/backend/dist/routes/api/repos.js.map new file mode 100644 index 00000000..6becbe92 --- /dev/null +++ b/backend/dist/routes/api/repos.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../../src/routes/api/repos.ts"], + "sourcesContent": ["import { prisma } from \"../../lib/db\";\nimport { makeResponse } from \"../../lib/response\";\nimport { ERROR_MESSAGES } from \"../../lib/errors\";\nimport { fetchUserRepos, registerWebhook, deleteWebhook } from \"../../services/gitea\";\nimport { getAdminSettings } from \"../../lib/adminSettings\";\nimport { env } from \"../../lib/env\";\n\nfunction requireAuth(ctr: any) {\n const auth = ctr.getAuth?.();\n if (!auth?.success) return null;\n return auth.user;\n}\n\nexport async function listRepos(ctr: any) {\n const user = requireAuth(ctr);\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\n\n const fullUser = await prisma.user.findUnique({ where: { id: user.id } });\n if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) {\n return makeResponse({ ctr, content: { code: 400, message: \"Gitea credentials not configured\" } });\n }\n\n const giteaRepos = await fetchUserRepos(fullUser as any);\n const configs = await prisma.repoConfig.findMany({ where: { userId: user.id } });\n const configMap = new Map(configs.map(c => [`${c.repoOwner}/${c.repoName}`, c]));\n\n const allOwners = [...new Set(giteaRepos.map((r: any) => r.full_name?.split(\"/\")[0]).filter(Boolean))];\n const allConfigs = await prisma.repoConfig.findMany({\n where: { repoOwner: { in: allOwners } },\n select: { repoOwner: true, repoName: true, userId: true },\n });\n const claimedByOthers = new Set(\n allConfigs.filter(c => c.userId !== user.id).map(c => `${c.repoOwner}/${c.repoName}`)\n );\n\n const result = giteaRepos.map((r: any) => {\n const [owner, name] = (r.full_name || \"\").split(\"/\");\n const key = `${owner}/${name}`;\n const config = configMap.get(key);\n const { giteaWebhookId, ...safeConfig } = config || {} as any;\n return {\n owner,\n name,\n fullName: r.full_name,\n htmlUrl: r.html_url,\n isEnabled: config?.isEnabled ?? false,\n claimedByOther: claimedByOthers.has(key),\n config: config ? safeConfig : null,\n };\n });\n\n return makeResponse({ ctr, content: { code: 200, data: result } });\n}\n\nexport async function getRepoConfig(ctr: any) {\n const user = requireAuth(ctr);\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\n\n const owner = ctr.params.get(\"owner\");\n const repo = ctr.params.get(\"repo\");\n\n const config = await prisma.repoConfig.findFirst({\n where: { repoOwner: owner, repoName: repo, userId: user.id },\n });\n if (!config) return makeResponse({ ctr, content: { code: 404, message: \"Repo config not found\" } });\n\n const { giteaWebhookId, ...safeConfig } = config;\n return makeResponse({ ctr, content: { code: 200, data: safeConfig } });\n}\n\nexport async function saveRepoConfig(ctr: any) {\n const user = requireAuth(ctr);\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\n\n const body = await ctr.body();\n const { owner, repo, ...configData } = body || {};\n if (!owner || !repo) return makeResponse({ ctr, content: { code: 400, message: \"owner and repo required\" } });\n\n const existing = await prisma.repoConfig.findFirst({\n where: { repoOwner: owner, repoName: repo },\n });\n\n if (existing && existing.userId !== user.id) {\n return makeResponse({ ctr, content: { code: 409, message: \"This repo is already configured by another user.\" } });\n }\n\n const settings = await getAdminSettings();\n\n const sanitized = {\n repoOwner: owner,\n repoName: repo,\n userId: user.id,\n instanceType: configData.instanceType ?? settings.defaultInstanceType,\n inactivityHours: Math.min(72, Math.max(0.5, Number(configData.inactivityHours ?? 12))),\n port: Number(configData.port ?? 3000),\n envVars: configData.envVars ?? {},\n useDockerCompose: Boolean(configData.useDockerCompose ?? false),\n composeFilePath: configData.composeFilePath ?? null,\n aptPackages: Array.isArray(configData.aptPackages) ? configData.aptPackages : [],\n setupCommands: Array.isArray(configData.setupCommands) ? configData.setupCommands : [],\n buildCommands: Array.isArray(configData.buildCommands) ? configData.buildCommands : [],\n postBuildCommands: Array.isArray(configData.postBuildCommands) ? configData.postBuildCommands : [],\n runCommand: configData.runCommand ?? null,\n denyList: Array.isArray(configData.denyList) ? configData.denyList : [],\n };\n\n let config;\n if (existing) {\n config = await prisma.repoConfig.update({ where: { id: existing.id }, data: sanitized });\n } else {\n config = await prisma.repoConfig.create({ data: sanitized });\n }\n\n const { giteaWebhookId, ...safeConfig } = config;\n return makeResponse({ ctr, content: { code: 200, data: safeConfig } });\n}\n\nexport async function toggleRepoEnabled(ctr: any) {\n const user = requireAuth(ctr);\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\n\n const body = await ctr.body();\n const { owner, repo, enabled } = body || {};\n if (!owner || !repo || enabled === undefined) {\n return makeResponse({ ctr, content: { code: 400, message: \"owner, repo, enabled required\" } });\n }\n\n const fullUser = await prisma.user.findUnique({ where: { id: user.id } });\n if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) {\n return makeResponse({ ctr, content: { code: 400, message: \"Gitea credentials not configured\" } });\n }\n\n let config = await prisma.repoConfig.findFirst({ where: { repoOwner: owner, repoName: repo } });\n if (config && config.userId !== user.id) {\n return makeResponse({ ctr, content: { code: 409, message: \"This repo is already configured by another user.\" } });\n }\n\n const webhookToken = await prisma.webhookToken.findUnique({ where: { userId: user.id } });\n if (!webhookToken) {\n return makeResponse({ ctr, content: { code: 400, message: \"No webhook token configured. Go to Settings to generate one.\" } });\n }\n\n if (enabled) {\n if (!config) {\n config = await prisma.repoConfig.create({\n data: { repoOwner: owner, repoName: repo, userId: user.id, isEnabled: false },\n });\n }\n\n const webhookUrl = `${env.PP_BASE_URL}/webhook/${user.id}`;\n const hookId = await registerWebhook(fullUser as any, owner, repo, webhookUrl, webhookToken.token);\n await prisma.repoConfig.update({\n where: { id: config.id },\n data: { isEnabled: true, giteaWebhookId: String(hookId) },\n });\n return makeResponse({ ctr, content: { code: 200, message: \"Repo enabled and webhook registered\" } });\n } else {\n if (!config) return makeResponse({ ctr, content: { code: 404, message: \"Repo config not found\" } });\n if (config.giteaWebhookId) {\n try {\n await deleteWebhook(fullUser as any, owner, repo, config.giteaWebhookId);\n } catch (e: any) {\n return makeResponse({ ctr, content: { code: 400, message: `Failed to delete Gitea webhook: ${e.message}` } });\n }\n }\n await prisma.repoConfig.update({\n where: { id: config.id },\n data: { isEnabled: false, giteaWebhookId: null },\n });\n return makeResponse({ ctr, content: { code: 200, message: \"Repo disabled and webhook removed\" } });\n }\n}\n"], + "mappings": "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", + "names": [] +} diff --git a/backend/dist/routes/api/user.js b/backend/dist/routes/api/user.js new file mode 100644 index 00000000..b2e8c5f7 --- /dev/null +++ b/backend/dist/routes/api/user.js @@ -0,0 +1,204 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var user_exports = {}; +__export(user_exports, { + getUserSettings: () => getUserSettings, + getWebhookSecret: () => getWebhookSecret, + regenerateWebhookSecret: () => regenerateWebhookSecret, + updateAws: () => updateAws, + updateGitea: () => updateGitea, + updatePassword: () => updatePassword, + updateUsername: () => updateUsername +}); +module.exports = __toCommonJS(user_exports); +var import_bcryptjs = __toESM(require("bcryptjs")); +var import_crypto = require("crypto"); +var import_db = require("../../lib/db"); +var import_response = require("../../lib/response"); +var import_errors = require("../../lib/errors"); +var import_encryption = require("../../lib/encryption"); +var import_gitea = require("../../services/gitea"); +var import_ec2 = require("../../services/ec2"); +var import_env = require("../../lib/env"); +function requireAuth(ctr) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + return auth.user; +} +async function getUserSettings(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: import_errors.ERROR_MESSAGES.UNAUTHORIZED.code, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const webhookToken = await import_db.prisma.webhookToken.findUnique({ where: { userId: user.id } }); + const fullUser = await import_db.prisma.user.findUnique({ where: { id: user.id } }); + return (0, import_response.makeResponse)({ + ctr, + content: { + code: 200, + data: { + id: user.id, + username: user.username, + giteaUsername: fullUser?.giteaUsername, + giteaInstanceUrl: fullUser?.giteaInstanceUrl, + giteaPatSet: !!fullUser?.giteaPAT, + awsAccessKeyId: fullUser?.awsAccessKeyId ? "****" : null, + awsRegion: fullUser?.awsRegion, + webhookUrl: `${import_env.env.PP_BASE_URL}/webhook/${user.id}`, + webhookSecret: webhookToken?.token ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null, + webhookTokenExists: !!webhookToken + } + } + }); +} +async function updateUsername(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { username } = body || {}; + if (!username || typeof username !== "string") return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Username required" } }); + const existing = await import_db.prisma.user.findFirst({ where: { username, id: { not: user.id } } }); + if (existing) return (0, import_response.makeResponse)({ ctr, content: { code: 409, message: "Username already taken" } }); + await import_db.prisma.user.update({ where: { id: user.id }, data: { username } }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Username updated" } }); +} +async function updatePassword(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { currentPassword, newPassword } = body || {}; + if (!currentPassword || !newPassword) return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Current and new passwords required" } }); + const fullUser = await import_db.prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: "User not found" } }); + const valid = await import_bcryptjs.default.compare(currentPassword, fullUser.passwordHash); + if (!valid) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: "Current password incorrect" } }); + const hash = await import_bcryptjs.default.hash(newPassword, 12); + await import_db.prisma.user.update({ where: { id: user.id }, data: { passwordHash: hash } }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Password updated" } }); +} +async function updateGitea(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { giteaInstanceUrl, giteaUsername, giteaPAT } = body || {}; + if (!giteaInstanceUrl || !giteaUsername) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Gitea URL and username required" } }); + } + const cleanUrl = giteaInstanceUrl.replace(/\/+$/, ""); + const validation = await (0, import_gitea.validateGiteaUrl)(cleanUrl); + if (!validation.success) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: `Gitea validation failed: ${validation.error}` } }); + } + const data = { giteaInstanceUrl: cleanUrl, giteaUsername }; + if (giteaPAT) data.giteaPAT = (0, import_encryption.encrypt)(giteaPAT); + await import_db.prisma.user.update({ where: { id: user.id }, data }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: `Connected to Gitea ${validation.version}`, data: { version: validation.version } } }); +} +async function updateAws(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { awsAccessKeyId, awsSecretAccessKey, awsRegion } = body || {}; + if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "AWS credentials and region required" } }); + } + const tempUser = { + ...user, + awsAccessKeyId: (0, import_encryption.encrypt)(awsAccessKeyId), + awsSecretAccessKey: (0, import_encryption.encrypt)(awsSecretAccessKey), + awsRegion + }; + const validation = await (0, import_ec2.validateAwsCredentials)(tempUser); + if (!validation.success) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: `AWS validation failed: ${validation.error}` } }); + } + await import_db.prisma.user.update({ + where: { id: user.id }, + data: { + awsAccessKeyId: (0, import_encryption.encrypt)(awsAccessKeyId), + awsSecretAccessKey: (0, import_encryption.encrypt)(awsSecretAccessKey), + awsRegion + } + }); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: `Connected as ${validation.arn}`, data: { arn: validation.arn } } }); +} +async function getWebhookSecret(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + let token = await import_db.prisma.webhookToken.findUnique({ where: { userId: user.id } }); + if (!token) { + const secret = (0, import_crypto.randomBytes)(32).toString("hex"); + token = await import_db.prisma.webhookToken.create({ data: { userId: user.id, token: secret } }); + } + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: { token: token.token } } }); +} +async function regenerateWebhookSecret(ctr) { + const user = requireAuth(ctr); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + const newSecret = (0, import_crypto.randomBytes)(32).toString("hex"); + await import_db.prisma.webhookToken.upsert({ + where: { userId: user.id }, + update: { token: newSecret }, + create: { userId: user.id, token: newSecret } + }); + const fullUser = await import_db.prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser?.giteaInstanceUrl) { + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Secret regenerated (no Gitea hooks to update)", data: { token: newSecret } } }); + } + const configs = await import_db.prisma.repoConfig.findMany({ + where: { userId: user.id, giteaWebhookId: { not: null } } + }); + const { updateWebhookSecret } = await import("../../services/gitea"); + const webhookUrl = `${import_env.env.PP_BASE_URL}/webhook/${user.id}`; + const errors = []; + for (const config of configs) { + try { + await updateWebhookSecret(fullUser, config.repoOwner, config.repoName, config.giteaWebhookId, webhookUrl, newSecret); + } catch (e) { + errors.push(`${config.repoOwner}/${config.repoName}: ${e.message}`); + } + } + return (0, import_response.makeResponse)({ + ctr, + content: { + code: 200, + message: errors.length ? `Secret regenerated with ${errors.length} hook update errors` : "Secret regenerated and all hooks updated", + data: { token: newSecret, errors } + } + }); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + getUserSettings, + getWebhookSecret, + regenerateWebhookSecret, + updateAws, + updateGitea, + updatePassword, + updateUsername +}); +//# sourceMappingURL=user.js.map diff --git a/backend/dist/routes/api/user.js.map b/backend/dist/routes/api/user.js.map new file mode 100644 index 00000000..29f86d6f --- /dev/null +++ b/backend/dist/routes/api/user.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../../src/routes/api/user.ts"], + "sourcesContent": ["import bcrypt from \"bcryptjs\";\r\nimport { randomBytes } from \"crypto\";\r\nimport { prisma } from \"../../lib/db\";\r\nimport { makeResponse } from \"../../lib/response\";\r\nimport { ERROR_MESSAGES } from \"../../lib/errors\";\r\nimport { encrypt, decrypt } from \"../../lib/encryption\";\r\nimport { validateGiteaUrl } from \"../../services/gitea\";\r\nimport { validateAwsCredentials } from \"../../services/ec2\";\r\nimport { env } from \"../../lib/env\";\r\n\r\nfunction requireAuth(ctr: any) {\r\n const auth = ctr.getAuth?.();\r\n if (!auth?.success) return null;\r\n return auth.user;\r\n}\r\n\r\nexport async function getUserSettings(ctr: any) {\r\n const user = requireAuth(ctr);\r\n if (!user) return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n\r\n const webhookToken = await prisma.webhookToken.findUnique({ where: { userId: user.id } });\r\n const fullUser = await prisma.user.findUnique({ where: { id: user.id } });\r\n\r\n return makeResponse({\r\n ctr, content: {\r\n code: 200, data: {\r\n id: user.id,\r\n username: user.username,\r\n giteaUsername: fullUser?.giteaUsername,\r\n giteaInstanceUrl: fullUser?.giteaInstanceUrl,\r\n giteaPatSet: !!fullUser?.giteaPAT,\r\n awsAccessKeyId: fullUser?.awsAccessKeyId ? \"****\" : null,\r\n awsRegion: fullUser?.awsRegion,\r\n webhookUrl: `${env.PP_BASE_URL}/webhook/${user.id}`,\r\n webhookSecret: webhookToken?.token ? \"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\" : null,\r\n webhookTokenExists: !!webhookToken,\r\n }\r\n }\r\n });\r\n}\r\n\r\nexport async function updateUsername(ctr: any) {\r\n const user = requireAuth(ctr);\r\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n const body = await ctr.body();\r\n const { username } = body || {};\r\n if (!username || typeof username !== \"string\") return makeResponse({ ctr, content: { code: 400, message: \"Username required\" } });\r\n\r\n const existing = await prisma.user.findFirst({ where: { username, id: { not: user.id } } });\r\n if (existing) return makeResponse({ ctr, content: { code: 409, message: \"Username already taken\" } });\r\n\r\n await prisma.user.update({ where: { id: user.id }, data: { username } });\r\n return makeResponse({ ctr, content: { code: 200, message: \"Username updated\" } });\r\n}\r\n\r\nexport async function updatePassword(ctr: any) {\r\n const user = requireAuth(ctr);\r\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n const body = await ctr.body();\r\n const { currentPassword, newPassword } = body || {};\r\n if (!currentPassword || !newPassword) return makeResponse({ ctr, content: { code: 400, message: \"Current and new passwords required\" } });\r\n\r\n const fullUser = await prisma.user.findUnique({ where: { id: user.id } });\r\n if (!fullUser) return makeResponse({ ctr, content: { code: 404, message: \"User not found\" } });\r\n\r\n const valid = await bcrypt.compare(currentPassword, fullUser.passwordHash);\r\n if (!valid) return makeResponse({ ctr, content: { code: 401, message: \"Current password incorrect\" } });\r\n\r\n const hash = await bcrypt.hash(newPassword, 12);\r\n await prisma.user.update({ where: { id: user.id }, data: { passwordHash: hash } });\r\n return makeResponse({ ctr, content: { code: 200, message: \"Password updated\" } });\r\n}\r\n\r\nexport async function updateGitea(ctr: any) {\r\n const user = requireAuth(ctr);\r\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n const body = await ctr.body();\r\n const { giteaInstanceUrl, giteaUsername, giteaPAT } = body || {};\r\n\r\n if (!giteaInstanceUrl || !giteaUsername) {\r\n return makeResponse({ ctr, content: { code: 400, message: \"Gitea URL and username required\" } });\r\n }\r\n\r\n const cleanUrl = giteaInstanceUrl.replace(/\\/+$/, \"\");\r\n const validation = await validateGiteaUrl(cleanUrl);\r\n if (!validation.success) {\r\n return makeResponse({ ctr, content: { code: 400, message: `Gitea validation failed: ${validation.error}` } });\r\n }\r\n\r\n const data: any = { giteaInstanceUrl: cleanUrl, giteaUsername };\r\n if (giteaPAT) data.giteaPAT = encrypt(giteaPAT);\r\n\r\n await prisma.user.update({ where: { id: user.id }, data });\r\n return makeResponse({ ctr, content: { code: 200, message: `Connected to Gitea ${validation.version}`, data: { version: validation.version } } });\r\n}\r\n\r\nexport async function updateAws(ctr: any) {\r\n const user = requireAuth(ctr);\r\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n const body = await ctr.body();\r\n const { awsAccessKeyId, awsSecretAccessKey, awsRegion } = body || {};\r\n\r\n if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {\r\n return makeResponse({ ctr, content: { code: 400, message: \"AWS credentials and region required\" } });\r\n }\r\n\r\n const tempUser = {\r\n ...user,\r\n awsAccessKeyId: encrypt(awsAccessKeyId),\r\n awsSecretAccessKey: encrypt(awsSecretAccessKey),\r\n awsRegion,\r\n };\r\n const validation = await validateAwsCredentials(tempUser as any);\r\n if (!validation.success) {\r\n return makeResponse({ ctr, content: { code: 400, message: `AWS validation failed: ${validation.error}` } });\r\n }\r\n\r\n await prisma.user.update({\r\n where: { id: user.id },\r\n data: {\r\n awsAccessKeyId: encrypt(awsAccessKeyId),\r\n awsSecretAccessKey: encrypt(awsSecretAccessKey),\r\n awsRegion,\r\n },\r\n });\r\n\r\n return makeResponse({ ctr, content: { code: 200, message: `Connected as ${validation.arn}`, data: { arn: validation.arn } } });\r\n}\r\n\r\nexport async function getWebhookSecret(ctr: any) {\r\n const user = requireAuth(ctr);\r\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n\r\n let token = await prisma.webhookToken.findUnique({ where: { userId: user.id } });\r\n if (!token) {\r\n const secret = randomBytes(32).toString(\"hex\");\r\n token = await prisma.webhookToken.create({ data: { userId: user.id, token: secret } });\r\n }\r\n\r\n return makeResponse({ ctr, content: { code: 200, data: { token: token.token } } });\r\n}\r\n\r\nexport async function regenerateWebhookSecret(ctr: any) {\r\n const user = requireAuth(ctr);\r\n if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n\r\n const newSecret = randomBytes(32).toString(\"hex\");\r\n await prisma.webhookToken.upsert({\r\n where: { userId: user.id },\r\n update: { token: newSecret },\r\n create: { userId: user.id, token: newSecret },\r\n });\r\n\r\n const fullUser = await prisma.user.findUnique({ where: { id: user.id } });\r\n if (!fullUser?.giteaInstanceUrl) {\r\n return makeResponse({ ctr, content: { code: 200, message: \"Secret regenerated (no Gitea hooks to update)\", data: { token: newSecret } } });\r\n }\r\n\r\n const configs = await prisma.repoConfig.findMany({\r\n where: { userId: user.id, giteaWebhookId: { not: null } },\r\n });\r\n\r\n const { updateWebhookSecret } = await import(\"../../services/gitea\");\r\n const webhookUrl = `${env.PP_BASE_URL}/webhook/${user.id}`;\r\n const errors: string[] = [];\r\n\r\n for (const config of configs) {\r\n try {\r\n await updateWebhookSecret(fullUser as any, config.repoOwner, config.repoName, config.giteaWebhookId!, webhookUrl, newSecret);\r\n } catch (e: any) {\r\n errors.push(`${config.repoOwner}/${config.repoName}: ${e.message}`);\r\n }\r\n }\r\n\r\n return makeResponse({\r\n ctr, content: {\r\n code: 200,\r\n message: errors.length ? `Secret regenerated with ${errors.length} hook update errors` : \"Secret regenerated and all hooks updated\",\r\n data: { token: newSecret, errors },\r\n }\r\n });\r\n}\r\n"], + "mappings": "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", + "names": ["bcrypt"] +} diff --git a/backend/dist/routes/auth.js b/backend/dist/routes/auth.js new file mode 100644 index 00000000..1d9e7e9e --- /dev/null +++ b/backend/dist/routes/auth.js @@ -0,0 +1,161 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var auth_exports = {}; +__export(auth_exports, { + firstUserHandler: () => firstUserHandler, + loginHandler: () => loginHandler, + logoutHandler: () => logoutHandler, + meHandler: () => meHandler, + setupStatusHandler: () => setupStatusHandler +}); +module.exports = __toCommonJS(auth_exports); +var import_crypto = require("crypto"); +var import_bcryptjs = __toESM(require("bcryptjs")); +var import_rjweb_server = require("rjweb-server"); +var import_db = require("../lib/db"); +var import_response = require("../lib/response"); +var import_errors = require("../lib/errors"); +var import_logger = require("../lib/logger"); +const log = (0, import_logger.createLogger)("AUTH_ROUTE"); +const COOKIE_NAME = "pp_session"; +const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3; +async function loginHandler(ctr) { + let body; + try { + body = await ctr.body(); + } catch { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Invalid body" } }); + } + const { username, password } = body || {}; + if (!username || !password) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Username and password required" } }); + } + const user = await import_db.prisma.user.findUnique({ where: { username } }); + if (!user) { + return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: "Invalid credentials" } }); + } + const valid = await import_bcryptjs.default.compare(password, user.passwordHash); + if (!valid) { + return (0, import_response.makeResponse)({ ctr, content: { code: 401, message: "Invalid credentials" } }); + } + const sessionHash = (0, import_crypto.randomBytes)(32).toString("hex"); + await import_db.prisma.session.create({ data: { hash: sessionHash, userId: user.id } }); + ctr.cookies.set( + COOKIE_NAME, + new import_rjweb_server.Cookie(sessionHash, { + httpOnly: true, + expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), + path: "/", + sameSite: "Lax" + }) + ); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: { id: user.id, username: user.username, isAdmin: user.isAdmin } } }); +} +async function logoutHandler(ctr) { + const auth = ctr.getAuth?.(); + if (auth?.success) { + await import_db.prisma.session.delete({ where: { id: auth.sessionId } }).catch(() => { + }); + } + ctr.cookies.set(COOKIE_NAME, new import_rjweb_server.Cookie("", { expires: /* @__PURE__ */ new Date(0), path: "/" })); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, message: "Logged out" } }); +} +async function meHandler(ctr) { + const auth = ctr.getAuth?.(); + if (!auth?.success) { + return (0, import_response.makeResponse)({ ctr, content: { code: import_errors.ERROR_MESSAGES.UNAUTHORIZED.code, message: import_errors.ERROR_MESSAGES.UNAUTHORIZED.message } }); + } + const user = await import_db.prisma.user.findUnique({ where: { id: auth.user.id } }); + if (!user) return (0, import_response.makeResponse)({ ctr, content: { code: 404, message: "User not found" } }); + return (0, import_response.makeResponse)({ + ctr, + content: { + code: 200, + data: { + id: user.id, + username: user.username, + isAdmin: user.isAdmin, + isFounder: user.isFounder, + giteaUsername: user.giteaUsername, + giteaInstanceUrl: user.giteaInstanceUrl, + giteaPatSet: !!user.giteaPAT, + awsAccessKeyId: user.awsAccessKeyId ? "****" : null, + awsRegion: user.awsRegion, + awsConfigured: !!(user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion), + setupComplete: !!(user.giteaInstanceUrl && user.giteaPAT && user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion) + } + } + }); +} +async function setupStatusHandler(ctr) { + const count = await import_db.prisma.user.count(); + return (0, import_response.makeResponse)({ ctr, content: { code: 200, data: { needsSetup: count === 0 } } }); +} +async function firstUserHandler(ctr) { + const count = await import_db.prisma.user.count(); + if (count > 0) { + return (0, import_response.makeResponse)({ ctr, content: { code: 403, message: "Setup already completed. Contact an administrator to create your account." } }); + } + let body; + try { + body = await ctr.body(); + } catch { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Invalid body" } }); + } + const { username, password } = body || {}; + if (!username || !password || password.length < 8) { + return (0, import_response.makeResponse)({ ctr, content: { code: 400, message: "Username and password (min 8 chars) required" } }); + } + const hash = await import_bcryptjs.default.hash(password, 12); + const user = await import_db.prisma.user.create({ + data: { username, passwordHash: hash, isAdmin: true, isFounder: true } + }); + const sessionHash = (0, import_crypto.randomBytes)(32).toString("hex"); + await import_db.prisma.session.create({ data: { hash: sessionHash, userId: user.id } }); + ctr.cookies.set( + COOKIE_NAME, + new import_rjweb_server.Cookie(sessionHash, { + httpOnly: true, + expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), + path: "/", + sameSite: "Lax" + }) + ); + log.info({ username }, "First user (founder) created"); + return (0, import_response.makeResponse)({ ctr, content: { code: 201, data: { id: user.id, username: user.username, isAdmin: true, isFounder: true } } }); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + firstUserHandler, + loginHandler, + logoutHandler, + meHandler, + setupStatusHandler +}); +//# sourceMappingURL=auth.js.map diff --git a/backend/dist/routes/auth.js.map b/backend/dist/routes/auth.js.map new file mode 100644 index 00000000..78020bf8 --- /dev/null +++ b/backend/dist/routes/auth.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/routes/auth.ts"], + "sourcesContent": ["import { randomBytes } from \"crypto\";\r\nimport bcrypt from \"bcryptjs\";\r\nimport { Cookie } from \"rjweb-server\";\r\nimport { prisma } from \"../lib/db\";\r\nimport { makeResponse } from \"../lib/response\";\r\nimport { ERROR_MESSAGES } from \"../lib/errors\";\r\nimport { createLogger } from \"../lib/logger\";\r\n\r\nconst log = createLogger(\"AUTH_ROUTE\");\r\nconst COOKIE_NAME = \"pp_session\";\r\nconst COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;\r\n\r\nexport async function loginHandler(ctr: any) {\r\n let body: any;\r\n try {\r\n body = await ctr.body();\r\n } catch {\r\n return makeResponse({ ctr, content: { code: 400, message: \"Invalid body\" } });\r\n }\r\n\r\n const { username, password } = body || {};\r\n if (!username || !password) {\r\n return makeResponse({ ctr, content: { code: 400, message: \"Username and password required\" } });\r\n }\r\n\r\n const user = await prisma.user.findUnique({ where: { username } });\r\n if (!user) {\r\n return makeResponse({ ctr, content: { code: 401, message: \"Invalid credentials\" } });\r\n }\r\n\r\n const valid = await bcrypt.compare(password, user.passwordHash);\r\n if (!valid) {\r\n return makeResponse({ ctr, content: { code: 401, message: \"Invalid credentials\" } });\r\n }\r\n\r\n const sessionHash = randomBytes(32).toString(\"hex\");\r\n await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });\r\n\r\n ctr.cookies.set(\r\n COOKIE_NAME,\r\n new Cookie(sessionHash, {\r\n httpOnly: true,\r\n expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),\r\n path: \"/\",\r\n sameSite: \"Lax\",\r\n }),\r\n );\r\n\r\n return makeResponse({ ctr, content: { code: 200, data: { id: user.id, username: user.username, isAdmin: user.isAdmin } } });\r\n}\r\n\r\nexport async function logoutHandler(ctr: any) {\r\n const auth = ctr.getAuth?.();\r\n if (auth?.success) {\r\n await prisma.session.delete({ where: { id: auth.sessionId } }).catch(() => {});\r\n }\r\n ctr.cookies.set(COOKIE_NAME, new Cookie(\"\", { expires: new Date(0), path: \"/\" }));\r\n return makeResponse({ ctr, content: { code: 200, message: \"Logged out\" } });\r\n}\r\n\r\nexport async function meHandler(ctr: any) {\r\n const auth = ctr.getAuth?.();\r\n if (!auth?.success) {\r\n return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } });\r\n }\r\n\r\n const user = await prisma.user.findUnique({ where: { id: auth.user.id } });\r\n if (!user) return makeResponse({ ctr, content: { code: 404, message: \"User not found\" } });\r\n\r\n return makeResponse({\r\n ctr,\r\n content: {\r\n code: 200,\r\n data: {\r\n id: user.id,\r\n username: user.username,\r\n isAdmin: user.isAdmin,\r\n isFounder: user.isFounder,\r\n giteaUsername: user.giteaUsername,\r\n giteaInstanceUrl: user.giteaInstanceUrl,\r\n giteaPatSet: !!user.giteaPAT,\r\n awsAccessKeyId: user.awsAccessKeyId ? \"****\" : null,\r\n awsRegion: user.awsRegion,\r\n awsConfigured: !!(user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion),\r\n setupComplete: !!(user.giteaInstanceUrl && user.giteaPAT && user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion),\r\n },\r\n },\r\n });\r\n}\r\n\r\nexport async function setupStatusHandler(ctr: any) {\r\n const count = await prisma.user.count();\r\n return makeResponse({ ctr, content: { code: 200, data: { needsSetup: count === 0 } } });\r\n}\r\n\r\nexport async function firstUserHandler(ctr: any) {\r\n const count = await prisma.user.count();\r\n if (count > 0) {\r\n return makeResponse({ ctr, content: { code: 403, message: \"Setup already completed. Contact an administrator to create your account.\" } });\r\n }\r\n\r\n let body: any;\r\n try {\r\n body = await ctr.body();\r\n } catch {\r\n return makeResponse({ ctr, content: { code: 400, message: \"Invalid body\" } });\r\n }\r\n\r\n const { username, password } = body || {};\r\n if (!username || !password || password.length < 8) {\r\n return makeResponse({ ctr, content: { code: 400, message: \"Username and password (min 8 chars) required\" } });\r\n }\r\n\r\n const hash = await bcrypt.hash(password, 12);\r\n const user = await prisma.user.create({\r\n data: { username, passwordHash: hash, isAdmin: true, isFounder: true },\r\n });\r\n\r\n const sessionHash = randomBytes(32).toString(\"hex\");\r\n await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });\r\n\r\n ctr.cookies.set(\r\n COOKIE_NAME,\r\n new Cookie(sessionHash, {\r\n httpOnly: true,\r\n expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),\r\n path: \"/\",\r\n sameSite: \"Lax\",\r\n }),\r\n );\r\n\r\n log.info({ username }, \"First user (founder) created\");\r\n return makeResponse({ ctr, content: { code: 201, data: { id: user.id, username: user.username, isAdmin: true, isFounder: true } } });\r\n}\r\n"], + "mappings": "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", + "names": ["bcrypt"] +} diff --git a/backend/dist/routes/webhook.js b/backend/dist/routes/webhook.js new file mode 100644 index 00000000..beaefba7 --- /dev/null +++ b/backend/dist/routes/webhook.js @@ -0,0 +1,279 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var webhook_exports = {}; +__export(webhook_exports, { + webhookHandler: () => webhookHandler +}); +module.exports = __toCommonJS(webhook_exports); +var import_crypto = require("crypto"); +var import_db = require("../lib/db"); +var import_logger = require("../lib/logger"); +var import_adminSettings = require("../lib/adminSettings"); +var import_gitea = require("../services/gitea"); +var import_env = require("../lib/env"); +const log = (0, import_logger.createLogger)("WEBHOOK"); +const rateLimitMap = /* @__PURE__ */ new Map(); +function checkRateLimit(userId, limitPerMin) { + const now = Date.now(); + const entry = rateLimitMap.get(userId); + if (!entry || now > entry.resetAt) { + rateLimitMap.set(userId, { count: 1, resetAt: now + 6e4 }); + return true; + } + if (entry.count >= limitPerMin) return false; + entry.count++; + return true; +} +async function webhookHandler(ctr) { + const userId = parseInt(ctr.params.get("userId") || "0", 10); + if (!userId) return ctr.status(400).print({ status: "FAILED", message: "Invalid user ID" }); + const settings = await (0, import_adminSettings.getAdminSettings)(); + if (!checkRateLimit(userId, settings.webhookRateLimitPerMinute)) { + return ctr.status(429).print({ status: "FAILED", message: "Rate limit exceeded" }); + } + const user = await import_db.prisma.user.findUnique({ where: { id: userId } }); + if (!user) return ctr.status(404).print({ status: "FAILED", message: "User not found" }); + const webhookToken = await import_db.prisma.webhookToken.findUnique({ where: { userId } }); + if (!webhookToken) return ctr.status(401).print({ status: "FAILED", message: "No webhook token configured" }); + const signature = ctr.headers.get("x-gitea-signature-256") || ctr.headers.get("x-hub-signature-256") || ""; + const rawBody = await ctr.$body().text(); + const expected = "sha256=" + (0, import_crypto.createHmac)("sha256", webhookToken.token).update(rawBody).digest("hex"); + if (!timingSafeEqual(signature, expected)) { + log.warn({ userId, signature }, "Invalid webhook signature"); + return ctr.status(401).print({ status: "FAILED", message: "Invalid signature" }); + } + let payload; + try { + payload = JSON.parse(rawBody); + } catch { + return ctr.status(400).print({ status: "FAILED", message: "Invalid JSON payload" }); + } + const event = ctr.headers.get("x-gitea-event") || ""; + ctr.status(200).print({ status: "OK" }); + setImmediate(() => handleWebhookAsync(user, event, payload).catch((e) => log.error({ e }, "Webhook processing error"))); +} +async function handleWebhookAsync(user, event, payload) { + if (event === "pull_request") { + await handlePullRequestEvent(user, payload); + } else if (event === "issue_comment") { + await handleIssueCommentEvent(user, payload); + } +} +async function handlePullRequestEvent(user, payload) { + const action = payload.action; + const pr = payload.pull_request; + const repo = payload.repository; + if (!pr || !repo) return; + const owner = repo.owner?.login || repo.full_name?.split("/")[0]; + const repoName = repo.name; + const prNumber = pr.number; + const prTitle = pr.title || `PR #${prNumber}`; + const commitSha = pr.head?.sha || ""; + const cloneUrl = pr.head?.repo?.clone_url || repo.clone_url; + const repoConfig = await import_db.prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true } + }); + if (!repoConfig) { + const existing = await import_db.prisma.noConfigComment.findUnique({ + where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } } + }); + if (!existing) { + const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${import_env.env.PP_BASE_URL}).`; + try { + await (0, import_gitea.postComment)(user, owner, repoName, prNumber, body); + await import_db.prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } }); + } catch { + } + } + return; + } + if (repoConfig.denyList.includes(pr.user?.login || "")) return; + if (action === "opened" || action === "reopened") { + let preview = await import_db.prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" } + }); + if (preview && preview.status === "IGNORED") return; + if (!preview || preview.status === "STOPPED") { + preview = await import_db.prisma.preview.create({ + data: { + repoConfigId: repoConfig.id, + prNumber, + prTitle, + commitSha, + status: "PROVISIONING", + port: repoConfig.port + } + }); + await import_db.prisma.job.create({ + data: { + previewId: preview.id, + type: "DEPLOY", + status: "PENDING", + payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: true } + } + }); + } + } else if (action === "synchronize") { + const preview = await import_db.prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" } + }); + if (!preview || preview.status === "IGNORED") return; + await import_db.prisma.preview.update({ where: { id: preview.id }, data: { lastActivityAt: /* @__PURE__ */ new Date() } }); + await import_db.prisma.job.create({ + data: { + previewId: preview.id, + type: "DEPLOY", + status: "PENDING", + payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: preview.status === "STOPPED" } + } + }); + } else if (action === "closed") { + const preview = await import_db.prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" } + }); + if (preview && preview.status !== "STOPPED" && preview.status !== "IGNORED") { + await import_db.prisma.job.create({ + data: { + previewId: preview.id, + type: "STOP", + status: "PENDING", + payload: { reason: "PR closed" } + } + }); + } + } +} +async function handleIssueCommentEvent(user, payload) { + const action = payload.action; + if (action !== "created") return; + const comment = payload.comment; + const issue = payload.issue; + const repo = payload.repository; + if (!comment || !issue || !repo || !issue.pull_request) return; + const body = comment.body || ""; + if (!body.trimStart().startsWith("/pp ")) return; + const owner = repo.owner?.login || repo.full_name?.split("/")[0]; + const repoName = repo.name; + const prNumber = issue.number; + if (user.giteaUsername && comment.user?.login === user.giteaUsername) return; + const repoConfig = await import_db.prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true } + }); + if (!repoConfig) return; + const commenter = comment.user?.login || ""; + const prAuthor = issue.user?.login || ""; + const isAllowed = commenter === prAuthor || await isRepoAdmin(user, owner, repoName, commenter); + if (!isAllowed) return; + await import_db.prisma.preview.updateMany({ + where: { + repoConfigId: repoConfig.id, + prNumber, + status: { in: ["RUNNING", "BUILDING", "FAILED", "PROVISIONING"] } + }, + data: { lastActivityAt: /* @__PURE__ */ new Date() } + }); + const commandLine = body.trimStart().split("\n")[0].trim(); + const command = commandLine.replace("/pp ", "").trim(); + const preview = await import_db.prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" } + }); + const cloneUrl = repo.clone_url; + if (command === "rebuild") { + if (!preview || preview.status !== "RUNNING" && preview.status !== "FAILED") return; + await import_db.prisma.job.create({ + data: { + previewId: preview.id, + type: "DEPLOY", + status: "PENDING", + payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false } + } + }); + } else if (command === "stop") { + if (!preview || preview.status === "STOPPED") return; + await import_db.prisma.job.create({ + data: { previewId: preview.id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop" } } + }); + } else if (command === "start") { + if (!preview) { + const newPreview = await import_db.prisma.preview.create({ + data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "PROVISIONING", port: repoConfig.port } + }); + await import_db.prisma.job.create({ + data: { previewId: newPreview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: "", prNumber, prTitle: issue.title, cloneUrl, isFirstDeploy: true } } + }); + } else if (preview.status === "STOPPED" || preview.status === "IGNORED") { + await import_db.prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING" } }); + await import_db.prisma.job.create({ + data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } } + }); + } + } else if (command === "logs") { + if (!preview) return; + const lastLines = (preview.logs || "").split("\n").slice(-50).join("\n"); + const logBody = `**PP Logs** (last 50 lines) +\`\`\` +${lastLines} +\`\`\``; + await (0, import_gitea.postComment)(user, owner, repoName, prNumber, logBody); + } else if (command === "ignore") { + if (!preview) { + await import_db.prisma.preview.create({ + data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "IGNORED", port: repoConfig.port } + }); + } else { + await import_db.prisma.preview.update({ where: { id: preview.id }, data: { status: "IGNORED" } }); + } + } +} +async function isRepoAdmin(user, owner, repo, username) { + try { + const { getRepoCollaboratorPermission } = await import("../services/gitea"); + const permission = await getRepoCollaboratorPermission(user, owner, repo, username); + return permission === "owner" || permission === "admin"; + } catch { + return false; + } +} +function timingSafeEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + webhookHandler +}); +//# sourceMappingURL=webhook.js.map diff --git a/backend/dist/routes/webhook.js.map b/backend/dist/routes/webhook.js.map new file mode 100644 index 00000000..eefd33aa --- /dev/null +++ b/backend/dist/routes/webhook.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/routes/webhook.ts"], + "sourcesContent": ["import { createHmac } from \"crypto\";\nimport { prisma } from \"../lib/db\";\nimport { createLogger } from \"../lib/logger\";\nimport { getAdminSettings } from \"../lib/adminSettings\";\nimport { buildPrCommentBody, postComment } from \"../services/gitea\";\nimport { env } from \"../lib/env\";\n\nconst log = createLogger(\"WEBHOOK\");\n\nconst rateLimitMap = new Map();\n\nfunction checkRateLimit(userId: number, limitPerMin: number): boolean {\n const now = Date.now();\n const entry = rateLimitMap.get(userId);\n if (!entry || now > entry.resetAt) {\n rateLimitMap.set(userId, { count: 1, resetAt: now + 60_000 });\n return true;\n }\n if (entry.count >= limitPerMin) return false;\n entry.count++;\n return true;\n}\n\nexport async function webhookHandler(ctr: any) {\n const userId = parseInt(ctr.params.get(\"userId\") || \"0\", 10);\n if (!userId) return ctr.status(400).print({ status: \"FAILED\", message: \"Invalid user ID\" });\n\n const settings = await getAdminSettings();\n\n if (!checkRateLimit(userId, settings.webhookRateLimitPerMinute)) {\n return ctr.status(429).print({ status: \"FAILED\", message: \"Rate limit exceeded\" });\n }\n\n const user = await prisma.user.findUnique({ where: { id: userId } });\n if (!user) return ctr.status(404).print({ status: \"FAILED\", message: \"User not found\" });\n\n const webhookToken = await prisma.webhookToken.findUnique({ where: { userId } });\n if (!webhookToken) return ctr.status(401).print({ status: \"FAILED\", message: \"No webhook token configured\" });\n\n const signature = ctr.headers.get(\"x-gitea-signature-256\") || ctr.headers.get(\"x-hub-signature-256\") || \"\";\n const rawBody = await ctr.$body().text();\n\n const expected = \"sha256=\" + createHmac(\"sha256\", webhookToken.token).update(rawBody).digest(\"hex\");\n if (!timingSafeEqual(signature, expected)) {\n log.warn({ userId, signature }, \"Invalid webhook signature\");\n return ctr.status(401).print({ status: \"FAILED\", message: \"Invalid signature\" });\n }\n\n let payload: any;\n try {\n payload = JSON.parse(rawBody);\n } catch {\n return ctr.status(400).print({ status: \"FAILED\", message: \"Invalid JSON payload\" });\n }\n\n const event = ctr.headers.get(\"x-gitea-event\") || \"\";\n\n ctr.status(200).print({ status: \"OK\" });\n\n setImmediate(() => handleWebhookAsync(user, event, payload).catch(e => log.error({ e }, \"Webhook processing error\")));\n}\n\nasync function handleWebhookAsync(user: any, event: string, payload: any) {\n if (event === \"pull_request\") {\n await handlePullRequestEvent(user, payload);\n } else if (event === \"issue_comment\") {\n await handleIssueCommentEvent(user, payload);\n }\n}\n\nasync function handlePullRequestEvent(user: any, payload: any) {\n const action = payload.action;\n const pr = payload.pull_request;\n const repo = payload.repository;\n if (!pr || !repo) return;\n\n const owner = repo.owner?.login || repo.full_name?.split(\"/\")[0];\n const repoName = repo.name;\n const prNumber = pr.number;\n const prTitle = pr.title || `PR #${prNumber}`;\n const commitSha = pr.head?.sha || \"\";\n const cloneUrl = pr.head?.repo?.clone_url || repo.clone_url;\n\n const repoConfig = await prisma.repoConfig.findFirst({\n where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },\n });\n\n if (!repoConfig) {\n const existing = await prisma.noConfigComment.findUnique({\n where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } },\n });\n if (!existing) {\n const body = `No previews configured for \\`${owner}/${repoName}\\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`;\n try {\n await postComment(user, owner, repoName, prNumber, body);\n await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } });\n } catch {}\n }\n return;\n }\n\n if (repoConfig.denyList.includes(pr.user?.login || \"\")) return;\n\n if (action === \"opened\" || action === \"reopened\") {\n let preview = await prisma.preview.findFirst({\n where: { repoConfigId: repoConfig.id, prNumber },\n orderBy: { createdAt: \"desc\" },\n });\n\n if (preview && preview.status === \"IGNORED\") return;\n\n if (!preview || preview.status === \"STOPPED\") {\n preview = await prisma.preview.create({\n data: {\n repoConfigId: repoConfig.id,\n prNumber,\n prTitle,\n commitSha,\n status: \"PROVISIONING\",\n port: repoConfig.port,\n },\n });\n\n await prisma.job.create({\n data: {\n previewId: preview.id,\n type: \"DEPLOY\",\n status: \"PENDING\",\n payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: true },\n },\n });\n }\n } else if (action === \"synchronize\") {\n const preview = await prisma.preview.findFirst({\n where: { repoConfigId: repoConfig.id, prNumber },\n orderBy: { createdAt: \"desc\" },\n });\n\n if (!preview || preview.status === \"IGNORED\") return;\n\n await prisma.preview.update({ where: { id: preview.id }, data: { lastActivityAt: new Date() } });\n\n await prisma.job.create({\n data: {\n previewId: preview.id,\n type: \"DEPLOY\",\n status: \"PENDING\",\n payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: preview.status === \"STOPPED\" },\n },\n });\n } else if (action === \"closed\") {\n const preview = await prisma.preview.findFirst({\n where: { repoConfigId: repoConfig.id, prNumber },\n orderBy: { createdAt: \"desc\" },\n });\n\n if (preview && preview.status !== \"STOPPED\" && preview.status !== \"IGNORED\") {\n await prisma.job.create({\n data: {\n previewId: preview.id,\n type: \"STOP\",\n status: \"PENDING\",\n payload: { reason: \"PR closed\" },\n },\n });\n }\n }\n}\n\nasync function handleIssueCommentEvent(user: any, payload: any) {\n const action = payload.action;\n if (action !== \"created\") return;\n\n const comment = payload.comment;\n const issue = payload.issue;\n const repo = payload.repository;\n if (!comment || !issue || !repo || !issue.pull_request) return;\n\n const body = comment.body || \"\";\n if (!body.trimStart().startsWith(\"/pp \")) return;\n\n const owner = repo.owner?.login || repo.full_name?.split(\"/\")[0];\n const repoName = repo.name;\n const prNumber = issue.number;\n\n if (user.giteaUsername && comment.user?.login === user.giteaUsername) return;\n\n const repoConfig = await prisma.repoConfig.findFirst({\n where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },\n });\n if (!repoConfig) return;\n\n const commenter = comment.user?.login || \"\";\n const prAuthor = issue.user?.login || \"\";\n const isAllowed = commenter === prAuthor || (await isRepoAdmin(user, owner, repoName, commenter));\n if (!isAllowed) return;\n\n await prisma.preview.updateMany({\n where: {\n repoConfigId: repoConfig.id,\n prNumber,\n status: { in: [\"RUNNING\", \"BUILDING\", \"FAILED\", \"PROVISIONING\"] },\n },\n data: { lastActivityAt: new Date() },\n });\n\n const commandLine = body.trimStart().split(\"\\n\")[0].trim();\n const command = commandLine.replace(\"/pp \", \"\").trim();\n\n const preview = await prisma.preview.findFirst({\n where: { repoConfigId: repoConfig.id, prNumber },\n orderBy: { createdAt: \"desc\" },\n });\n\n const cloneUrl = repo.clone_url;\n\n if (command === \"rebuild\") {\n if (!preview || (preview.status !== \"RUNNING\" && preview.status !== \"FAILED\")) return;\n await prisma.job.create({\n data: {\n previewId: preview.id,\n type: \"DEPLOY\",\n status: \"PENDING\",\n payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false },\n },\n });\n } else if (command === \"stop\") {\n if (!preview || preview.status === \"STOPPED\") return;\n await prisma.job.create({\n data: { previewId: preview.id, type: \"STOP\", status: \"PENDING\", payload: { reason: \"Manual stop\" } },\n });\n } else if (command === \"start\") {\n if (!preview) {\n const newPreview = await prisma.preview.create({\n data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: \"\", status: \"PROVISIONING\", port: repoConfig.port },\n });\n await prisma.job.create({\n data: { previewId: newPreview.id, type: \"DEPLOY\", status: \"PENDING\", payload: { commitSha: \"\", prNumber, prTitle: issue.title, cloneUrl, isFirstDeploy: true } },\n });\n } else if (preview.status === \"STOPPED\" || preview.status === \"IGNORED\") {\n await prisma.preview.update({ where: { id: preview.id }, data: { status: \"PROVISIONING\" } });\n await prisma.job.create({\n data: { previewId: preview.id, type: \"DEPLOY\", status: \"PENDING\", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } },\n });\n }\n } else if (command === \"logs\") {\n if (!preview) return;\n const lastLines = (preview.logs || \"\").split(\"\\n\").slice(-50).join(\"\\n\");\n const logBody = `**PP Logs** (last 50 lines)\\n\\`\\`\\`\\n${lastLines}\\n\\`\\`\\``;\n await postComment(user, owner, repoName, prNumber, logBody);\n } else if (command === \"ignore\") {\n if (!preview) {\n await prisma.preview.create({\n data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: \"\", status: \"IGNORED\", port: repoConfig.port },\n });\n } else {\n await prisma.preview.update({ where: { id: preview.id }, data: { status: \"IGNORED\" } });\n }\n }\n}\n\nasync function isRepoAdmin(user: any, owner: string, repo: string, username: string): Promise {\n try {\n const { getRepoCollaboratorPermission } = await import(\"../services/gitea\");\n const permission = await getRepoCollaboratorPermission(user, owner, repo, username);\n return permission === \"owner\" || permission === \"admin\";\n } catch {\n return false;\n }\n}\n\nfunction timingSafeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n return diff === 0;\n}\n"], + "mappings": "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", + "names": [] +} diff --git a/backend/dist/services/deploy.js b/backend/dist/services/deploy.js new file mode 100644 index 00000000..0344d3c5 --- /dev/null +++ b/backend/dist/services/deploy.js @@ -0,0 +1,440 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var deploy_exports = {}; +__export(deploy_exports, { + abortJobForPreview: () => abortJobForPreview, + appendLog: () => appendLog, + runDeploy: () => runDeploy, + signalAbort: () => signalAbort, + stopPreview: () => stopPreview, + subscribeToLogs: () => subscribeToLogs +}); +module.exports = __toCommonJS(deploy_exports); +var import_db = require("../lib/db"); +var import_logger = require("../lib/logger"); +var import_encryption = require("../lib/encryption"); +var import_adminSettings = require("../lib/adminSettings"); +var import_env = require("../lib/env"); +var import_ec2 = require("./ec2"); +var import_ssh = require("./ssh"); +var import_gitea = require("./gitea"); +const log = (0, import_logger.createLogger)("DEPLOY"); +const activeSshSessions = /* @__PURE__ */ new Map(); +function abortJobForPreview(previewId) { + const session = activeSshSessions.get(previewId); + if (session) { + session.abort(); + activeSshSessions.delete(previewId); + } +} +async function appendLog(previewId, text) { + const settings = await (0, import_adminSettings.getAdminSettings)(); + const preview = await import_db.prisma.preview.findUnique({ where: { id: previewId } }); + if (!preview) return; + let logs = (preview.logs || "") + text; + if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) { + const marker = "--- logs truncated ---\n"; + while (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) { + const nl = logs.indexOf("\n"); + if (nl === -1) break; + logs = logs.slice(nl + 1); + } + logs = marker + logs; + } + await import_db.prisma.preview.update({ where: { id: previewId }, data: { logs } }); + broadcastLogUpdate(previewId, text); +} +const logSubscribers = /* @__PURE__ */ new Map(); +function subscribeToLogs(previewId, cb) { + if (!logSubscribers.has(previewId)) logSubscribers.set(previewId, /* @__PURE__ */ new Set()); + logSubscribers.get(previewId).add(cb); + return () => logSubscribers.get(previewId)?.delete(cb); +} +function broadcastLogUpdate(previewId, text) { + logSubscribers.get(previewId)?.forEach((cb) => cb(text)); +} +async function updateStatus(previewId, status, extra = {}) { + await import_db.prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } }); +} +async function updateGiteaComment(user, preview, repoConfig, statusLine, lastLogLines) { + const body = (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber: preview.prNumber, + status: statusLine, + commitSha: preview.commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL, + lastLogLines, + instanceIp: preview.instanceIp ?? void 0, + port: preview.port + }); + if (preview.giteaCommentId) { + try { + await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body); + } catch (e) { + log.warn({ e }, "Failed to update Gitea comment"); + } + } +} +async function runDeploy(jobId) { + const job = await import_db.prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } }); + if (!job || !job.preview) { + log.error({ jobId }, "Job or preview not found"); + return; + } + await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } }); + const preview = job.preview; + const repoConfig = preview.repoConfig; + const user = preview.repoConfig.user; + const payload = job.payload; + const { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy } = payload; + try { + if (isFirstDeploy) { + await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl); + } else { + await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle); + } + await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date() } }); + } catch (e) { + if (e.message === "ABORTED") { + log.info({ jobId, previewId: preview.id }, "Job aborted"); + await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: "Aborted by newer deploy", finishedAt: /* @__PURE__ */ new Date() } }); + return; + } + log.error({ e, jobId, previewId: preview.id }, "Deploy failed"); + await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: e.message, finishedAt: /* @__PURE__ */ new Date() } }); + const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: preview.id } }); + if (!freshPreview) return; + const lastLines = (freshPreview.logs || "").split("\n").slice(-10).join("\n"); + await updateStatus(preview.id, "FAILED"); + const failBody = (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber: freshPreview.prNumber, + status: `\u{1F534} Failed \u2014 last log lines: +\`\`\` +${lastLines} +\`\`\``, + commitSha: freshPreview.commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }); + if (freshPreview.giteaCommentId) { + try { + await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, freshPreview.giteaCommentId, failBody); + } catch { + } + } + } +} +async function firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl) { + const settings = await (0, import_adminSettings.getAdminSettings)(); + const ec2 = (0, import_ec2.makeEc2Client)(user); + const previewId = preview.id; + const activeCount = await import_db.prisma.preview.count({ + where: { + repoConfig: { userId: user.id }, + status: { in: ["PROVISIONING", "BUILDING", "RUNNING"] }, + id: { not: previewId } + } + }); + if (activeCount >= settings.maxConcurrentInstancesPerUser) { + const body = (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber, + status: `\u{1F534} Cannot provision preview \u2014 concurrent instance limit (${settings.maxConcurrentInstancesPerUser}) reached.`, + commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }); + const commentId2 = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, body); + await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId2 } }); + throw new Error("Concurrent instance limit reached"); + } + await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle }); + const commentBody = (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber, + status: "\u{1F7E1} Provisioning EC2 instance...", + commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }); + let commentId = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody); + await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } }); + checkAbort(previewId); + const keyName = `pp-preview-${previewId}`; + const { privateKey } = await (0, import_ec2.generateAndImportKeyPair)(ec2, keyName); + const encPrivateKey = (0, import_encryption.encrypt)(privateKey); + await import_db.prisma.preview.update({ where: { id: previewId }, data: { sshPrivateKey: encPrivateKey, sshKeyName: keyName } }); + checkAbort(previewId); + const sgName = `pp-preview-${previewId}`; + const securityGroupId = await (0, import_ec2.createPreviewSecurityGroup)(ec2, sgName, repoConfig.port); + checkAbort(previewId); + const instanceId = await (0, import_ec2.launchInstance)({ + ec2, + region: user.awsRegion, + instanceType: repoConfig.instanceType, + keyName, + securityGroupId, + tags: { + "pp:managed": "true", + "pp:userId": String(user.id), + "pp:repo": `${repoConfig.repoOwner}/${repoConfig.repoName}`, + "pp:prNumber": String(prNumber), + "pp:previewId": String(previewId) + } + }); + await import_db.prisma.preview.update({ where: { id: previewId }, data: { instanceId } }); + await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running... +`); + checkAbort(previewId); + const instanceIp = await (0, import_ec2.waitForInstanceRunning)(ec2, instanceId); + await import_db.prisma.preview.update({ where: { id: previewId }, data: { instanceIp, port: repoConfig.port } }); + await (0, import_gitea.updateComment)( + user, + repoConfig.repoOwner, + repoConfig.repoName, + commentId, + (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber, + status: `\u{1F7E1} Building... (EC2 ready at ${instanceIp})`, + commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }) + ); + await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH... +`); + checkAbort(previewId); + const sshSession = await (0, import_ssh.connectSsh)(instanceIp, privateKey, 3e5); + activeSshSessions.set(previewId, sshSession); + try { + if (repoConfig.aptPackages.length > 0) { + await runSshStep(previewId, sshSession, `sudo apt-get install -y ${repoConfig.aptPackages.join(" ")}`); + } + const giteaPat = user.giteaPAT ? (0, import_encryption.decrypt)(user.giteaPAT) : ""; + const authCloneUrl = cloneUrl.replace("https://", `https://${user.giteaUsername}:${giteaPat}@`); + await runSshStep(previewId, sshSession, `git clone ${authCloneUrl} /opt/app`); + await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`); + await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true); + await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: /* @__PURE__ */ new Date() }); + const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: previewId } }); + await (0, import_gitea.updateComment)( + user, + repoConfig.repoOwner, + repoConfig.repoName, + commentId, + (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber, + status: `\u{1F7E2} Live at http://${instanceIp}:${repoConfig.port}`, + commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }) + ); + } finally { + sshSession.close(); + activeSshSessions.delete(previewId); + } +} +async function redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle) { + const previewId = preview.id; + const instanceIp = preview.instanceIp; + const privateKey = (0, import_encryption.decrypt)(preview.sshPrivateKey); + const sshSession = await (0, import_ssh.connectSsh)(instanceIp, privateKey, 3e4); + activeSshSessions.set(previewId, sshSession); + await appendLog(previewId, ` +--- Redeploy: ${commitSha} --- +`); + const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: previewId } }); + const commentBody = (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber, + status: `\u{1F7E1} Building... (EC2 at ${instanceIp})`, + commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }); + const newCommentId = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody); + await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: newCommentId, commitSha } }); + try { + if (repoConfig.useDockerCompose) { + const composePath = repoConfig.composeFilePath || "docker-compose.yml"; + await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`); + } else if (freshPreview?.pid) { + await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`); + } + await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`); + await updateStatus(previewId, "BUILDING"); + await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false); + await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: /* @__PURE__ */ new Date() }); + await (0, import_gitea.updateComment)( + user, + repoConfig.repoOwner, + repoConfig.repoName, + newCommentId, + (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber, + status: `\u{1F7E2} Live at http://${instanceIp}:${repoConfig.port}`, + commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }) + ); + } finally { + sshSession.close(); + activeSshSessions.delete(previewId); + } +} +async function setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, isFirstProvision) { + await detectAndUseNode(previewId, sshSession); + const envVars = repoConfig.envVars; + const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n"); + await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF' +${envContent} +PPEOF`); + if (isFirstProvision) { + for (const cmd of repoConfig.setupCommands) { + await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); + } + } + await updateStatus(previewId, "BUILDING"); + if (repoConfig.useDockerCompose) { + const composePath = repoConfig.composeFilePath || "docker-compose.yml"; + await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`); + } else { + for (const cmd of repoConfig.buildCommands) { + await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); + } + for (const cmd of repoConfig.postBuildCommands) { + await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); + } + if (repoConfig.runCommand) { + const res = await runSshStep( + previewId, + sshSession, + `cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!` + ); + const pid = parseInt(res.stdout.trim(), 10); + if (!isNaN(pid)) { + await import_db.prisma.preview.update({ where: { id: previewId }, data: { pid } }); + } + } + } +} +async function detectAndUseNode(previewId, sshSession) { + const nvmSource = `export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"`; + const res = await runSshStep(previewId, sshSession, `${nvmSource} && [ -f /opt/app/.nvmrc ] && nvm install && nvm use || nvm use default 2>&1`, false); +} +async function runSshStep(previewId, sshSession, command, throwOnFail = true) { + checkAbortSession(sshSession); + await appendLog(previewId, `$ ${command} +`); + const res = await sshSession.exec(command); + if (res.stdout) await appendLog(previewId, res.stdout); + if (res.stderr) await appendLog(previewId, res.stderr); + if (throwOnFail && res.code !== 0) { + throw new Error(`Command failed with exit code ${res.code}: ${command}`); + } + return res; +} +function checkAbortSession(session) { + if (session.aborted) throw new Error("ABORTED"); +} +const abortSignals = /* @__PURE__ */ new Set(); +function signalAbort(previewId) { + abortSignals.add(previewId); + abortJobForPreview(previewId); +} +function checkAbort(previewId) { + if (abortSignals.has(previewId)) { + abortSignals.delete(previewId); + throw new Error("ABORTED"); + } +} +async function stopPreview(previewId, reason = "STOPPED") { + const preview = await import_db.prisma.preview.findUnique({ + where: { id: previewId }, + include: { repoConfig: { include: { user: true } } } + }); + if (!preview) return; + const user = preview.repoConfig.user; + const repoConfig = preview.repoConfig; + if (preview.instanceId) { + const ec2 = (0, import_ec2.makeEc2Client)(user); + try { + await (0, import_ec2.deleteKeyPairAws)(ec2, `pp-preview-${previewId}`); + } catch { + } + try { + await (0, import_ec2.deleteSecurityGroupAws)(ec2, `pp-preview-${previewId}`); + } catch { + } + try { + await (0, import_ec2.terminateInstance)(ec2, preview.instanceId); + } catch { + } + } + await import_db.prisma.preview.update({ + where: { id: previewId }, + data: { + status: reason, + stoppedAt: /* @__PURE__ */ new Date(), + sshPrivateKey: null, + sshKeyName: null, + instanceId: null + } + }); + const stoppedBody = (0, import_gitea.buildPrCommentBody)({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber: preview.prNumber, + status: "\u26AB Stopped (inactivity timeout / PR closed / manual stop)", + commitSha: preview.commitSha, + updatedAt: /* @__PURE__ */ new Date(), + ppBaseUrl: import_env.env.PP_BASE_URL + }); + if (preview.giteaCommentId) { + try { + await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, stoppedBody); + } catch { + } + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + abortJobForPreview, + appendLog, + runDeploy, + signalAbort, + stopPreview, + subscribeToLogs +}); +//# sourceMappingURL=deploy.js.map diff --git a/backend/dist/services/deploy.js.map b/backend/dist/services/deploy.js.map new file mode 100644 index 00000000..8afa4cb7 --- /dev/null +++ b/backend/dist/services/deploy.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/services/deploy.ts"], + "sourcesContent": ["import { prisma } from \"../lib/db\";\nimport { createLogger } from \"../lib/logger\";\nimport { decrypt, encrypt } from \"../lib/encryption\";\nimport { getAdminSettings } from \"../lib/adminSettings\";\nimport { env } from \"../lib/env\";\nimport {\n makeEc2Client,\n generateAndImportKeyPair,\n createPreviewSecurityGroup,\n launchInstance,\n waitForInstanceRunning,\n terminateInstance,\n deleteKeyPairAws,\n deleteSecurityGroupAws,\n} from \"./ec2\";\nimport { connectSsh, type SshSession } from \"./ssh\";\nimport {\n buildPrCommentBody,\n postComment,\n updateComment,\n} from \"./gitea\";\nimport type { Preview, RepoConfig, User } from \"@prisma/client\";\n\nconst log = createLogger(\"DEPLOY\");\n\nconst activeSshSessions = new Map();\n\nexport function abortJobForPreview(previewId: number) {\n const session = activeSshSessions.get(previewId);\n if (session) {\n session.abort();\n activeSshSessions.delete(previewId);\n }\n}\n\nexport async function appendLog(previewId: number, text: string) {\n const settings = await getAdminSettings();\n const preview = await prisma.preview.findUnique({ where: { id: previewId } });\n if (!preview) return;\n\n let logs = (preview.logs || \"\") + text;\n if (Buffer.byteLength(logs, \"utf8\") > settings.logSizeLimitBytes) {\n const marker = \"--- logs truncated ---\\n\";\n while (Buffer.byteLength(logs, \"utf8\") > settings.logSizeLimitBytes) {\n const nl = logs.indexOf(\"\\n\");\n if (nl === -1) break;\n logs = logs.slice(nl + 1);\n }\n logs = marker + logs;\n }\n\n await prisma.preview.update({ where: { id: previewId }, data: { logs } });\n\n broadcastLogUpdate(previewId, text);\n}\n\nconst logSubscribers = new Map void>>();\n\nexport function subscribeToLogs(previewId: number, cb: (text: string) => void): () => void {\n if (!logSubscribers.has(previewId)) logSubscribers.set(previewId, new Set());\n logSubscribers.get(previewId)!.add(cb);\n return () => logSubscribers.get(previewId)?.delete(cb);\n}\n\nfunction broadcastLogUpdate(previewId: number, text: string) {\n logSubscribers.get(previewId)?.forEach(cb => cb(text));\n}\n\nasync function updateStatus(previewId: number, status: Preview[\"status\"], extra: Partial = {}) {\n await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });\n}\n\nasync function updateGiteaComment(user: User, preview: Preview, repoConfig: RepoConfig, statusLine: string, lastLogLines?: string) {\n const body = buildPrCommentBody({\n owner: repoConfig.repoOwner,\n repo: repoConfig.repoName,\n prNumber: preview.prNumber,\n status: statusLine,\n commitSha: preview.commitSha,\n updatedAt: new Date(),\n ppBaseUrl: env.PP_BASE_URL,\n lastLogLines,\n instanceIp: preview.instanceIp ?? undefined,\n port: preview.port,\n });\n\n if (preview.giteaCommentId) {\n try {\n await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body);\n } catch (e) {\n log.warn({ e }, \"Failed to update Gitea comment\");\n }\n }\n}\n\nexport async function runDeploy(jobId: number) {\n const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });\n if (!job || !job.preview) {\n log.error({ jobId }, \"Job or preview not found\");\n return;\n }\n\n await prisma.job.update({ where: { id: jobId }, data: { status: \"RUNNING\", startedAt: new Date() } });\n\n const preview = job.preview as any;\n const repoConfig: RepoConfig = preview.repoConfig;\n const user: User = (preview.repoConfig as any).user;\n\n const payload = job.payload as any;\n const { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy } = payload;\n\n try {\n if (isFirstDeploy) {\n await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl);\n } else {\n await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle);\n }\n\n await prisma.job.update({ where: { id: jobId }, data: { status: \"DONE\", finishedAt: new Date() } });\n } catch (e: any) {\n if (e.message === \"ABORTED\") {\n log.info({ jobId, previewId: preview.id }, \"Job aborted\");\n await prisma.job.update({ where: { id: jobId }, data: { status: \"FAILED\", error: \"Aborted by newer deploy\", finishedAt: new Date() } });\n return;\n }\n log.error({ e, jobId, previewId: preview.id }, \"Deploy failed\");\n await prisma.job.update({ where: { id: jobId }, data: { status: \"FAILED\", error: e.message, finishedAt: new Date() } });\n\n const freshPreview = await prisma.preview.findUnique({ where: { id: preview.id } });\n if (!freshPreview) return;\n\n const lastLines = (freshPreview.logs || \"\").split(\"\\n\").slice(-10).join(\"\\n\");\n await updateStatus(preview.id, \"FAILED\");\n\n const failBody = buildPrCommentBody({\n owner: repoConfig.repoOwner,\n repo: repoConfig.repoName,\n prNumber: freshPreview.prNumber,\n status: `\uD83D\uDD34 Failed \u2014 last log lines:\\n\\`\\`\\`\\n${lastLines}\\n\\`\\`\\``,\n commitSha: freshPreview.commitSha,\n updatedAt: new Date(),\n ppBaseUrl: env.PP_BASE_URL,\n });\n\n if (freshPreview.giteaCommentId) {\n try {\n await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, freshPreview.giteaCommentId, failBody);\n } catch {}\n }\n }\n}\n\nasync function firstDeploy(\n jobId: number,\n preview: Preview & { repoConfig: RepoConfig },\n repoConfig: RepoConfig,\n user: User,\n commitSha: string,\n prNumber: number,\n prTitle: string,\n cloneUrl: string,\n) {\n const settings = await getAdminSettings();\n const ec2 = makeEc2Client(user);\n const previewId = preview.id;\n\n const activeCount = await prisma.preview.count({\n where: {\n repoConfig: { userId: user.id },\n status: { in: [\"PROVISIONING\", \"BUILDING\", \"RUNNING\"] },\n id: { not: previewId },\n },\n });\n\n if (activeCount >= settings.maxConcurrentInstancesPerUser) {\n const body = buildPrCommentBody({\n owner: repoConfig.repoOwner,\n repo: repoConfig.repoName,\n prNumber,\n status: `\uD83D\uDD34 Cannot provision preview \u2014 concurrent instance limit (${settings.maxConcurrentInstancesPerUser}) reached.`,\n commitSha,\n updatedAt: new Date(),\n ppBaseUrl: env.PP_BASE_URL,\n });\n const commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, body);\n await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });\n throw new Error(\"Concurrent instance limit reached\");\n }\n\n await updateStatus(previewId, \"PROVISIONING\", { commitSha, prNumber, prTitle });\n\n const commentBody = buildPrCommentBody({\n owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,\n status: \"\uD83D\uDFE1 Provisioning EC2 instance...\",\n commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,\n });\n let commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);\n await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });\n\n checkAbort(previewId);\n\n const keyName = `pp-preview-${previewId}`;\n const { privateKey } = await generateAndImportKeyPair(ec2, keyName);\n const encPrivateKey = encrypt(privateKey);\n\n await prisma.preview.update({ where: { id: previewId }, data: { sshPrivateKey: encPrivateKey, sshKeyName: keyName } });\n\n checkAbort(previewId);\n\n const sgName = `pp-preview-${previewId}`;\n const securityGroupId = await createPreviewSecurityGroup(ec2, sgName, repoConfig.port);\n\n checkAbort(previewId);\n\n const instanceId = await launchInstance({\n ec2, region: user.awsRegion!,\n instanceType: repoConfig.instanceType,\n keyName,\n securityGroupId,\n tags: {\n \"pp:managed\": \"true\",\n \"pp:userId\": String(user.id),\n \"pp:repo\": `${repoConfig.repoOwner}/${repoConfig.repoName}`,\n \"pp:prNumber\": String(prNumber),\n \"pp:previewId\": String(previewId),\n },\n });\n\n await prisma.preview.update({ where: { id: previewId }, data: { instanceId } });\n\n await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\\n`);\n\n checkAbort(previewId);\n\n const instanceIp = await waitForInstanceRunning(ec2, instanceId);\n await prisma.preview.update({ where: { id: previewId }, data: { instanceIp, port: repoConfig.port } });\n\n await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,\n buildPrCommentBody({\n owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,\n status: `\uD83D\uDFE1 Building... (EC2 ready at ${instanceIp})`,\n commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,\n })\n );\n\n await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...\\n`);\n\n checkAbort(previewId);\n\n const sshSession = await connectSsh(instanceIp, privateKey, 300_000);\n activeSshSessions.set(previewId, sshSession);\n\n try {\n if (repoConfig.aptPackages.length > 0) {\n await runSshStep(previewId, sshSession, `sudo apt-get install -y ${repoConfig.aptPackages.join(\" \")}`);\n }\n\n const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : \"\";\n const authCloneUrl = cloneUrl.replace(\"https://\", `https://${user.giteaUsername}:${giteaPat}@`);\n await runSshStep(previewId, sshSession, `git clone ${authCloneUrl} /opt/app`);\n await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);\n\n await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true);\n\n await updateStatus(previewId, \"RUNNING\", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() });\n\n const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });\n await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,\n buildPrCommentBody({\n owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,\n status: `\uD83D\uDFE2 Live at http://${instanceIp}:${repoConfig.port}`,\n commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,\n })\n );\n } finally {\n sshSession.close();\n activeSshSessions.delete(previewId);\n }\n}\n\nasync function redeploy(\n jobId: number,\n preview: Preview & { repoConfig: RepoConfig },\n repoConfig: RepoConfig,\n user: User,\n commitSha: string,\n prNumber: number,\n prTitle: string,\n) {\n const previewId = preview.id;\n const instanceIp = preview.instanceIp!;\n const privateKey = decrypt(preview.sshPrivateKey!);\n\n const sshSession = await connectSsh(instanceIp, privateKey, 30_000);\n activeSshSessions.set(previewId, sshSession);\n\n await appendLog(previewId, `\\n--- Redeploy: ${commitSha} ---\\n`);\n\n const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });\n\n const commentBody = buildPrCommentBody({\n owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,\n status: `\uD83D\uDFE1 Building... (EC2 at ${instanceIp})`,\n commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,\n });\n const newCommentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);\n await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: newCommentId, commitSha } });\n\n try {\n if (repoConfig.useDockerCompose) {\n const composePath = repoConfig.composeFilePath || \"docker-compose.yml\";\n await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`);\n } else if (freshPreview?.pid) {\n await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`);\n }\n\n await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`);\n\n await updateStatus(previewId, \"BUILDING\");\n await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false);\n\n await updateStatus(previewId, \"RUNNING\", { commitSha, lastActivityAt: new Date() });\n\n await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, newCommentId,\n buildPrCommentBody({\n owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,\n status: `\uD83D\uDFE2 Live at http://${instanceIp}:${repoConfig.port}`,\n commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,\n })\n );\n } finally {\n sshSession.close();\n activeSshSessions.delete(previewId);\n }\n}\n\nasync function setupAndBuild(\n previewId: number,\n sshSession: SshSession,\n repoConfig: RepoConfig,\n preview: Preview,\n commitSha: string,\n isFirstProvision: boolean,\n) {\n await detectAndUseNode(previewId, sshSession);\n\n const envVars = repoConfig.envVars as Record;\n const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join(\"\\n\");\n await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF'\\n${envContent}\\nPPEOF`);\n\n if (isFirstProvision) {\n for (const cmd of repoConfig.setupCommands) {\n await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);\n }\n }\n\n await updateStatus(previewId, \"BUILDING\");\n\n if (repoConfig.useDockerCompose) {\n const composePath = repoConfig.composeFilePath || \"docker-compose.yml\";\n await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`);\n } else {\n for (const cmd of repoConfig.buildCommands) {\n await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);\n }\n for (const cmd of repoConfig.postBuildCommands) {\n await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);\n }\n\n if (repoConfig.runCommand) {\n const res = await runSshStep(previewId, sshSession,\n `cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!`\n );\n const pid = parseInt(res.stdout.trim(), 10);\n if (!isNaN(pid)) {\n await prisma.preview.update({ where: { id: previewId }, data: { pid } });\n }\n }\n }\n}\n\nasync function detectAndUseNode(previewId: number, sshSession: SshSession) {\n const nvmSource = `export NVM_DIR=\"/root/.nvm\" && source \"$NVM_DIR/nvm.sh\"`;\n const res = await runSshStep(previewId, sshSession, `${nvmSource} && [ -f /opt/app/.nvmrc ] && nvm install && nvm use || nvm use default 2>&1`, false);\n}\n\nasync function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) {\n checkAbortSession(sshSession);\n await appendLog(previewId, `$ ${command}\\n`);\n const res = await sshSession.exec(command);\n if (res.stdout) await appendLog(previewId, res.stdout);\n if (res.stderr) await appendLog(previewId, res.stderr);\n if (throwOnFail && res.code !== 0) {\n throw new Error(`Command failed with exit code ${res.code}: ${command}`);\n }\n return res;\n}\n\nfunction checkAbortSession(session: SshSession) {\n if (session.aborted) throw new Error(\"ABORTED\");\n}\n\nconst abortSignals = new Set();\n\nexport function signalAbort(previewId: number) {\n abortSignals.add(previewId);\n abortJobForPreview(previewId);\n}\n\nfunction checkAbort(previewId: number) {\n if (abortSignals.has(previewId)) {\n abortSignals.delete(previewId);\n throw new Error(\"ABORTED\");\n }\n}\n\nexport async function stopPreview(previewId: number, reason: \"STOPPED\" | \"FAILED\" = \"STOPPED\") {\n const preview = await prisma.preview.findUnique({\n where: { id: previewId },\n include: { repoConfig: { include: { user: true } } },\n });\n if (!preview) return;\n\n const user = (preview.repoConfig as any).user as User;\n const repoConfig = preview.repoConfig;\n\n if (preview.instanceId) {\n const ec2 = makeEc2Client(user);\n try {\n await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);\n } catch {}\n try {\n await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);\n } catch {}\n try {\n await terminateInstance(ec2, preview.instanceId);\n } catch {}\n }\n\n await prisma.preview.update({\n where: { id: previewId },\n data: {\n status: reason,\n stoppedAt: new Date(),\n sshPrivateKey: null,\n sshKeyName: null,\n instanceId: null,\n },\n });\n\n const stoppedBody = buildPrCommentBody({\n owner: repoConfig.repoOwner,\n repo: repoConfig.repoName,\n prNumber: preview.prNumber,\n status: \"\u26AB Stopped (inactivity timeout / PR closed / manual stop)\",\n commitSha: preview.commitSha,\n updatedAt: new Date(),\n ppBaseUrl: env.PP_BASE_URL,\n });\n\n if (preview.giteaCommentId) {\n try {\n await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, stoppedBody);\n } catch {}\n }\n}\n"], + "mappings": "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", + "names": ["commentId"] +} diff --git a/backend/dist/services/ec2.js b/backend/dist/services/ec2.js new file mode 100644 index 00000000..8f36e96e --- /dev/null +++ b/backend/dist/services/ec2.js @@ -0,0 +1,249 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var ec2_exports = {}; +__export(ec2_exports, { + createPreviewSecurityGroup: () => createPreviewSecurityGroup, + deleteKeyPairAws: () => deleteKeyPairAws, + deleteSecurityGroupAws: () => deleteSecurityGroupAws, + describeAllManagedInstances: () => describeAllManagedInstances, + generateAndImportKeyPair: () => generateAndImportKeyPair, + generateSshKeyPair: () => generateSshKeyPair, + launchInstance: () => launchInstance, + makeEc2Client: () => makeEc2Client, + makeStsClient: () => makeStsClient, + terminateInstance: () => terminateInstance, + validateAwsCredentials: () => validateAwsCredentials, + waitForInstanceRunning: () => waitForInstanceRunning +}); +module.exports = __toCommonJS(ec2_exports); +var import_client_ec2 = require("@aws-sdk/client-ec2"); +var import_client_sts = require("@aws-sdk/client-sts"); +var import_crypto = require("crypto"); +var import_logger = require("../lib/logger"); +var import_encryption = require("../lib/encryption"); +const log = (0, import_logger.createLogger)("EC2"); +const UBUNTU_22_04_AMI = { + "us-east-1": "ami-0e86e20dae9224db8", + "us-east-2": "ami-0a0d9cf81c479446a", + "us-west-1": "ami-05c969369880fa2c2", + "us-west-2": "ami-03f8acd418785369b", + "eu-west-1": "ami-0694d931cee176e7d", + "eu-west-2": "ami-0f3d9639a5674d559", + "eu-west-3": "ami-022e307f4b9e39f45", + "eu-central-1": "ami-0faab6bdbac9486fb", + "ap-southeast-1": "ami-0823c236601fef765", + "ap-southeast-2": "ami-07620139298af599e", + "ap-northeast-1": "ami-0b7546e839d7ace12", + "ap-northeast-2": "ami-042e76978adeb8c48", + "ap-south-1": "ami-076e3a557efe1aa9c", + "sa-east-1": "ami-0eed58016fbe42de3", + "ca-central-1": "ami-024f768de9e73d4f4", + "eu-north-1": "ami-00381a880aa48c6c6", + "me-south-1": "ami-09574f34b8dcd2eac", + "af-south-1": "ami-08fdcf06b39fe83ec" +}; +function makeEc2Client(user) { + const accessKeyId = user.awsAccessKeyId ? (0, import_encryption.decrypt)(user.awsAccessKeyId) : ""; + const secretAccessKey = user.awsSecretAccessKey ? (0, import_encryption.decrypt)(user.awsSecretAccessKey) : ""; + return new import_client_ec2.EC2Client({ + region: user.awsRegion, + credentials: { accessKeyId, secretAccessKey } + }); +} +function makeStsClient(user) { + const accessKeyId = user.awsAccessKeyId ? (0, import_encryption.decrypt)(user.awsAccessKeyId) : ""; + const secretAccessKey = user.awsSecretAccessKey ? (0, import_encryption.decrypt)(user.awsSecretAccessKey) : ""; + return new import_client_sts.STSClient({ + region: user.awsRegion, + credentials: { accessKeyId, secretAccessKey } + }); +} +async function validateAwsCredentials(user) { + try { + const sts = makeStsClient(user); + const res = await sts.send(new import_client_sts.GetCallerIdentityCommand({})); + return { success: true, arn: res.Arn }; + } catch (e) { + return { success: false, error: e.message }; + } +} +function generateSshKeyPair() { + const { privateKey, publicKey } = (0, import_crypto.generateKeyPairSync)("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" } + }); + const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey); + return { privateKey, publicKey: pubKeyOpenSsh }; +} +function rsaPemToOpenSsh(pem) { + const { publicKeyEncoding } = (0, import_crypto.generateKeyPairSync)("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs8", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }); + void publicKeyEncoding; + const der = Buffer.from( + pem.replace(/-----BEGIN RSA PUBLIC KEY-----/, "").replace(/-----END RSA PUBLIC KEY-----/, "").replace(/\n/g, ""), + "base64" + ); + const type = Buffer.from("ssh-rsa"); + function encodeBuffer(buf) { + const len = Buffer.allocUnsafe(4); + len.writeUInt32BE(buf.length, 0); + return Buffer.concat([len, buf]); + } + const typeEncoded = encodeBuffer(type); + const rsaKeyData = der; + const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64"); + return `ssh-rsa ${base64Key} pp-generated`; +} +async function generateAndImportKeyPair(ec2, keyName) { + const { privateKey, publicKey } = generateSshKeyPair(); + await ec2.send(new import_client_ec2.ImportKeyPairCommand({ + KeyName: keyName, + PublicKeyMaterial: Buffer.from(publicKey) + })); + return { privateKey }; +} +async function createPreviewSecurityGroup(ec2, groupName, port) { + const describe = await ec2.send(new import_client_ec2.DescribeSecurityGroupsCommand({ + Filters: [{ Name: "group-name", Values: [groupName] }] + })); + if (describe.SecurityGroups && describe.SecurityGroups.length > 0) { + return describe.SecurityGroups[0].GroupId; + } + const res = await ec2.send(new import_client_ec2.CreateSecurityGroupCommand({ + GroupName: groupName, + Description: `PP Preview security group: ${groupName}` + })); + const groupId = res.GroupId; + await ec2.send(new import_client_ec2.AuthorizeSecurityGroupIngressCommand({ + GroupId: groupId, + IpPermissions: [ + { + IpProtocol: "tcp", + FromPort: 22, + ToPort: 22, + IpRanges: [{ CidrIp: "0.0.0.0/0" }] + }, + { + IpProtocol: "tcp", + FromPort: port, + ToPort: port, + IpRanges: [{ CidrIp: "0.0.0.0/0" }] + } + ] + })); + return groupId; +} +const BOOTSTRAP_SCRIPT = `#!/bin/bash +set -e +apt-get update -y +apt-get install -y curl git unzip build-essential +curl -fsSL https://get.docker.com | sh +systemctl enable docker +systemctl start docker +apt-get install -y docker-compose-plugin +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +export NVM_DIR="/root/.nvm" +source "$NVM_DIR/nvm.sh" +nvm install --lts +nvm alias default lts/* +`; +async function launchInstance(opts) { + const ami = UBUNTU_22_04_AMI[opts.region] ?? UBUNTU_22_04_AMI["us-east-1"]; + const tagSpecs = Object.entries(opts.tags).map(([k, v]) => ({ Key: k, Value: v })); + tagSpecs.push({ Key: "Name", Value: `pp-preview-${opts.tags["pp:previewId"]}` }); + const res = await opts.ec2.send(new import_client_ec2.RunInstancesCommand({ + ImageId: ami, + InstanceType: opts.instanceType, + MinCount: 1, + MaxCount: 1, + KeyName: opts.keyName, + SecurityGroupIds: [opts.securityGroupId], + UserData: Buffer.from(BOOTSTRAP_SCRIPT).toString("base64"), + TagSpecifications: [ + { ResourceType: "instance", Tags: tagSpecs } + ] + })); + return res.Instances[0].InstanceId; +} +async function waitForInstanceRunning(ec2, instanceId, maxWaitMs = 3e5) { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + const res = await ec2.send(new import_client_ec2.DescribeInstancesCommand({ + InstanceIds: [instanceId] + })); + const inst = res.Reservations?.[0]?.Instances?.[0]; + if (inst?.State?.Name === "running" && inst.PublicIpAddress) { + return inst.PublicIpAddress; + } + await sleep(5e3); + } + throw new Error(`Instance ${instanceId} did not reach running state within timeout`); +} +async function terminateInstance(ec2, instanceId) { + await ec2.send(new import_client_ec2.TerminateInstancesCommand({ InstanceIds: [instanceId] })); +} +async function deleteKeyPairAws(ec2, keyName) { + try { + await ec2.send(new import_client_ec2.DeleteKeyPairCommand({ KeyName: keyName })); + } catch (e) { + log.warn({ e, keyName }, "Failed to delete key pair"); + } +} +async function deleteSecurityGroupAws(ec2, groupName) { + try { + const describe = await ec2.send(new import_client_ec2.DescribeSecurityGroupsCommand({ + Filters: [{ Name: "group-name", Values: [groupName] }] + })); + const groupId = describe.SecurityGroups?.[0]?.GroupId; + if (groupId) { + await ec2.send(new import_client_ec2.DeleteSecurityGroupCommand({ GroupId: groupId })); + } + } catch (e) { + log.warn({ e, groupName }, "Failed to delete security group"); + } +} +async function describeAllManagedInstances(ec2) { + const res = await ec2.send(new import_client_ec2.DescribeInstancesCommand({ + Filters: [{ Name: "tag:pp:managed", Values: ["true"] }, { Name: "instance-state-name", Values: ["running", "pending", "stopping", "stopped"] }] + })); + return (res.Reservations ?? []).flatMap((r) => r.Instances ?? []); +} +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + createPreviewSecurityGroup, + deleteKeyPairAws, + deleteSecurityGroupAws, + describeAllManagedInstances, + generateAndImportKeyPair, + generateSshKeyPair, + launchInstance, + makeEc2Client, + makeStsClient, + terminateInstance, + validateAwsCredentials, + waitForInstanceRunning +}); +//# sourceMappingURL=ec2.js.map diff --git a/backend/dist/services/ec2.js.map b/backend/dist/services/ec2.js.map new file mode 100644 index 00000000..81efd815 --- /dev/null +++ b/backend/dist/services/ec2.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/services/ec2.ts"], + "sourcesContent": ["import {\n EC2Client,\n RunInstancesCommand,\n TerminateInstancesCommand,\n DescribeInstancesCommand,\n CreateSecurityGroupCommand,\n DeleteSecurityGroupCommand,\n AuthorizeSecurityGroupIngressCommand,\n DescribeSecurityGroupsCommand,\n ImportKeyPairCommand,\n DeleteKeyPairCommand,\n CreateTagsCommand,\n} from \"@aws-sdk/client-ec2\";\nimport { STSClient, GetCallerIdentityCommand } from \"@aws-sdk/client-sts\";\nimport { generateKeyPairSync } from \"crypto\";\nimport { createLogger } from \"../lib/logger\";\nimport { decrypt } from \"../lib/encryption\";\nimport type { User } from \"@prisma/client\";\n\nconst log = createLogger(\"EC2\");\n\nconst UBUNTU_22_04_AMI: Record = {\n \"us-east-1\": \"ami-0e86e20dae9224db8\",\n \"us-east-2\": \"ami-0a0d9cf81c479446a\",\n \"us-west-1\": \"ami-05c969369880fa2c2\",\n \"us-west-2\": \"ami-03f8acd418785369b\",\n \"eu-west-1\": \"ami-0694d931cee176e7d\",\n \"eu-west-2\": \"ami-0f3d9639a5674d559\",\n \"eu-west-3\": \"ami-022e307f4b9e39f45\",\n \"eu-central-1\": \"ami-0faab6bdbac9486fb\",\n \"ap-southeast-1\": \"ami-0823c236601fef765\",\n \"ap-southeast-2\": \"ami-07620139298af599e\",\n \"ap-northeast-1\": \"ami-0b7546e839d7ace12\",\n \"ap-northeast-2\": \"ami-042e76978adeb8c48\",\n \"ap-south-1\": \"ami-076e3a557efe1aa9c\",\n \"sa-east-1\": \"ami-0eed58016fbe42de3\",\n \"ca-central-1\": \"ami-024f768de9e73d4f4\",\n \"eu-north-1\": \"ami-00381a880aa48c6c6\",\n \"me-south-1\": \"ami-09574f34b8dcd2eac\",\n \"af-south-1\": \"ami-08fdcf06b39fe83ec\",\n};\n\nexport function makeEc2Client(user: User): EC2Client {\n const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : \"\";\n const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : \"\";\n return new EC2Client({\n region: user.awsRegion!,\n credentials: { accessKeyId, secretAccessKey },\n });\n}\n\nexport function makeStsClient(user: User): STSClient {\n const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : \"\";\n const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : \"\";\n return new STSClient({\n region: user.awsRegion!,\n credentials: { accessKeyId, secretAccessKey },\n });\n}\n\nexport async function validateAwsCredentials(user: User): Promise<{ success: boolean; arn?: string; error?: string }> {\n try {\n const sts = makeStsClient(user);\n const res = await sts.send(new GetCallerIdentityCommand({}));\n return { success: true, arn: res.Arn };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n}\n\nexport function generateSshKeyPair(): { privateKey: string; publicKey: string } {\n const { privateKey, publicKey } = generateKeyPairSync(\"rsa\", {\n modulusLength: 2048,\n publicKeyEncoding: { type: \"pkcs1\", format: \"pem\" },\n privateKeyEncoding: { type: \"pkcs1\", format: \"pem\" },\n });\n const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey);\n return { privateKey, publicKey: pubKeyOpenSsh };\n}\n\nfunction rsaPemToOpenSsh(pem: string): string {\n const { publicKeyEncoding } = generateKeyPairSync(\"rsa\", {\n modulusLength: 2048,\n publicKeyEncoding: { type: \"pkcs8\", format: \"pem\" },\n privateKeyEncoding: { type: \"pkcs8\", format: \"pem\" },\n });\n void publicKeyEncoding;\n const der = Buffer.from(\n pem.replace(/-----BEGIN RSA PUBLIC KEY-----/, \"\")\n .replace(/-----END RSA PUBLIC KEY-----/, \"\")\n .replace(/\\n/g, \"\"),\n \"base64\"\n );\n const type = Buffer.from(\"ssh-rsa\");\n function encodeBuffer(buf: Buffer): Buffer {\n const len = Buffer.allocUnsafe(4);\n len.writeUInt32BE(buf.length, 0);\n return Buffer.concat([len, buf]);\n }\n const typeEncoded = encodeBuffer(type);\n const rsaKeyData = der;\n const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString(\"base64\");\n return `ssh-rsa ${base64Key} pp-generated`;\n}\n\nexport async function generateAndImportKeyPair(ec2: EC2Client, keyName: string): Promise<{ privateKey: string }> {\n const { privateKey, publicKey } = generateSshKeyPair();\n await ec2.send(new ImportKeyPairCommand({\n KeyName: keyName,\n PublicKeyMaterial: Buffer.from(publicKey),\n }));\n return { privateKey };\n}\n\nexport async function createPreviewSecurityGroup(ec2: EC2Client, groupName: string, port: number): Promise {\n const describe = await ec2.send(new DescribeSecurityGroupsCommand({\n Filters: [{ Name: \"group-name\", Values: [groupName] }],\n }));\n if (describe.SecurityGroups && describe.SecurityGroups.length > 0) {\n return describe.SecurityGroups[0].GroupId!;\n }\n\n const res = await ec2.send(new CreateSecurityGroupCommand({\n GroupName: groupName,\n Description: `PP Preview security group: ${groupName}`,\n }));\n const groupId = res.GroupId!;\n\n await ec2.send(new AuthorizeSecurityGroupIngressCommand({\n GroupId: groupId,\n IpPermissions: [\n {\n IpProtocol: \"tcp\",\n FromPort: 22,\n ToPort: 22,\n IpRanges: [{ CidrIp: \"0.0.0.0/0\" }],\n },\n {\n IpProtocol: \"tcp\",\n FromPort: port,\n ToPort: port,\n IpRanges: [{ CidrIp: \"0.0.0.0/0\" }],\n },\n ],\n }));\n return groupId;\n}\n\nconst BOOTSTRAP_SCRIPT = `#!/bin/bash\nset -e\napt-get update -y\napt-get install -y curl git unzip build-essential\ncurl -fsSL https://get.docker.com | sh\nsystemctl enable docker\nsystemctl start docker\napt-get install -y docker-compose-plugin\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash\nexport NVM_DIR=\"/root/.nvm\"\nsource \"$NVM_DIR/nvm.sh\"\nnvm install --lts\nnvm alias default lts/*\n`;\n\nexport async function launchInstance(opts: {\n ec2: EC2Client;\n region: string;\n instanceType: string;\n keyName: string;\n securityGroupId: string;\n tags: Record;\n}): Promise {\n const ami = UBUNTU_22_04_AMI[opts.region] ?? UBUNTU_22_04_AMI[\"us-east-1\"];\n const tagSpecs = Object.entries(opts.tags).map(([k, v]) => ({ Key: k, Value: v }));\n tagSpecs.push({ Key: \"Name\", Value: `pp-preview-${opts.tags[\"pp:previewId\"]}` });\n\n const res = await opts.ec2.send(new RunInstancesCommand({\n ImageId: ami,\n InstanceType: opts.instanceType as any,\n MinCount: 1,\n MaxCount: 1,\n KeyName: opts.keyName,\n SecurityGroupIds: [opts.securityGroupId],\n UserData: Buffer.from(BOOTSTRAP_SCRIPT).toString(\"base64\"),\n TagSpecifications: [\n { ResourceType: \"instance\", Tags: tagSpecs },\n ],\n }));\n\n return res.Instances![0].InstanceId!;\n}\n\nexport async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise {\n const start = Date.now();\n while (Date.now() - start < maxWaitMs) {\n const res = await ec2.send(new DescribeInstancesCommand({\n InstanceIds: [instanceId],\n }));\n const inst = res.Reservations?.[0]?.Instances?.[0];\n if (inst?.State?.Name === \"running\" && inst.PublicIpAddress) {\n return inst.PublicIpAddress;\n }\n await sleep(5000);\n }\n throw new Error(`Instance ${instanceId} did not reach running state within timeout`);\n}\n\nexport async function terminateInstance(ec2: EC2Client, instanceId: string): Promise {\n await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] }));\n}\n\nexport async function deleteKeyPairAws(ec2: EC2Client, keyName: string): Promise {\n try {\n await ec2.send(new DeleteKeyPairCommand({ KeyName: keyName }));\n } catch (e) {\n log.warn({ e, keyName }, \"Failed to delete key pair\");\n }\n}\n\nexport async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string): Promise {\n try {\n const describe = await ec2.send(new DescribeSecurityGroupsCommand({\n Filters: [{ Name: \"group-name\", Values: [groupName] }],\n }));\n const groupId = describe.SecurityGroups?.[0]?.GroupId;\n if (groupId) {\n await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));\n }\n } catch (e) {\n log.warn({ e, groupName }, \"Failed to delete security group\");\n }\n}\n\nexport async function describeAllManagedInstances(ec2: EC2Client): Promise {\n const res = await ec2.send(new DescribeInstancesCommand({\n Filters: [{ Name: \"tag:pp:managed\", Values: [\"true\"] }, { Name: \"instance-state-name\", Values: [\"running\", \"pending\", \"stopping\", \"stopped\"] }],\n }));\n return (res.Reservations ?? []).flatMap(r => r.Instances ?? []);\n}\n\nfunction sleep(ms: number) {\n return new Promise(r => setTimeout(r, ms));\n}\n"], + "mappings": "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", + "names": [] +} diff --git a/backend/dist/services/gitea.js b/backend/dist/services/gitea.js new file mode 100644 index 00000000..b32e182d --- /dev/null +++ b/backend/dist/services/gitea.js @@ -0,0 +1,170 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var gitea_exports = {}; +__export(gitea_exports, { + buildPrCommentBody: () => buildPrCommentBody, + checkUserPermission: () => checkUserPermission, + deleteWebhook: () => deleteWebhook, + fetchUserRepos: () => fetchUserRepos, + getRepoCollaboratorPermission: () => getRepoCollaboratorPermission, + giteaApi: () => giteaApi, + postComment: () => postComment, + registerWebhook: () => registerWebhook, + updateComment: () => updateComment, + updateWebhookSecret: () => updateWebhookSecret, + validateGiteaUrl: () => validateGiteaUrl +}); +module.exports = __toCommonJS(gitea_exports); +var import_axios = __toESM(require("axios")); +var import_encryption = require("../lib/encryption"); +function giteaApi(user) { + const pat = user.giteaPAT ? (0, import_encryption.decrypt)(user.giteaPAT) : ""; + return import_axios.default.create({ + baseURL: `${user.giteaInstanceUrl}/api/v1`, + headers: { + Authorization: `token ${pat}`, + "Content-Type": "application/json" + }, + timeout: 15e3 + }); +} +async function validateGiteaUrl(url) { + try { + const res = await import_axios.default.get(`${url}/api/v1/version`, { timeout: 1e4 }); + return { success: true, version: res.data.version }; + } catch (e) { + return { success: false, error: e.message }; + } +} +async function fetchUserRepos(user) { + const api = giteaApi(user); + const repos = []; + let page = 1; + while (true) { + const res = await api.get(`/repos/search?limit=50&page=${page}`); + const data = res.data?.data ?? []; + if (data.length === 0) break; + repos.push(...data); + if (data.length < 50) break; + page++; + } + return repos; +} +async function registerWebhook(user, owner, repo, webhookUrl, secret) { + const api = giteaApi(user); + const res = await api.post(`/repos/${owner}/${repo}/hooks`, { + type: "gitea", + config: { + url: webhookUrl, + secret, + content_type: "json" + }, + events: ["pull_request", "issue_comment"], + active: true + }); + return res.data.id; +} +async function deleteWebhook(user, owner, repo, hookId) { + const api = giteaApi(user); + await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`); +} +async function updateWebhookSecret(user, owner, repo, hookId, webhookUrl, newSecret) { + const api = giteaApi(user); + await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, { + config: { + url: webhookUrl, + secret: newSecret, + content_type: "json" + }, + events: ["pull_request", "issue_comment"], + active: true + }); +} +async function postComment(user, owner, repo, issueNumber, body) { + const api = giteaApi(user); + const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body }); + return res.data.id; +} +async function updateComment(user, owner, repo, commentId, body) { + const api = giteaApi(user); + await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body }); +} +async function checkUserPermission(user, owner, repo, username) { + try { + const api = giteaApi(user); + const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`); + return res.status === 204; + } catch { + return false; + } +} +async function getRepoCollaboratorPermission(user, owner, repo, username) { + try { + const api = giteaApi(user); + const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`); + return res.data?.permission ?? null; + } catch { + return null; + } +} +function buildPrCommentBody(opts) { + const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts; + const ts = updatedAt.toISOString().replace("T", " ").slice(0, 19) + " UTC"; + let statusLine = status; + if (lastLogLines) { + statusLine += ` + +\`\`\` +${lastLogLines} +\`\`\``; + } + return `## \u{1F680} PR Preview \u2014 \`${owner}/${repo}\` #${prNumber} + +**Status:** ${statusLine} +**Commit:** \`${commitSha.slice(0, 8)}\` +**Updated:** ${ts} + +--- +_Powered by [PR Previews](${ppBaseUrl})_`; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + buildPrCommentBody, + checkUserPermission, + deleteWebhook, + fetchUserRepos, + getRepoCollaboratorPermission, + giteaApi, + postComment, + registerWebhook, + updateComment, + updateWebhookSecret, + validateGiteaUrl +}); +//# sourceMappingURL=gitea.js.map diff --git a/backend/dist/services/gitea.js.map b/backend/dist/services/gitea.js.map new file mode 100644 index 00000000..3bb4339d --- /dev/null +++ b/backend/dist/services/gitea.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/services/gitea.ts"], + "sourcesContent": ["import axios from \"axios\";\nimport { decrypt } from \"../lib/encryption\";\nimport type { User } from \"@prisma/client\";\n\nexport function giteaApi(user: User) {\n const pat = user.giteaPAT ? decrypt(user.giteaPAT) : \"\";\n return axios.create({\n baseURL: `${user.giteaInstanceUrl}/api/v1`,\n headers: {\n Authorization: `token ${pat}`,\n \"Content-Type\": \"application/json\",\n },\n timeout: 15000,\n });\n}\n\nexport async function validateGiteaUrl(url: string): Promise<{ success: boolean; version?: string; error?: string }> {\n try {\n const res = await axios.get(`${url}/api/v1/version`, { timeout: 10000 });\n return { success: true, version: res.data.version };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n}\n\nexport async function fetchUserRepos(user: User): Promise {\n const api = giteaApi(user);\n const repos: any[] = [];\n let page = 1;\n while (true) {\n const res = await api.get(`/repos/search?limit=50&page=${page}`);\n const data = res.data?.data ?? [];\n if (data.length === 0) break;\n repos.push(...data);\n if (data.length < 50) break;\n page++;\n }\n return repos;\n}\n\nexport async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise {\n const api = giteaApi(user);\n const res = await api.post(`/repos/${owner}/${repo}/hooks`, {\n type: \"gitea\",\n config: {\n url: webhookUrl,\n secret,\n content_type: \"json\",\n },\n events: [\"pull_request\", \"issue_comment\"],\n active: true,\n });\n return res.data.id;\n}\n\nexport async function deleteWebhook(user: User, owner: string, repo: string, hookId: string): Promise {\n const api = giteaApi(user);\n await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`);\n}\n\nexport async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise {\n const api = giteaApi(user);\n await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {\n config: {\n url: webhookUrl,\n secret: newSecret,\n content_type: \"json\",\n },\n events: [\"pull_request\", \"issue_comment\"],\n active: true,\n });\n}\n\nexport async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise {\n const api = giteaApi(user);\n const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });\n return res.data.id;\n}\n\nexport async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise {\n const api = giteaApi(user);\n await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });\n}\n\nexport async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise {\n try {\n const api = giteaApi(user);\n const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`);\n return res.status === 204;\n } catch {\n return false;\n }\n}\n\nexport async function getRepoCollaboratorPermission(user: User, owner: string, repo: string, username: string): Promise {\n try {\n const api = giteaApi(user);\n const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`);\n return res.data?.permission ?? null;\n } catch {\n return null;\n }\n}\n\nexport function buildPrCommentBody(opts: {\n owner: string;\n repo: string;\n prNumber: number;\n status: string;\n commitSha: string;\n updatedAt: Date;\n ppBaseUrl: string;\n lastLogLines?: string;\n instanceIp?: string;\n port?: number;\n}): string {\n const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts;\n const ts = updatedAt.toISOString().replace(\"T\", \" \").slice(0, 19) + \" UTC\";\n\n let statusLine = status;\n if (lastLogLines) {\n statusLine += `\\n\\n\\`\\`\\`\\n${lastLogLines}\\n\\`\\`\\``;\n }\n\n return `## \uD83D\uDE80 PR Preview \u2014 \\`${owner}/${repo}\\` #${prNumber}\n\n**Status:** ${statusLine}\n**Commit:** \\`${commitSha.slice(0, 8)}\\`\n**Updated:** ${ts}\n\n---\n_Powered by [PR Previews](${ppBaseUrl})_`;\n}\n"], + "mappings": "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", + "names": ["axios"] +} diff --git a/backend/dist/services/ssh.js b/backend/dist/services/ssh.js new file mode 100644 index 00000000..62dd600e --- /dev/null +++ b/backend/dist/services/ssh.js @@ -0,0 +1,115 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var ssh_exports = {}; +__export(ssh_exports, { + connectSsh: () => connectSsh +}); +module.exports = __toCommonJS(ssh_exports); +var import_ssh2 = require("ssh2"); +var import_logger = require("../lib/logger"); +const log = (0, import_logger.createLogger)("SSH"); +async function connectSsh(host, privateKey, maxWaitMs = 3e5) { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + try { + const conn = await tryConnect(host, privateKey, 1e4); + let aborted = false; + return { + get aborted() { + return aborted; + }, + abort() { + aborted = true; + try { + conn.end(); + } catch { + } + }, + async exec(command) { + if (aborted) throw new Error("SSH session aborted"); + return execOnConn(conn, command); + }, + close() { + try { + conn.end(); + } catch { + } + } + }; + } catch (e) { + if (Date.now() - start > maxWaitMs) throw e; + log.debug({ host, error: e.message }, "SSH connect retry"); + await sleep(5e3); + } + } + throw new Error(`Could not SSH into ${host} within timeout`); +} +function tryConnect(host, privateKey, timeoutMs) { + return new Promise((resolve, reject) => { + const conn = new import_ssh2.Client(); + const timer = setTimeout(() => { + conn.end(); + reject(new Error(`SSH connection to ${host} timed out`)); + }, timeoutMs); + conn.on("ready", () => { + clearTimeout(timer); + resolve(conn); + }); + conn.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + conn.connect({ + host, + port: 22, + username: "ubuntu", + privateKey, + readyTimeout: timeoutMs, + algorithms: { + serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"] + } + }); + }); +} +function execOnConn(conn, command) { + return new Promise((resolve, reject) => { + conn.exec(command, (err, stream) => { + if (err) return reject(err); + let stdout = ""; + let stderr = ""; + stream.on("data", (d) => { + stdout += d.toString(); + }); + stream.stderr.on("data", (d) => { + stderr += d.toString(); + }); + stream.on("close", (code) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + }); + }); +} +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + connectSsh +}); +//# sourceMappingURL=ssh.js.map diff --git a/backend/dist/services/ssh.js.map b/backend/dist/services/ssh.js.map new file mode 100644 index 00000000..b56d125f --- /dev/null +++ b/backend/dist/services/ssh.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/services/ssh.ts"], + "sourcesContent": ["import { Client } from \"ssh2\";\nimport { createLogger } from \"../lib/logger\";\n\nconst log = createLogger(\"SSH\");\n\nexport interface SshSession {\n exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>;\n close(): void;\n aborted: boolean;\n abort(): void;\n}\n\nexport async function connectSsh(host: string, privateKey: string, maxWaitMs = 300_000): Promise {\n const start = Date.now();\n\n while (Date.now() - start < maxWaitMs) {\n try {\n const conn = await tryConnect(host, privateKey, 10000);\n let aborted = false;\n\n return {\n get aborted() { return aborted; },\n abort() {\n aborted = true;\n try { conn.end(); } catch {}\n },\n async exec(command: string) {\n if (aborted) throw new Error(\"SSH session aborted\");\n return execOnConn(conn, command);\n },\n close() {\n try { conn.end(); } catch {}\n },\n };\n } catch (e: any) {\n if (Date.now() - start > maxWaitMs) throw e;\n log.debug({ host, error: e.message }, \"SSH connect retry\");\n await sleep(5000);\n }\n }\n throw new Error(`Could not SSH into ${host} within timeout`);\n}\n\nfunction tryConnect(host: string, privateKey: string, timeoutMs: number): Promise {\n return new Promise((resolve, reject) => {\n const conn = new Client();\n const timer = setTimeout(() => {\n conn.end();\n reject(new Error(`SSH connection to ${host} timed out`));\n }, timeoutMs);\n\n conn.on(\"ready\", () => {\n clearTimeout(timer);\n resolve(conn);\n });\n conn.on(\"error\", (e) => {\n clearTimeout(timer);\n reject(e);\n });\n conn.connect({\n host,\n port: 22,\n username: \"ubuntu\",\n privateKey,\n readyTimeout: timeoutMs,\n algorithms: {\n serverHostKey: [\"ssh-rsa\", \"ecdsa-sha2-nistp256\", \"ecdsa-sha2-nistp384\", \"ecdsa-sha2-nistp521\"],\n },\n });\n });\n}\n\nfunction execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {\n return new Promise((resolve, reject) => {\n conn.exec(command, (err, stream) => {\n if (err) return reject(err);\n let stdout = \"\";\n let stderr = \"\";\n stream.on(\"data\", (d: Buffer) => { stdout += d.toString(); });\n stream.stderr.on(\"data\", (d: Buffer) => { stderr += d.toString(); });\n stream.on(\"close\", (code: number) => {\n resolve({ stdout, stderr, code: code ?? 0 });\n });\n });\n });\n}\n\nfunction sleep(ms: number) {\n return new Promise(r => setTimeout(r, ms));\n}\n"], + "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAuB;AACvB,oBAA6B;AAE7B,MAAM,UAAM,4BAAa,KAAK;AAS9B,eAAsB,WAAW,MAAc,YAAoB,YAAY,KAA8B;AAC3G,QAAM,QAAQ,KAAK,IAAI;AAEvB,SAAO,KAAK,IAAI,IAAI,QAAQ,WAAW;AACrC,QAAI;AACF,YAAM,OAAO,MAAM,WAAW,MAAM,YAAY,GAAK;AACrD,UAAI,UAAU;AAEd,aAAO;AAAA,QACL,IAAI,UAAU;AAAE,iBAAO;AAAA,QAAS;AAAA,QAChC,QAAQ;AACN,oBAAU;AACV,cAAI;AAAE,iBAAK,IAAI;AAAA,UAAG,QAAQ;AAAA,UAAC;AAAA,QAC7B;AAAA,QACA,MAAM,KAAK,SAAiB;AAC1B,cAAI,QAAS,OAAM,IAAI,MAAM,qBAAqB;AAClD,iBAAO,WAAW,MAAM,OAAO;AAAA,QACjC;AAAA,QACA,QAAQ;AACN,cAAI;AAAE,iBAAK,IAAI;AAAA,UAAG,QAAQ;AAAA,UAAC;AAAA,QAC7B;AAAA,MACF;AAAA,IACF,SAAS,GAAQ;AACf,UAAI,KAAK,IAAI,IAAI,QAAQ,UAAW,OAAM;AAC1C,UAAI,MAAM,EAAE,MAAM,OAAO,EAAE,QAAQ,GAAG,mBAAmB;AACzD,YAAM,MAAM,GAAI;AAAA,IAClB;AAAA,EACF;AACA,QAAM,IAAI,MAAM,sBAAsB,IAAI,iBAAiB;AAC7D;AAEA,SAAS,WAAW,MAAc,YAAoB,WAAoC;AACxF,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,OAAO,IAAI,mBAAO;AACxB,UAAM,QAAQ,WAAW,MAAM;AAC7B,WAAK,IAAI;AACT,aAAO,IAAI,MAAM,qBAAqB,IAAI,YAAY,CAAC;AAAA,IACzD,GAAG,SAAS;AAEZ,SAAK,GAAG,SAAS,MAAM;AACrB,mBAAa,KAAK;AAClB,cAAQ,IAAI;AAAA,IACd,CAAC;AACD,SAAK,GAAG,SAAS,CAAC,MAAM;AACtB,mBAAa,KAAK;AAClB,aAAO,CAAC;AAAA,IACV,CAAC;AACD,SAAK,QAAQ;AAAA,MACX;AAAA,MACA,MAAM;AAAA,MACN,UAAU;AAAA,MACV;AAAA,MACA,cAAc;AAAA,MACd,YAAY;AAAA,QACV,eAAe,CAAC,WAAW,uBAAuB,uBAAuB,qBAAqB;AAAA,MAChG;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH;AAEA,SAAS,WAAW,MAAc,SAA4E;AAC5G,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,SAAK,KAAK,SAAS,CAAC,KAAK,WAAW;AAClC,UAAI,IAAK,QAAO,OAAO,GAAG;AAC1B,UAAI,SAAS;AACb,UAAI,SAAS;AACb,aAAO,GAAG,QAAQ,CAAC,MAAc;AAAE,kBAAU,EAAE,SAAS;AAAA,MAAG,CAAC;AAC5D,aAAO,OAAO,GAAG,QAAQ,CAAC,MAAc;AAAE,kBAAU,EAAE,SAAS;AAAA,MAAG,CAAC;AACnE,aAAO,GAAG,SAAS,CAAC,SAAiB;AACnC,gBAAQ,EAAE,QAAQ,QAAQ,MAAM,QAAQ,EAAE,CAAC;AAAA,MAC7C,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AACH;AAEA,SAAS,MAAM,IAAY;AACzB,SAAO,IAAI,QAAQ,OAAK,WAAW,GAAG,EAAE,CAAC;AAC3C;", + "names": [] +} diff --git a/backend/dist/workers/cronWorker.js b/backend/dist/workers/cronWorker.js new file mode 100644 index 00000000..a44cb3b1 --- /dev/null +++ b/backend/dist/workers/cronWorker.js @@ -0,0 +1,100 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var cronWorker_exports = {}; +__export(cronWorker_exports, { + startCronWorkers: () => startCronWorkers +}); +module.exports = __toCommonJS(cronWorker_exports); +var import_node_cron = __toESM(require("node-cron")); +var import_db = require("../lib/db"); +var import_logger = require("../lib/logger"); +var import_adminSettings = require("../lib/adminSettings"); +const log = (0, import_logger.createLogger)("CRON"); +function startCronWorkers() { + import_node_cron.default.schedule("*/30 * * * *", async () => { + try { + await checkInactivity(); + } catch (e) { + log.error({ e }, "Inactivity check error"); + } + }); + import_node_cron.default.schedule("0 3 * * *", async () => { + try { + await dailyCleanup(); + } catch (e) { + log.error({ e }, "Daily cleanup error"); + } + }); + log.info("Cron workers started"); +} +async function checkInactivity() { + const settings = await (0, import_adminSettings.getAdminSettings)(); + const now = /* @__PURE__ */ new Date(); + const running = await import_db.prisma.preview.findMany({ + where: { status: "RUNNING" } + }); + for (const preview of running) { + const inactivityMs = settings.maxConcurrentInstancesPerUser; + const repoConfig = await import_db.prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } }); + if (!repoConfig) continue; + const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1e3); + if (now >= deadline) { + log.info({ previewId: preview.id }, "Preview inactive, enqueuing INACTIVITY_STOP"); + await import_db.prisma.job.create({ + data: { + previewId: preview.id, + type: "INACTIVITY_STOP", + status: "PENDING", + payload: {} + } + }); + } + } +} +async function dailyCleanup() { + const settings = await (0, import_adminSettings.getAdminSettings)(); + const cutoff = new Date(Date.now() - settings.previewRetentionDays * 86400 * 1e3); + const old = await import_db.prisma.preview.findMany({ + where: { + status: { in: ["STOPPED", "FAILED"] }, + stoppedAt: { lt: cutoff } + }, + select: { id: true } + }); + for (const { id } of old) { + await import_db.prisma.job.deleteMany({ where: { previewId: id } }); + await import_db.prisma.preview.delete({ where: { id } }); + } + if (old.length > 0) log.info({ count: old.length }, "Cleaned up old previews"); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + startCronWorkers +}); +//# sourceMappingURL=cronWorker.js.map diff --git a/backend/dist/workers/cronWorker.js.map b/backend/dist/workers/cronWorker.js.map new file mode 100644 index 00000000..104be027 --- /dev/null +++ b/backend/dist/workers/cronWorker.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/workers/cronWorker.ts"], + "sourcesContent": ["import cron from \"node-cron\";\nimport { prisma } from \"../lib/db\";\nimport { createLogger } from \"../lib/logger\";\nimport { getAdminSettings } from \"../lib/adminSettings\";\n\nconst log = createLogger(\"CRON\");\n\nexport function startCronWorkers() {\n // Inactivity check every 30 minutes\n cron.schedule(\"*/30 * * * *\", async () => {\n try {\n await checkInactivity();\n } catch (e) {\n log.error({ e }, \"Inactivity check error\");\n }\n });\n\n // Daily cleanup\n cron.schedule(\"0 3 * * *\", async () => {\n try {\n await dailyCleanup();\n } catch (e) {\n log.error({ e }, \"Daily cleanup error\");\n }\n });\n\n log.info(\"Cron workers started\");\n}\n\nasync function checkInactivity() {\n const settings = await getAdminSettings();\n const now = new Date();\n\n const running = await prisma.preview.findMany({\n where: { status: \"RUNNING\" },\n });\n\n for (const preview of running) {\n const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig\n const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } });\n if (!repoConfig) continue;\n\n const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000);\n if (now >= deadline) {\n log.info({ previewId: preview.id }, \"Preview inactive, enqueuing INACTIVITY_STOP\");\n await prisma.job.create({\n data: {\n previewId: preview.id,\n type: \"INACTIVITY_STOP\",\n status: \"PENDING\",\n payload: {},\n },\n });\n }\n }\n}\n\nasync function dailyCleanup() {\n const settings = await getAdminSettings();\n const cutoff = new Date(Date.now() - settings.previewRetentionDays * 86400 * 1000);\n\n const old = await prisma.preview.findMany({\n where: {\n status: { in: [\"STOPPED\", \"FAILED\"] },\n stoppedAt: { lt: cutoff },\n },\n select: { id: true },\n });\n\n for (const { id } of old) {\n await prisma.job.deleteMany({ where: { previewId: id } });\n await prisma.preview.delete({ where: { id } });\n }\n\n if (old.length > 0) log.info({ count: old.length }, \"Cleaned up old previews\");\n}\n"], + "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,uBAAiB;AACjB,gBAAuB;AACvB,oBAA6B;AAC7B,2BAAiC;AAEjC,MAAM,UAAM,4BAAa,MAAM;AAExB,SAAS,mBAAmB;AAEjC,mBAAAA,QAAK,SAAS,gBAAgB,YAAY;AACxC,QAAI;AACF,YAAM,gBAAgB;AAAA,IACxB,SAAS,GAAG;AACV,UAAI,MAAM,EAAE,EAAE,GAAG,wBAAwB;AAAA,IAC3C;AAAA,EACF,CAAC;AAGD,mBAAAA,QAAK,SAAS,aAAa,YAAY;AACrC,QAAI;AACF,YAAM,aAAa;AAAA,IACrB,SAAS,GAAG;AACV,UAAI,MAAM,EAAE,EAAE,GAAG,qBAAqB;AAAA,IACxC;AAAA,EACF,CAAC;AAED,MAAI,KAAK,sBAAsB;AACjC;AAEA,eAAe,kBAAkB;AAC/B,QAAM,WAAW,UAAM,uCAAiB;AACxC,QAAM,MAAM,oBAAI,KAAK;AAErB,QAAM,UAAU,MAAM,iBAAO,QAAQ,SAAS;AAAA,IAC5C,OAAO,EAAE,QAAQ,UAAU;AAAA,EAC7B,CAAC;AAED,aAAW,WAAW,SAAS;AAC7B,UAAM,eAAe,SAAS;AAC9B,UAAM,aAAa,MAAM,iBAAO,WAAW,WAAW,EAAE,OAAO,EAAE,IAAI,QAAQ,aAAa,EAAE,CAAC;AAC7F,QAAI,CAAC,WAAY;AAEjB,UAAM,WAAW,IAAI,KAAK,QAAQ,eAAe,QAAQ,IAAI,WAAW,kBAAkB,OAAO,GAAI;AACrG,QAAI,OAAO,UAAU;AACnB,UAAI,KAAK,EAAE,WAAW,QAAQ,GAAG,GAAG,6CAA6C;AACjF,YAAM,iBAAO,IAAI,OAAO;AAAA,QACtB,MAAM;AAAA,UACJ,WAAW,QAAQ;AAAA,UACnB,MAAM;AAAA,UACN,QAAQ;AAAA,UACR,SAAS,CAAC;AAAA,QACZ;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACF;AAEA,eAAe,eAAe;AAC5B,QAAM,WAAW,UAAM,uCAAiB;AACxC,QAAM,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,uBAAuB,QAAQ,GAAI;AAEjF,QAAM,MAAM,MAAM,iBAAO,QAAQ,SAAS;AAAA,IACxC,OAAO;AAAA,MACL,QAAQ,EAAE,IAAI,CAAC,WAAW,QAAQ,EAAE;AAAA,MACpC,WAAW,EAAE,IAAI,OAAO;AAAA,IAC1B;AAAA,IACA,QAAQ,EAAE,IAAI,KAAK;AAAA,EACrB,CAAC;AAED,aAAW,EAAE,GAAG,KAAK,KAAK;AACxB,UAAM,iBAAO,IAAI,WAAW,EAAE,OAAO,EAAE,WAAW,GAAG,EAAE,CAAC;AACxD,UAAM,iBAAO,QAAQ,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC;AAAA,EAC/C;AAEA,MAAI,IAAI,SAAS,EAAG,KAAI,KAAK,EAAE,OAAO,IAAI,OAAO,GAAG,yBAAyB;AAC/E;", + "names": ["cron"] +} diff --git a/backend/dist/workers/jobWorker.js b/backend/dist/workers/jobWorker.js new file mode 100644 index 00000000..2497293f --- /dev/null +++ b/backend/dist/workers/jobWorker.js @@ -0,0 +1,114 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var jobWorker_exports = {}; +__export(jobWorker_exports, { + startJobWorker: () => startJobWorker, + stopJobWorker: () => stopJobWorker +}); +module.exports = __toCommonJS(jobWorker_exports); +var import_db = require("../lib/db"); +var import_logger = require("../lib/logger"); +var import_deploy = require("../services/deploy"); +const log = (0, import_logger.createLogger)("JOB_WORKER"); +let running = false; +async function startJobWorker() { + if (running) return; + running = true; + log.info("Job worker started"); + await resetStuckJobs(); + pollLoop(); +} +async function resetStuckJobs() { + const count = await import_db.prisma.job.updateMany({ + where: { status: "RUNNING" }, + data: { status: "PENDING", startedAt: null } + }); + if (count.count > 0) log.info({ count: count.count }, "Reset stuck running jobs to PENDING"); +} +async function pollLoop() { + while (running) { + try { + await processPendingJobs(); + } catch (e) { + log.error({ e }, "Job worker poll error"); + } + await sleep(1e3); + } +} +const activePreviewJobs = /* @__PURE__ */ new Map(); +async function processPendingJobs() { + const pending = await import_db.prisma.job.findMany({ + where: { status: "PENDING" }, + orderBy: { createdAt: "asc" }, + take: 20 + }); + for (const job of pending) { + const previewId = job.previewId; + if (!previewId) continue; + if (activePreviewJobs.has(previewId)) { + const existingJobId = activePreviewJobs.get(previewId); + if (job.type === "DEPLOY") { + log.info({ previewId, newJobId: job.id, abortingJobId: existingJobId }, "New deploy cancels existing"); + (0, import_deploy.signalAbort)(previewId); + await sleep(500); + } else { + continue; + } + } + activePreviewJobs.set(previewId, job.id); + processJob(job).finally(() => { + if (activePreviewJobs.get(previewId) === job.id) { + activePreviewJobs.delete(previewId); + } + }); + } +} +async function processJob(job) { + log.info({ jobId: job.id, type: job.type, previewId: job.previewId }, "Processing job"); + try { + if (job.type === "DEPLOY") { + await (0, import_deploy.runDeploy)(job.id); + } else if (job.type === "STOP" || job.type === "INACTIVITY_STOP") { + if (job.previewId) { + await import_db.prisma.job.update({ where: { id: job.id }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } }); + await (0, import_deploy.stopPreview)(job.previewId); + await import_db.prisma.job.update({ where: { id: job.id }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date() } }); + } + } + } catch (e) { + log.error({ e, jobId: job.id }, "Job processing error"); + await import_db.prisma.job.update({ + where: { id: job.id }, + data: { status: "FAILED", error: e.message, finishedAt: /* @__PURE__ */ new Date() } + }).catch(() => { + }); + } +} +function stopJobWorker() { + running = false; +} +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + startJobWorker, + stopJobWorker +}); +//# sourceMappingURL=jobWorker.js.map diff --git a/backend/dist/workers/jobWorker.js.map b/backend/dist/workers/jobWorker.js.map new file mode 100644 index 00000000..85db799c --- /dev/null +++ b/backend/dist/workers/jobWorker.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../src/workers/jobWorker.ts"], + "sourcesContent": ["import { prisma } from \"../lib/db\";\nimport { createLogger } from \"../lib/logger\";\nimport { runDeploy, stopPreview, signalAbort } from \"../services/deploy\";\n\nconst log = createLogger(\"JOB_WORKER\");\nlet running = false;\n\nexport async function startJobWorker() {\n if (running) return;\n running = true;\n log.info(\"Job worker started\");\n\n await resetStuckJobs();\n pollLoop();\n}\n\nasync function resetStuckJobs() {\n const count = await prisma.job.updateMany({\n where: { status: \"RUNNING\" },\n data: { status: \"PENDING\", startedAt: null },\n });\n if (count.count > 0) log.info({ count: count.count }, \"Reset stuck running jobs to PENDING\");\n}\n\nasync function pollLoop() {\n while (running) {\n try {\n await processPendingJobs();\n } catch (e) {\n log.error({ e }, \"Job worker poll error\");\n }\n await sleep(1000);\n }\n}\n\nconst activePreviewJobs = new Map();\n\nasync function processPendingJobs() {\n const pending = await prisma.job.findMany({\n where: { status: \"PENDING\" },\n orderBy: { createdAt: \"asc\" },\n take: 20,\n });\n\n for (const job of pending) {\n const previewId = job.previewId;\n if (!previewId) continue;\n\n if (activePreviewJobs.has(previewId)) {\n const existingJobId = activePreviewJobs.get(previewId)!;\n\n if (job.type === \"DEPLOY\") {\n log.info({ previewId, newJobId: job.id, abortingJobId: existingJobId }, \"New deploy cancels existing\");\n signalAbort(previewId);\n await sleep(500);\n } else {\n continue;\n }\n }\n\n activePreviewJobs.set(previewId, job.id);\n\n processJob(job).finally(() => {\n if (activePreviewJobs.get(previewId) === job.id) {\n activePreviewJobs.delete(previewId);\n }\n });\n }\n}\n\nasync function processJob(job: { id: number; type: string; previewId: number | null; payload: any }) {\n log.info({ jobId: job.id, type: job.type, previewId: job.previewId }, \"Processing job\");\n\n try {\n if (job.type === \"DEPLOY\") {\n await runDeploy(job.id);\n } else if (job.type === \"STOP\" || job.type === \"INACTIVITY_STOP\") {\n if (job.previewId) {\n await prisma.job.update({ where: { id: job.id }, data: { status: \"RUNNING\", startedAt: new Date() } });\n await stopPreview(job.previewId);\n await prisma.job.update({ where: { id: job.id }, data: { status: \"DONE\", finishedAt: new Date() } });\n }\n }\n } catch (e: any) {\n log.error({ e, jobId: job.id }, \"Job processing error\");\n await prisma.job.update({\n where: { id: job.id },\n data: { status: \"FAILED\", error: e.message, finishedAt: new Date() },\n }).catch(() => {});\n }\n}\n\nexport function stopJobWorker() {\n running = false;\n}\n\nfunction sleep(ms: number) {\n return new Promise(r => setTimeout(r, ms));\n}\n"], + "mappings": "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", + "names": [] +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..424564a0 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,40 @@ +{ + "name": "pp-backend", + "version": "1.0.0", + "description": "PR Previews backend", + "private": true, + "scripts": { + "dev": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --sourcemap --ignore-annotations --format=cjs --target=es2022 --outdir=dist && cd dist && node index.js", + "build": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --sourcemap --ignore-annotations --format=cjs --target=es2022 --outdir=dist", + "start": "cd dist && node index.js", + "prod": "pnpm build && pnpm start", + "migrate": "prisma migrate dev --schema=../prisma/schema.prisma", + "migrate:deploy": "prisma migrate deploy --schema=../prisma/schema.prisma", + "generate": "prisma generate --schema=../prisma/schema.prisma" + }, + "dependencies": { + "@aws-sdk/client-ec2": "^3.800.0", + "@aws-sdk/client-sts": "^3.800.0", + "@prisma/client": "^6.0.0", + "@rjweb/runtime-node": "^1.1.1", + "@rjweb/utils": "^1.12.29", + "@types/bcryptjs": "^3.0.0", + "@types/node": "^22.0.0", + "@types/ssh2": "^1.15.0", + "@types/ws": "^8.5.0", + "axios": "^1.7.0", + "bcryptjs": "^3.0.3", + "dotenv": "^17.0.0", + "esbuild": "^0.25.0", + "node-cron": "^3.0.3", + "pino": "^10.0.0", + "pino-pretty": "^13.0.0", + "prisma": "^6.0.0", + "rimraf": "^5.0.0", + "rjweb-server": "^9.8.6", + "ssh2": "^1.16.0", + "typescript": "^5.0.0", + "ws": "^8.18.0", + "zod": "^3.24.0" + } +} diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 00000000..eb5f8193 --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,2253 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@aws-sdk/client-ec2': + specifier: ^3.800.0 + version: 3.1095.0 + '@aws-sdk/client-sts': + specifier: ^3.800.0 + version: 3.1095.0 + '@prisma/client': + specifier: ^6.0.0 + version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + '@rjweb/runtime-node': + specifier: ^1.1.1 + version: 1.1.1 + '@rjweb/utils': + specifier: ^1.12.29 + version: 1.12.29 + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/ssh2': + specifier: ^1.15.0 + version: 1.15.5 + '@types/ws': + specifier: ^8.5.0 + version: 8.18.1 + axios: + specifier: ^1.7.0 + version: 1.18.1 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 + dotenv: + specifier: ^17.0.0 + version: 17.4.2 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + node-cron: + specifier: ^3.0.3 + version: 3.0.3 + pino: + specifier: ^10.0.0 + version: 10.3.1 + pino-pretty: + specifier: ^13.0.0 + version: 13.1.3 + prisma: + specifier: ^6.0.0 + version: 6.19.3(typescript@5.9.3) + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + rjweb-server: + specifier: ^9.8.6 + version: 9.9.0(@types/node@22.20.1) + ssh2: + specifier: ^1.16.0 + version: 1.17.0 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + ws: + specifier: ^8.18.0 + version: 8.21.1(bufferutil@4.1.0) + zod: + specifier: ^3.24.0 + version: 3.25.76 + +packages: + + '@aws-sdk/client-ec2@3.1095.0': + resolution: {integrity: sha512-ZXdqlaGL/ePExgcewKQLNu+oPBYRGWTqLWNwOqYSjOQSvmtvXFsr36II88vLAN3et6+YvVdKVakYn2scg0bcSQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-sts@3.1095.0': + resolution: {integrity: sha512-Frc/KFP9lrf9Z/NbmAj0mrPm0SQjgAo3lo3DFx3dM35/EhoVrlahmTntlK7ulTSYUi4Y3AHbLfpo5LfyX6YVlA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.0': + resolution: {integrity: sha512-w+iANjPGOj4fHxWeyjfRt+xeRX8BIOVStqdTcMebfeIJicTiBTMAbQurQuVfAxHpUfsoV+fWaEQvGpO6IWifLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.61': + resolution: {integrity: sha512-qihs2ekMb89Nxd2JenCgVFhjbkb3EIo7HEBCBzyZACKVJdrLUZBLOmAE3xr0Sayml8n/jZSzwO/IufIiIzO7PQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.63': + resolution: {integrity: sha512-yfozsS8wkWZEi/n6IsrodcFKBWZ0iNAezhJbTReMNc0z1Px17qdeAeuL1/wziCAmCZyXiW7QzP75ggJkBQv8jQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.6': + resolution: {integrity: sha512-jGLTW1bj148GL/6/IMlfY2fMYS9FtHOG+NahkFD4y0qkzYudNUahelxryY68/HGMslYuHClk1XaS/3b3eJzEkg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.68': + resolution: {integrity: sha512-w6tNci6g7RqFpLhj1f5xseBvaNojb4Pkgp5Jp5apl9hrJtaf2AA+rX9+qlhlWUK6kcyAFYPA7emO+55zj+S98Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.72': + resolution: {integrity: sha512-blQ7F5QGzylnzeh5549zQLoCAiMHkXFLjFovEMaVy4b2X8JhUu+u9NXro1hyK95YHdVFNmBHKs2hIHtZchxKlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.61': + resolution: {integrity: sha512-xzRuj+fUVO4nkafKQJVKAF97kGpeQbfjuwmRrtGZNf42/1dkmcz6o7dswBy7alY0htQn5sCL1GWQYEykviWZkA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.5': + resolution: {integrity: sha512-fZRjjWhLFelsDoOYjqShQTrIGYC3Pf9Mx9Czf+1ikfQDgktxjze33dVo1q1/ZQ+T0qbtejVoHNHrfD5aJVpv/w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.67': + resolution: {integrity: sha512-FTNZ05gkPBA6CKbU3N4zPgybV+stdazwMOya75CmGdcJL7p8Fw/BdHP8WVxJd0mvzyPK2cg/C3gli58Ir4HgCw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-ec2@3.972.49': + resolution: {integrity: sha512-6Mgh8coumYjvrTDlLtvuTcRy+iRWK6wq7MIKi/ba8t04WdL8XSPU8vhEYOAGivof/t2M7nIZiXmZGP4nqPuAeQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.35': + resolution: {integrity: sha512-2MJfseVG/aXvIyOIBlYA/Oaf6qFDdsu4D8RKsEUdOQpVuLaor0BdxIBBtJLBNQQEe6Ku3YMvLljwb1MwVUpzRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.42': + resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1095.0': + resolution: {integrity: sha512-65SudS6y4nzaYHybtqcpm3sHe5jLhdMn68HRKS1nUx690BtQeaAQOoujQ+dpOjBATIVGVgKKjEP8tR+U06QJQA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + + '@rjweb/runtime-node@1.1.1': + resolution: {integrity: sha512-rlNGXQV3IYn19MsJhnd+tOaEr09vTvRw498ihsKzEDcUsidqN8nYrOS3sRI5I0w6ROY4zyAMsUFbSucktngx1w==} + + '@rjweb/utils@1.12.29': + resolution: {integrity: sha512-iBE0VN4FKYKpMtgrT0KgfscRfIyC6xPnxLF2v/05rSet+bukfqEbfceLfFh5V6U9sH3ncTlZ56kzUsCOvoJsSA==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.29.8': + resolution: {integrity: sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.13': + resolution: {integrity: sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.10': + resolution: {integrity: sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.10': + resolution: {integrity: sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.9': + resolution: {integrity: sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.4: + resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inquirer@13.4.3: + resolution: {integrity: sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + + node-cron@3.0.3: + resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} + engines: {node: '>=6.0.0'} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + nypm@0.6.8: + resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==} + engines: {node: '>=18'} + hasBin: true + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi3-ts@4.6.0: + resolution: {integrity: sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + rjweb-server@9.9.0: + resolution: {integrity: sha512-W3Stj8BjgK4vncZTPAdXNSMix4Uq9vCV7T4e/Ec3FHAHNdb6+8xmk3QhqSOYbSpExWAdfitAA3lTDzLSnrYH4w==} + engines: {node: '>=22.0.0'} + hasBin: true + + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + ts-arithmetic@0.1.1: + resolution: {integrity: sha512-3VqgsRgzaYfj+zKWn+7O66ifHwbOOnT2BoOrHwdEUBz7az0DetoZOS20+juNJh1klgzvWEi2Qxden41pomOUAQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@aws-sdk/client-ec2@3.1095.0': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/credential-provider-node': 3.972.72 + '@aws-sdk/middleware-sdk-ec2': 3.972.49 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/fetch-http-handler': 5.6.10 + '@smithy/node-http-handler': 4.9.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-sts@3.1095.0': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/credential-provider-node': 3.972.72 + '@aws-sdk/signature-v4-multi-region': 3.996.42 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/fetch-http-handler': 5.6.10 + '@smithy/node-http-handler': 4.9.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.0': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.8 + '@smithy/signature-v4': 5.6.9 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.61': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.63': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/fetch-http-handler': 5.6.10 + '@smithy/node-http-handler': 4.9.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.6': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/credential-provider-env': 3.972.61 + '@aws-sdk/credential-provider-http': 3.972.63 + '@aws-sdk/credential-provider-login': 3.972.68 + '@aws-sdk/credential-provider-process': 3.972.61 + '@aws-sdk/credential-provider-sso': 3.973.5 + '@aws-sdk/credential-provider-web-identity': 3.972.67 + '@aws-sdk/nested-clients': 3.997.35 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/credential-provider-imds': 4.4.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.68': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/nested-clients': 3.997.35 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.72': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.61 + '@aws-sdk/credential-provider-http': 3.972.63 + '@aws-sdk/credential-provider-ini': 3.973.6 + '@aws-sdk/credential-provider-process': 3.972.61 + '@aws-sdk/credential-provider-sso': 3.973.5 + '@aws-sdk/credential-provider-web-identity': 3.972.67 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/credential-provider-imds': 4.4.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.61': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.5': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/nested-clients': 3.997.35 + '@aws-sdk/token-providers': 3.1095.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/nested-clients': 3.997.35 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-ec2@3.972.49': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/signature-v4': 5.6.9 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.35': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/signature-v4-multi-region': 3.996.42 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/fetch-http-handler': 5.6.10 + '@smithy/node-http-handler': 4.9.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.42': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.9 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1095.0': + dependencies: + '@aws-sdk/core': 3.977.0 + '@aws-sdk/nested-clients': 3.997.35 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/confirm@6.1.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/core@11.2.1(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.1) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/editor@5.2.2(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/external-editor': 3.0.3(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/expand@5.1.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/external-editor@3.0.3(@types/node@22.20.1)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/number@4.1.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/password@5.1.1(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/prompts@8.5.2(@types/node@22.20.1)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@22.20.1) + '@inquirer/confirm': 6.1.1(@types/node@22.20.1) + '@inquirer/editor': 5.2.2(@types/node@22.20.1) + '@inquirer/expand': 5.1.1(@types/node@22.20.1) + '@inquirer/input': 5.1.2(@types/node@22.20.1) + '@inquirer/number': 4.1.1(@types/node@22.20.1) + '@inquirer/password': 5.1.1(@types/node@22.20.1) + '@inquirer/rawlist': 5.3.1(@types/node@22.20.1) + '@inquirer/search': 4.2.1(@types/node@22.20.1) + '@inquirer/select': 5.2.1(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/rawlist@5.3.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/search@4.2.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/select@5.2.1(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/type@4.0.7(@types/node@22.20.1)': + optionalDependencies: + '@types/node': 22.20.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@pinojs/redact@0.4.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.3(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.3': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.3': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + + '@rjweb/runtime-node@1.1.1': + dependencies: + '@rjweb/utils': 1.12.29 + bufferutil: 4.1.0 + ws: 8.21.1(bufferutil@4.1.0) + transitivePeerDependencies: + - utf-8-validate + + '@rjweb/utils@1.12.29': + dependencies: + ts-arithmetic: 0.1.1 + + '@smithy/core@3.29.8': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.13': + dependencies: + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.10': + dependencies: + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.10': + dependencies: + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.9': + dependencies: + '@smithy/core': 3.29.8 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + + '@standard-schema/spec@1.1.0': {} + + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.1 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + bcryptjs@3.0.3: {} + + bowser@2.14.1: {} + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + + buildcheck@0.0.7: + optional: true + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.1.0 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.1 + rc9: 2.1.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chardet@2.2.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.28.0 + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dateformat@4.6.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + destr@2.0.5: {} + + dotenv@16.6.1: {} + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + empathic@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + exsolve@1.1.0: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.4: {} + + fast-safe-stringify@2.1.1: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + follow-redirects@1.16.0: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.8 + pathe: 2.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + help-me@5.0.0: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inquirer@13.4.3(@types/node@22.20.1): + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/prompts': 8.5.2(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + mute-stream: 3.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 22.20.1 + + is-fullwidth-code-point@3.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.7.0: {} + + joycon@3.1.1: {} + + lru-cache@10.4.3: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + ms@2.1.3: {} + + mute-stream@3.0.0: {} + + nan@2.28.0: + optional: true + + node-cron@3.0.3: + dependencies: + uuid: 8.3.2 + + node-fetch-native@1.6.7: {} + + node-gyp-build@4.8.4: {} + + nypm@0.6.8: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.2.4 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openapi3-ts@4.6.0: + dependencies: + yaml: 2.9.0 + + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.4 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + + prisma@6.19.3(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.3 + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + process-warning@5.0.0: {} + + proxy-from-env@2.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pure-rand@6.1.0: {} + + quick-format-unescaped@4.0.4: {} + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@4.1.2: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-directory@2.1.1: {} + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + + rjweb-server@9.9.0(@types/node@22.20.1): + dependencies: + '@inquirer/prompts': 8.5.2(@types/node@22.20.1) + '@rjweb/utils': 1.12.29 + content-disposition: 0.5.4 + inquirer: 13.4.3(@types/node@22.20.1) + openapi3-ts: 4.6.0 + yargs: 17.7.3 + zod: 4.4.3 + transitivePeerDependencies: + - '@types/node' + + run-async@4.0.6: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@4.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.28.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@5.0.3: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinyexec@1.2.4: {} + + ts-arithmetic@0.1.1: {} + + tslib@2.8.1: {} + + tweetnacl@0.14.5: {} + + typescript@5.9.3: {} + + undici-types@5.26.5: {} + + undici-types@6.21.0: {} + + uuid@8.3.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.21.1(bufferutil@4.1.0): + optionalDependencies: + bufferutil: 4.1.0 + + y18n@5.0.8: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 00000000..fe67bfca --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,130 @@ +import { env } from "./lib/env"; +import { Server, Cookie } from "rjweb-server"; +import { Runtime } from "@rjweb/runtime-node"; +import { existsSync } from "node:fs"; +import { join } from "path"; +import { prisma } from "./lib/db"; +import { logger } from "./lib/logger"; +import { corsMiddleware } from "./lib/middlewares/cors"; +import { mainMiddleware } from "./lib/middlewares/main"; +import { authResolutionMiddleware, authEnforcementMiddleware } from "./lib/middlewares/auth"; +import { makeResponse } from "./lib/response"; +import { ERROR_MESSAGES } from "./lib/errors"; +import { startJobWorker } from "./workers/jobWorker"; +import { startCronWorkers } from "./workers/cronWorker"; +import { getAdminSettings } from "./lib/adminSettings"; + +import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth"; +import { webhookHandler } from "./routes/webhook"; +import { getUserSettings, updateUsername, updatePassword, updateGitea, updateAws, getWebhookSecret, regenerateWebhookSecret } from "./routes/api/user"; +import { listRepos, saveRepoConfig, toggleRepoEnabled, getRepoConfig } from "./routes/api/repos"; +import { listPreviews, getPreview, stopPreviewRoute, previewLogsWs } from "./routes/api/previews"; +import { listUsers, createUser, updateUser, deleteUser, getSettings, updateSettings, adminListPreviews, adminStopPreview } from "./routes/api/admin"; + +const uiBuildPath = join(__dirname, "../../frontend/dist"); +const uiIndexPath = join(uiBuildPath, "index.html"); +const hasUiBuild = existsSync(uiBuildPath); + +export const server = new Server( + Runtime, + { + port: env.PORT, + bind: "0.0.0.0", + version: false, + performance: { lastModified: false, eTag: false }, + logging: { warn: true, debug: false, error: true }, + }, + [ + corsMiddleware.use({}), + mainMiddleware.use({}), + authResolutionMiddleware.use({}), + authEnforcementMiddleware.use({}), + ], +); + +// Auth +server.path("/api/auth", (path) => path + .http("POST", "/login", (http) => http.onRequest(loginHandler)) + .http("POST", "/logout", (http) => http.onRequest(logoutHandler)) + .http("GET", "/me", (http) => http.onRequest(meHandler)) + .http("GET", "/setup-status", (http) => http.onRequest(setupStatusHandler)) + .http("POST", "/first-user", (http) => http.onRequest(firstUserHandler)) +); + +// Webhook +server.path("/webhook", (path) => path + .http("POST", "/:userId", (http) => http.onRequest(webhookHandler)) +); + +// User settings +server.path("/api/user", (path) => path + .http("GET", "/settings", (http) => http.onRequest(getUserSettings)) + .http("PATCH", "/username", (http) => http.onRequest(updateUsername)) + .http("PATCH", "/password", (http) => http.onRequest(updatePassword)) + .http("PUT", "/gitea", (http) => http.onRequest(updateGitea)) + .http("PUT", "/aws", (http) => http.onRequest(updateAws)) + .http("GET", "/webhook-secret", (http) => http.onRequest(getWebhookSecret)) + .http("POST", "/webhook-secret/regenerate", (http) => http.onRequest(regenerateWebhookSecret)) +); + +// Repos +server.path("/api/repos", (path) => path + .http("GET", "/", (http) => http.onRequest(listRepos)) + .http("POST", "/config", (http) => http.onRequest(saveRepoConfig)) + .http("POST", "/toggle", (http) => http.onRequest(toggleRepoEnabled)) + .http("GET", "/:owner/:repo/config", (http) => http.onRequest(getRepoConfig)) +); + +// Previews +server.path("/api/previews", (path) => path + .http("GET", "/", (http) => http.onRequest(listPreviews)) + .http("GET", "/:id", (http) => http.onRequest(getPreview)) + .http("POST", "/:id/stop", (http) => http.onRequest(stopPreviewRoute)) + .ws("/:id/logs", (ws) => ws + .onOpen(previewLogsWs) + .onMessage(async () => {}) + .onClose(async () => {}) + ) +); + +// Admin +server.path("/api/admin", (path) => path + .http("GET", "/users", (http) => http.onRequest(listUsers)) + .http("POST", "/users", (http) => http.onRequest(createUser)) + .http("PATCH", "/users/:id", (http) => http.onRequest(updateUser)) + .http("DELETE", "/users/:id", (http) => http.onRequest(deleteUser)) + .http("GET", "/settings", (http) => http.onRequest(getSettings)) + .http("PUT", "/settings", (http) => http.onRequest(updateSettings)) + .http("GET", "/previews", (http) => http.onRequest(adminListPreviews)) + .http("POST", "/previews/:id/stop", (http) => http.onRequest(adminStopPreview)) +); + +// Static UI +if (hasUiBuild) { + server.path("/", (path) => path.static(uiBuildPath)); +} + +server.notFound(async (ctr) => { + const STATIC_EXT = /\.(js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|json|txt|xml|webp|avif)(\?.*)?$/i; + if (!ctr.url.path.startsWith("/api") && !STATIC_EXT.test(ctr.url.path) && existsSync(uiIndexPath)) { + return ctr.status(200).printFile(uiIndexPath, { addTypes: true }); + } + return makeResponse({ ctr, content: { code: ERROR_MESSAGES.NOT_FOUND.code, message: ERROR_MESSAGES.NOT_FOUND.message } }); +}); + +server.error("httpRequest", async (ctr, error) => { + logger.error(error, "Unhandled HTTP request error"); + return makeResponse({ ctr, content: { code: ERROR_MESSAGES.INTERNAL_SERVER_ERROR.code } }); +}); + +server + .start() + .then(async (port) => { + await prisma.$connect(); + logger.info({ port }, "PP backend running"); + await getAdminSettings(); + startJobWorker(); + startCronWorkers(); + logger.info("All workers started"); + }) + .catch((err) => logger.error(err, "Server failed to start")); diff --git a/backend/src/lib/adminSettings.ts b/backend/src/lib/adminSettings.ts new file mode 100644 index 00000000..bf66daa7 --- /dev/null +++ b/backend/src/lib/adminSettings.ts @@ -0,0 +1,9 @@ +import { prisma } from "./db"; + +export async function getAdminSettings() { + let settings = await prisma.adminSettings.findUnique({ where: { id: 1 } }); + if (!settings) { + settings = await prisma.adminSettings.create({ data: { id: 1 } }); + } + return settings; +} diff --git a/backend/src/lib/db.ts b/backend/src/lib/db.ts new file mode 100644 index 00000000..d383939e --- /dev/null +++ b/backend/src/lib/db.ts @@ -0,0 +1,6 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient({ + log: ["error", "warn"], + errorFormat: "pretty", +}); diff --git a/backend/src/lib/encryption.ts b/backend/src/lib/encryption.ts new file mode 100644 index 00000000..57640b87 --- /dev/null +++ b/backend/src/lib/encryption.ts @@ -0,0 +1,23 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; +import { env } from "./env"; + +const ALGORITHM = "aes-256-gcm"; +const KEY = Buffer.from(env.ENCRYPTION_KEY, "hex"); + +export function encrypt(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString("base64"); +} + +export function decrypt(ciphertext: string): string { + const buf = Buffer.from(ciphertext, "base64"); + const iv = buf.slice(0, 12); + const authTag = buf.slice(12, 28); + const encrypted = buf.slice(28); + const decipher = createDecipheriv(ALGORITHM, KEY, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); +} diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts new file mode 100644 index 00000000..557a6b4c --- /dev/null +++ b/backend/src/lib/env.ts @@ -0,0 +1,26 @@ +import dotenv from "dotenv"; +import { join } from "path"; +import { z } from "zod"; + +dotenv.config({ path: join(__dirname, "../../.env") }); + +const schema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + DATABASE_URL: z.string().min(1), + PORT: z.coerce.number().int().positive().default(5000), + SESSION_SECRET: z.string().min(16), + PP_BASE_URL: z.string().min(1), + ENCRYPTION_KEY: z.string().length(64, "ENCRYPTION_KEY must be 64 hex chars (32 bytes AES-256)"), + LOG_LEVEL: z.string().default("info"), +}); + +const result = schema.safeParse(process.env); + +if (!result.success) { + const formatted = result.error.issues + .map((i) => ` ${i.path.join(".")}: ${i.message}`) + .join("\n"); + throw new Error(`Invalid environment variables:\n${formatted}`); +} + +export const env = result.data; diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts new file mode 100644 index 00000000..9af72217 --- /dev/null +++ b/backend/src/lib/errors.ts @@ -0,0 +1,9 @@ +export const ERROR_MESSAGES = { + UNAUTHORIZED: { code: 401, message: "You are not authorized to access this resource." }, + FORBIDDEN: { code: 403, message: "You do not have permission to access this resource." }, + NOT_FOUND: { code: 404, message: "The requested resource was not found." }, + INTERNAL_SERVER_ERROR: { code: 500, message: "An unexpected server error has occurred." }, + BAD_REQUEST: { code: 400, message: "The request was invalid or malformed." }, + CONFLICT: { code: 409, message: "The request conflicts with the current state of the resource." }, + TOO_MANY_REQUESTS: { code: 429, message: "Too many requests. Please try again later." }, +} as const; diff --git a/backend/src/lib/logger.ts b/backend/src/lib/logger.ts new file mode 100644 index 00000000..27b9b1c2 --- /dev/null +++ b/backend/src/lib/logger.ts @@ -0,0 +1,14 @@ +import { env } from "./env"; +import pino from "pino"; + +export const logger = pino({ + level: env.LOG_LEVEL, + transport: + env.NODE_ENV !== "production" + ? { target: "pino-pretty", options: { colorize: true } } + : undefined, +}); + +export function createLogger(component: string) { + return logger.child({ component }); +} diff --git a/backend/src/lib/middlewares/auth.ts b/backend/src/lib/middlewares/auth.ts new file mode 100644 index 00000000..72edf5dc --- /dev/null +++ b/backend/src/lib/middlewares/auth.ts @@ -0,0 +1,83 @@ +import { Middleware } from "rjweb-server"; +import { type User } from "@prisma/client"; +import { prisma } from "../db"; +import { createLogger } from "../logger"; +import { ERROR_MESSAGES } from "../errors"; + +const log = createLogger("AUTH"); + +const COOKIE_NAME = "pp_session"; + +export type AuthState = + | { success: true; user: User; sessionId: number } + | { success: false; message: string; tokenProvided: boolean }; + +type AuthContext = { + auth?: AuthState; +}; + +export const authResolutionMiddleware = new Middleware<{}, AuthContext>( + "Auth Resolution Middleware", + "1.0.0", +) + .load(() => { + log.info("Auth resolution middleware loaded"); + }) + .httpRequest(async (_config, _server, context, ctr) => { + const cookieToken = ctr.cookies.get(COOKIE_NAME); + const tokenProvided = Boolean(cookieToken); + const data = context.data(authResolutionMiddleware); + + if (!cookieToken) { + data.auth = { success: false, message: "No session", tokenProvided: false }; + return; + } + + const session = await prisma.session.findFirst({ + where: { hash: cookieToken }, + include: { user: true }, + }); + + if (!session) { + data.auth = { success: false, message: "Invalid session", tokenProvided }; + return; + } + + data.auth = { success: true, user: session.user, sessionId: session.id }; + }) + .httpRequestContext( + (_config, Original) => + class extends Original { + getAuth(): AuthState { + const data = this.context.data(authResolutionMiddleware); + if (!data.auth) { + return { success: false, message: "Auth not resolved", tokenProvided: false }; + } + return data.auth; + } + }, + ) + .export(); + +export const authEnforcementMiddleware = new Middleware<{}, {}>( + "Auth Enforcement Middleware", + "1.0.0", +) + .httpRequest(async (_config, _server, context, ctr, end) => { + const data = context.data(authResolutionMiddleware) as AuthContext; + const auth = data.auth; + + if (!auth || auth.success || !auth.tokenProvided) { + return; + } + + return end( + ctr.status(ERROR_MESSAGES.UNAUTHORIZED.code).print({ + status: "FAILED", + message: auth.message, + }), + ); + }) + .export(); + +export const COOKIE_NAME_EXPORT = COOKIE_NAME; diff --git a/backend/src/lib/middlewares/cors.ts b/backend/src/lib/middlewares/cors.ts new file mode 100644 index 00000000..f6844423 --- /dev/null +++ b/backend/src/lib/middlewares/cors.ts @@ -0,0 +1,16 @@ +import { Middleware } from "rjweb-server"; +import { env } from "../env"; + +const ALLOWED_ORIGINS = new Set([env.PP_BASE_URL]); + +export const corsMiddleware = new Middleware<{}, {}>("CORS Middleware", "1.0.0") + .httpRequest(async (_config, _server, _context, ctr) => { + const origin = ctr.headers.get("origin") || ""; + if (ALLOWED_ORIGINS.has(origin) || env.NODE_ENV === "development") { + ctr.headers.set("Access-Control-Allow-Origin", origin || "*"); + ctr.headers.set("Access-Control-Allow-Credentials", "true"); + ctr.headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); + ctr.headers.set("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Requested-With"); + } + }) + .export(); diff --git a/backend/src/lib/middlewares/main.ts b/backend/src/lib/middlewares/main.ts new file mode 100644 index 00000000..e9c0e843 --- /dev/null +++ b/backend/src/lib/middlewares/main.ts @@ -0,0 +1,15 @@ +import { Middleware } from "rjweb-server"; +import { createLogger } from "../logger"; + +const log = createLogger("HTTP"); + +export const mainMiddleware = new Middleware<{}, {}>("Main Middleware", "1.0.0") + .load(() => { + log.info("Main middleware loaded"); + }) + .httpRequest(async (_config, _server, _context, ctr) => { + if (ctr.url.method === "OPTIONS") { + ctr.status(204).print(""); + } + }) + .export(); diff --git a/backend/src/lib/response.ts b/backend/src/lib/response.ts new file mode 100644 index 00000000..d9e376b2 --- /dev/null +++ b/backend/src/lib/response.ts @@ -0,0 +1,49 @@ +import { ERROR_MESSAGES } from "./errors"; + +type ResponseContent = + | { code: number; message?: string; data?: unknown } + | { status: number; message?: string; data?: unknown }; + +function resolve(content: ResponseContent) { + const code = "code" in content ? content.code : content.status; + const message = code >= 500 ? ERROR_MESSAGES.INTERNAL_SERVER_ERROR.message : content.message; + return { code, message }; +} + +function buildBody(code: number, message: string | undefined, data: unknown) { + if (code >= 400) { + return { status: "FAILED", message }; + } + return { + status: "OK", + ...(message !== undefined ? { message } : {}), + ...(data !== undefined ? { data } : {}), + }; +} + +export async function makeResponse({ + ctr, + content, +}: { + ctr: any; + content: ResponseContent; +}) { + const { code, message } = resolve(content); + const data = "data" in content ? content.data : undefined; + return ctr.status(code).print(buildBody(code, message, data)); +} + +export async function endResponse({ + ctr, + end, + content, +}: { + ctr: any; + end: () => void; + content: ResponseContent; +}) { + const { code, message } = resolve(content); + const data = "data" in content ? content.data : undefined; + ctr.status(code).print(buildBody(code, message, data)); + end(); +} diff --git a/backend/src/routes/api/admin.ts b/backend/src/routes/api/admin.ts new file mode 100644 index 00000000..61b148b7 --- /dev/null +++ b/backend/src/routes/api/admin.ts @@ -0,0 +1,170 @@ +import bcrypt from "bcryptjs"; +import { prisma } from "../../lib/db"; +import { makeResponse } from "../../lib/response"; +import { ERROR_MESSAGES } from "../../lib/errors"; +import { getAdminSettings } from "../../lib/adminSettings"; + +function requireAdmin(ctr: any) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + if (!auth.user.isAdmin) return null; + return auth.user; +} + +export async function listUsers(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const users = await prisma.user.findMany({ + select: { id: true, username: true, isAdmin: true, isFounder: true, createdAt: true, giteaInstanceUrl: true }, + orderBy: { createdAt: "asc" }, + }); + + return makeResponse({ ctr, content: { code: 200, data: users } }); +} + +export async function createUser(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const body = await ctr.body(); + const { username, password } = body || {}; + if (!username || !password) return makeResponse({ ctr, content: { code: 400, message: "Username and password required" } }); + + const existing = await prisma.user.findUnique({ where: { username } }); + if (existing) return makeResponse({ ctr, content: { code: 409, message: "Username already taken" } }); + + const userCount = await prisma.user.count(); + const isFirst = userCount === 0; + const hash = await bcrypt.hash(password, 12); + + const user = await prisma.user.create({ + data: { username, passwordHash: hash, isAdmin: isFirst, isFounder: isFirst }, + select: { id: true, username: true, isAdmin: true, isFounder: true }, + }); + + return makeResponse({ ctr, content: { code: 201, data: user } }); +} + +export async function updateUser(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const id = parseInt(ctr.params.get("id") || "0", 10); + const target = await prisma.user.findUnique({ where: { id } }); + if (!target) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } }); + + const body = await ctr.body(); + const { username, password, isAdmin } = body || {}; + const data: any = {}; + + if (username) { + const existing = await prisma.user.findFirst({ where: { username, id: { not: id } } }); + if (existing) return makeResponse({ ctr, content: { code: 409, message: "Username taken" } }); + data.username = username; + } + if (password) data.passwordHash = await bcrypt.hash(password, 12); + if (isAdmin !== undefined && !target.isFounder) data.isAdmin = Boolean(isAdmin); + + await prisma.user.update({ where: { id }, data }); + return makeResponse({ ctr, content: { code: 200, message: "User updated" } }); +} + +export async function deleteUser(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const id = parseInt(ctr.params.get("id") || "0", 10); + const target = await prisma.user.findUnique({ where: { id } }); + if (!target) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } }); + if (target.isFounder) return makeResponse({ ctr, content: { code: 403, message: "Cannot delete founder" } }); + if (id === admin.id) return makeResponse({ ctr, content: { code: 403, message: "Cannot delete yourself" } }); + + await prisma.user.delete({ where: { id } }); + return makeResponse({ ctr, content: { code: 200, message: "User deleted" } }); +} + +export async function getSettings(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const settings = await getAdminSettings(); + return makeResponse({ ctr, content: { code: 200, data: settings } }); +} + +export async function updateSettings(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const body = await ctr.body(); + const { + defaultInstanceType, + maxConcurrentInstancesPerUser, + logSizeLimitBytes, + previewRetentionDays, + webhookRateLimitPerMinute, + contactEmail, + } = body || {}; + + const data: any = {}; + if (defaultInstanceType) data.defaultInstanceType = defaultInstanceType; + if (maxConcurrentInstancesPerUser) data.maxConcurrentInstancesPerUser = Number(maxConcurrentInstancesPerUser); + if (logSizeLimitBytes) data.logSizeLimitBytes = Number(logSizeLimitBytes); + if (previewRetentionDays) data.previewRetentionDays = Number(previewRetentionDays); + if (webhookRateLimitPerMinute) data.webhookRateLimitPerMinute = Number(webhookRateLimitPerMinute); + if (contactEmail !== undefined) data.contactEmail = contactEmail; + + await prisma.adminSettings.upsert({ + where: { id: 1 }, + update: data, + create: { id: 1, ...data }, + }); + + return makeResponse({ ctr, content: { code: 200, message: "Settings updated" } }); +} + +export async function adminListPreviews(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const previews = await prisma.preview.findMany({ + include: { + repoConfig: { include: { user: { select: { id: true, username: true } } } }, + }, + orderBy: { updatedAt: "desc" }, + take: 200, + }); + + return makeResponse({ + ctr, content: { + code: 200, data: previews.map(p => ({ + id: p.id, + prNumber: p.prNumber, + prTitle: p.prTitle, + status: p.status, + instanceIp: p.instanceIp, + port: p.port, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + repoOwner: p.repoConfig.repoOwner, + repoName: p.repoConfig.repoName, + user: (p.repoConfig as any).user, + })) + } + }); +} + +export async function adminStopPreview(ctr: any) { + const admin = requireAdmin(ctr); + if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } }); + + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await prisma.preview.findUnique({ where: { id } }); + if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } }); + + await prisma.job.create({ + data: { previewId: id, type: "STOP", status: "PENDING", payload: { reason: "Admin stop" } }, + }); + + return makeResponse({ ctr, content: { code: 200, message: "Stop job enqueued" } }); +} diff --git a/backend/src/routes/api/previews.ts b/backend/src/routes/api/previews.ts new file mode 100644 index 00000000..984f7f02 --- /dev/null +++ b/backend/src/routes/api/previews.ts @@ -0,0 +1,133 @@ +import { prisma } from "../../lib/db"; +import { makeResponse } from "../../lib/response"; +import { ERROR_MESSAGES } from "../../lib/errors"; +import { subscribeToLogs } from "../../services/deploy"; + +function requireAuth(ctr: any) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + return auth.user; +} + +export async function listPreviews(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const previews = await prisma.preview.findMany({ + where: { repoConfig: { userId: user.id } }, + include: { repoConfig: { select: { repoOwner: true, repoName: true } } }, + orderBy: { updatedAt: "desc" }, + }); + + return makeResponse({ + ctr, content: { + code: 200, data: previews.map(p => ({ + id: p.id, + prNumber: p.prNumber, + prTitle: p.prTitle, + commitSha: p.commitSha, + status: p.status, + instanceIp: p.instanceIp, + port: p.port, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + lastActivityAt: p.lastActivityAt, + repoOwner: p.repoConfig.repoOwner, + repoName: p.repoConfig.repoName, + })) + } + }); +} + +export async function getPreview(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await prisma.preview.findFirst({ + where: { id, repoConfig: { userId: user.id } }, + include: { + repoConfig: { select: { repoOwner: true, repoName: true } }, + jobs: { orderBy: { createdAt: "desc" }, take: 10 }, + }, + }); + + if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } }); + + return makeResponse({ + ctr, content: { + code: 200, data: { + id: preview.id, + prNumber: preview.prNumber, + prTitle: preview.prTitle, + commitSha: preview.commitSha, + status: preview.status, + instanceIp: preview.instanceIp, + port: preview.port, + logs: preview.logs, + createdAt: preview.createdAt, + updatedAt: preview.updatedAt, + stoppedAt: preview.stoppedAt, + lastActivityAt: preview.lastActivityAt, + repoOwner: preview.repoConfig.repoOwner, + repoName: preview.repoConfig.repoName, + jobs: preview.jobs.map(j => ({ + id: j.id, + type: j.type, + status: j.status, + createdAt: j.createdAt, + startedAt: j.startedAt, + finishedAt: j.finishedAt, + error: j.error, + })), + } + } + }); +} + +export async function stopPreviewRoute(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await prisma.preview.findFirst({ + where: { id, repoConfig: { userId: user.id } }, + }); + + if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } }); + if (preview.status === "STOPPED") return makeResponse({ ctr, content: { code: 400, message: "Already stopped" } }); + + await prisma.job.create({ + data: { previewId: id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop via UI" } }, + }); + + return makeResponse({ ctr, content: { code: 200, message: "Stop job enqueued" } }); +} + +export async function previewLogsWs(ctr: any) { + const auth = ctr.getAuth?.(); + if (!auth?.success) { + ctr.close(1008, "Unauthorized"); + return; + } + + const id = parseInt(ctr.params.get("id") || "0", 10); + const preview = await prisma.preview.findFirst({ + where: { id, repoConfig: { userId: auth.user.id } }, + }); + + if (!preview) { + ctr.close(1008, "Not found"); + return; + } + + await ctr.print(JSON.stringify({ type: "init", logs: preview.logs })); + + const unsub = subscribeToLogs(id, (text) => { + try { + ctr.print(JSON.stringify({ type: "append", text })); + } catch {} + }); + + ctr.$abort(unsub); +} diff --git a/backend/src/routes/api/repos.ts b/backend/src/routes/api/repos.ts new file mode 100644 index 00000000..bdb90b6f --- /dev/null +++ b/backend/src/routes/api/repos.ts @@ -0,0 +1,172 @@ +import { prisma } from "../../lib/db"; +import { makeResponse } from "../../lib/response"; +import { ERROR_MESSAGES } from "../../lib/errors"; +import { fetchUserRepos, registerWebhook, deleteWebhook } from "../../services/gitea"; +import { getAdminSettings } from "../../lib/adminSettings"; +import { env } from "../../lib/env"; + +function requireAuth(ctr: any) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + return auth.user; +} + +export async function listRepos(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const fullUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) { + return makeResponse({ ctr, content: { code: 400, message: "Gitea credentials not configured" } }); + } + + const giteaRepos = await fetchUserRepos(fullUser as any); + const configs = await prisma.repoConfig.findMany({ where: { userId: user.id } }); + const configMap = new Map(configs.map(c => [`${c.repoOwner}/${c.repoName}`, c])); + + const allOwners = [...new Set(giteaRepos.map((r: any) => r.full_name?.split("/")[0]).filter(Boolean))]; + const allConfigs = await prisma.repoConfig.findMany({ + where: { repoOwner: { in: allOwners } }, + select: { repoOwner: true, repoName: true, userId: true }, + }); + const claimedByOthers = new Set( + allConfigs.filter(c => c.userId !== user.id).map(c => `${c.repoOwner}/${c.repoName}`) + ); + + const result = giteaRepos.map((r: any) => { + const [owner, name] = (r.full_name || "").split("/"); + const key = `${owner}/${name}`; + const config = configMap.get(key); + const { giteaWebhookId, ...safeConfig } = config || {} as any; + return { + owner, + name, + fullName: r.full_name, + htmlUrl: r.html_url, + isEnabled: config?.isEnabled ?? false, + claimedByOther: claimedByOthers.has(key), + config: config ? safeConfig : null, + }; + }); + + return makeResponse({ ctr, content: { code: 200, data: result } }); +} + +export async function getRepoConfig(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const owner = ctr.params.get("owner"); + const repo = ctr.params.get("repo"); + + const config = await prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName: repo, userId: user.id }, + }); + if (!config) return makeResponse({ ctr, content: { code: 404, message: "Repo config not found" } }); + + const { giteaWebhookId, ...safeConfig } = config; + return makeResponse({ ctr, content: { code: 200, data: safeConfig } }); +} + +export async function saveRepoConfig(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const body = await ctr.body(); + const { owner, repo, ...configData } = body || {}; + if (!owner || !repo) return makeResponse({ ctr, content: { code: 400, message: "owner and repo required" } }); + + const existing = await prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName: repo }, + }); + + if (existing && existing.userId !== user.id) { + return makeResponse({ ctr, content: { code: 409, message: "This repo is already configured by another user." } }); + } + + const settings = await getAdminSettings(); + + const sanitized = { + repoOwner: owner, + repoName: repo, + userId: user.id, + instanceType: configData.instanceType ?? settings.defaultInstanceType, + inactivityHours: Math.min(72, Math.max(0.5, Number(configData.inactivityHours ?? 12))), + port: Number(configData.port ?? 3000), + envVars: configData.envVars ?? {}, + useDockerCompose: Boolean(configData.useDockerCompose ?? false), + composeFilePath: configData.composeFilePath ?? null, + aptPackages: Array.isArray(configData.aptPackages) ? configData.aptPackages : [], + setupCommands: Array.isArray(configData.setupCommands) ? configData.setupCommands : [], + buildCommands: Array.isArray(configData.buildCommands) ? configData.buildCommands : [], + postBuildCommands: Array.isArray(configData.postBuildCommands) ? configData.postBuildCommands : [], + runCommand: configData.runCommand ?? null, + denyList: Array.isArray(configData.denyList) ? configData.denyList : [], + }; + + let config; + if (existing) { + config = await prisma.repoConfig.update({ where: { id: existing.id }, data: sanitized }); + } else { + config = await prisma.repoConfig.create({ data: sanitized }); + } + + const { giteaWebhookId, ...safeConfig } = config; + return makeResponse({ ctr, content: { code: 200, data: safeConfig } }); +} + +export async function toggleRepoEnabled(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const body = await ctr.body(); + const { owner, repo, enabled } = body || {}; + if (!owner || !repo || enabled === undefined) { + return makeResponse({ ctr, content: { code: 400, message: "owner, repo, enabled required" } }); + } + + const fullUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) { + return makeResponse({ ctr, content: { code: 400, message: "Gitea credentials not configured" } }); + } + + let config = await prisma.repoConfig.findFirst({ where: { repoOwner: owner, repoName: repo } }); + if (config && config.userId !== user.id) { + return makeResponse({ ctr, content: { code: 409, message: "This repo is already configured by another user." } }); + } + + const webhookToken = await prisma.webhookToken.findUnique({ where: { userId: user.id } }); + if (!webhookToken) { + return makeResponse({ ctr, content: { code: 400, message: "No webhook token configured. Go to Settings to generate one." } }); + } + + if (enabled) { + if (!config) { + config = await prisma.repoConfig.create({ + data: { repoOwner: owner, repoName: repo, userId: user.id, isEnabled: false }, + }); + } + + const webhookUrl = `${env.PP_BASE_URL}/webhook/${user.id}`; + const hookId = await registerWebhook(fullUser as any, owner, repo, webhookUrl, webhookToken.token); + await prisma.repoConfig.update({ + where: { id: config.id }, + data: { isEnabled: true, giteaWebhookId: String(hookId) }, + }); + return makeResponse({ ctr, content: { code: 200, message: "Repo enabled and webhook registered" } }); + } else { + if (!config) return makeResponse({ ctr, content: { code: 404, message: "Repo config not found" } }); + if (config.giteaWebhookId) { + try { + await deleteWebhook(fullUser as any, owner, repo, config.giteaWebhookId); + } catch (e: any) { + return makeResponse({ ctr, content: { code: 400, message: `Failed to delete Gitea webhook: ${e.message}` } }); + } + } + await prisma.repoConfig.update({ + where: { id: config.id }, + data: { isEnabled: false, giteaWebhookId: null }, + }); + return makeResponse({ ctr, content: { code: 200, message: "Repo disabled and webhook removed" } }); + } +} diff --git a/backend/src/routes/api/user.ts b/backend/src/routes/api/user.ts new file mode 100644 index 00000000..45243c08 --- /dev/null +++ b/backend/src/routes/api/user.ts @@ -0,0 +1,182 @@ +import bcrypt from "bcryptjs"; +import { randomBytes } from "crypto"; +import { prisma } from "../../lib/db"; +import { makeResponse } from "../../lib/response"; +import { ERROR_MESSAGES } from "../../lib/errors"; +import { encrypt, decrypt } from "../../lib/encryption"; +import { validateGiteaUrl } from "../../services/gitea"; +import { validateAwsCredentials } from "../../services/ec2"; +import { env } from "../../lib/env"; + +function requireAuth(ctr: any) { + const auth = ctr.getAuth?.(); + if (!auth?.success) return null; + return auth.user; +} + +export async function getUserSettings(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const webhookToken = await prisma.webhookToken.findUnique({ where: { userId: user.id } }); + const fullUser = await prisma.user.findUnique({ where: { id: user.id } }); + + return makeResponse({ + ctr, content: { + code: 200, data: { + id: user.id, + username: user.username, + giteaUsername: fullUser?.giteaUsername, + giteaInstanceUrl: fullUser?.giteaInstanceUrl, + giteaPatSet: !!fullUser?.giteaPAT, + awsAccessKeyId: fullUser?.awsAccessKeyId ? "****" : null, + awsRegion: fullUser?.awsRegion, + webhookUrl: `${env.PP_BASE_URL}/webhook/${user.id}`, + webhookSecret: webhookToken?.token ? "••••••••" : null, + webhookTokenExists: !!webhookToken, + } + } + }); +} + +export async function updateUsername(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { username } = body || {}; + if (!username || typeof username !== "string") return makeResponse({ ctr, content: { code: 400, message: "Username required" } }); + + const existing = await prisma.user.findFirst({ where: { username, id: { not: user.id } } }); + if (existing) return makeResponse({ ctr, content: { code: 409, message: "Username already taken" } }); + + await prisma.user.update({ where: { id: user.id }, data: { username } }); + return makeResponse({ ctr, content: { code: 200, message: "Username updated" } }); +} + +export async function updatePassword(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { currentPassword, newPassword } = body || {}; + if (!currentPassword || !newPassword) return makeResponse({ ctr, content: { code: 400, message: "Current and new passwords required" } }); + + const fullUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser) return makeResponse({ ctr, content: { code: 404, message: "User not found" } }); + + const valid = await bcrypt.compare(currentPassword, fullUser.passwordHash); + if (!valid) return makeResponse({ ctr, content: { code: 401, message: "Current password incorrect" } }); + + const hash = await bcrypt.hash(newPassword, 12); + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: hash } }); + return makeResponse({ ctr, content: { code: 200, message: "Password updated" } }); +} + +export async function updateGitea(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { giteaInstanceUrl, giteaUsername, giteaPAT } = body || {}; + + if (!giteaInstanceUrl || !giteaUsername) { + return makeResponse({ ctr, content: { code: 400, message: "Gitea URL and username required" } }); + } + + const cleanUrl = giteaInstanceUrl.replace(/\/+$/, ""); + const validation = await validateGiteaUrl(cleanUrl); + if (!validation.success) { + return makeResponse({ ctr, content: { code: 400, message: `Gitea validation failed: ${validation.error}` } }); + } + + const data: any = { giteaInstanceUrl: cleanUrl, giteaUsername }; + if (giteaPAT) data.giteaPAT = encrypt(giteaPAT); + + await prisma.user.update({ where: { id: user.id }, data }); + return makeResponse({ ctr, content: { code: 200, message: `Connected to Gitea ${validation.version}`, data: { version: validation.version } } }); +} + +export async function updateAws(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + const body = await ctr.body(); + const { awsAccessKeyId, awsSecretAccessKey, awsRegion } = body || {}; + + if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) { + return makeResponse({ ctr, content: { code: 400, message: "AWS credentials and region required" } }); + } + + const tempUser = { + ...user, + awsAccessKeyId: encrypt(awsAccessKeyId), + awsSecretAccessKey: encrypt(awsSecretAccessKey), + awsRegion, + }; + const validation = await validateAwsCredentials(tempUser as any); + if (!validation.success) { + return makeResponse({ ctr, content: { code: 400, message: `AWS validation failed: ${validation.error}` } }); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + awsAccessKeyId: encrypt(awsAccessKeyId), + awsSecretAccessKey: encrypt(awsSecretAccessKey), + awsRegion, + }, + }); + + return makeResponse({ ctr, content: { code: 200, message: `Connected as ${validation.arn}`, data: { arn: validation.arn } } }); +} + +export async function getWebhookSecret(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + let token = await prisma.webhookToken.findUnique({ where: { userId: user.id } }); + if (!token) { + const secret = randomBytes(32).toString("hex"); + token = await prisma.webhookToken.create({ data: { userId: user.id, token: secret } }); + } + + return makeResponse({ ctr, content: { code: 200, data: { token: token.token } } }); +} + +export async function regenerateWebhookSecret(ctr: any) { + const user = requireAuth(ctr); + if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + + const newSecret = randomBytes(32).toString("hex"); + await prisma.webhookToken.upsert({ + where: { userId: user.id }, + update: { token: newSecret }, + create: { userId: user.id, token: newSecret }, + }); + + const fullUser = await prisma.user.findUnique({ where: { id: user.id } }); + if (!fullUser?.giteaInstanceUrl) { + return makeResponse({ ctr, content: { code: 200, message: "Secret regenerated (no Gitea hooks to update)", data: { token: newSecret } } }); + } + + const configs = await prisma.repoConfig.findMany({ + where: { userId: user.id, giteaWebhookId: { not: null } }, + }); + + const { updateWebhookSecret } = await import("../../services/gitea"); + const webhookUrl = `${env.PP_BASE_URL}/webhook/${user.id}`; + const errors: string[] = []; + + for (const config of configs) { + try { + await updateWebhookSecret(fullUser as any, config.repoOwner, config.repoName, config.giteaWebhookId!, webhookUrl, newSecret); + } catch (e: any) { + errors.push(`${config.repoOwner}/${config.repoName}: ${e.message}`); + } + } + + return makeResponse({ + ctr, content: { + code: 200, + message: errors.length ? `Secret regenerated with ${errors.length} hook update errors` : "Secret regenerated and all hooks updated", + data: { token: newSecret, errors }, + } + }); +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 00000000..0b4f5249 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,134 @@ +import { randomBytes } from "crypto"; +import bcrypt from "bcryptjs"; +import { Cookie } from "rjweb-server"; +import { prisma } from "../lib/db"; +import { makeResponse } from "../lib/response"; +import { ERROR_MESSAGES } from "../lib/errors"; +import { createLogger } from "../lib/logger"; + +const log = createLogger("AUTH_ROUTE"); +const COOKIE_NAME = "pp_session"; +const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +export async function loginHandler(ctr: any) { + let body: any; + try { + body = await ctr.body(); + } catch { + return makeResponse({ ctr, content: { code: 400, message: "Invalid body" } }); + } + + const { username, password } = body || {}; + if (!username || !password) { + return makeResponse({ ctr, content: { code: 400, message: "Username and password required" } }); + } + + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) { + return makeResponse({ ctr, content: { code: 401, message: "Invalid credentials" } }); + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + return makeResponse({ ctr, content: { code: 401, message: "Invalid credentials" } }); + } + + const sessionHash = randomBytes(32).toString("hex"); + await prisma.session.create({ data: { hash: sessionHash, userId: user.id } }); + + ctr.cookies.set( + COOKIE_NAME, + new Cookie(sessionHash, { + httpOnly: true, + expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), + path: "/", + sameSite: "Lax", + }), + ); + + return makeResponse({ ctr, content: { code: 200, data: { id: user.id, username: user.username, isAdmin: user.isAdmin } } }); +} + +export async function logoutHandler(ctr: any) { + const auth = ctr.getAuth?.(); + if (auth?.success) { + await prisma.session.delete({ where: { id: auth.sessionId } }).catch(() => {}); + } + ctr.cookies.set(COOKIE_NAME, new Cookie("", { expires: new Date(0), path: "/" })); + return makeResponse({ ctr, content: { code: 200, message: "Logged out" } }); +} + +export async function meHandler(ctr: any) { + const auth = ctr.getAuth?.(); + if (!auth?.success) { + return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); + } + + const user = await prisma.user.findUnique({ where: { id: auth.user.id } }); + if (!user) return makeResponse({ ctr, content: { code: 404, message: "User not found" } }); + + return makeResponse({ + ctr, + content: { + code: 200, + data: { + id: user.id, + username: user.username, + isAdmin: user.isAdmin, + isFounder: user.isFounder, + giteaUsername: user.giteaUsername, + giteaInstanceUrl: user.giteaInstanceUrl, + giteaPatSet: !!user.giteaPAT, + awsAccessKeyId: user.awsAccessKeyId ? "****" : null, + awsRegion: user.awsRegion, + awsConfigured: !!(user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion), + setupComplete: !!(user.giteaInstanceUrl && user.giteaPAT && user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion), + }, + }, + }); +} + +export async function setupStatusHandler(ctr: any) { + const count = await prisma.user.count(); + return makeResponse({ ctr, content: { code: 200, data: { needsSetup: count === 0 } } }); +} + +export async function firstUserHandler(ctr: any) { + const count = await prisma.user.count(); + if (count > 0) { + return makeResponse({ ctr, content: { code: 403, message: "Setup already completed. Contact an administrator to create your account." } }); + } + + let body: any; + try { + body = await ctr.body(); + } catch { + return makeResponse({ ctr, content: { code: 400, message: "Invalid body" } }); + } + + const { username, password } = body || {}; + if (!username || !password || password.length < 8) { + return makeResponse({ ctr, content: { code: 400, message: "Username and password (min 8 chars) required" } }); + } + + const hash = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ + data: { username, passwordHash: hash, isAdmin: true, isFounder: true }, + }); + + const sessionHash = randomBytes(32).toString("hex"); + await prisma.session.create({ data: { hash: sessionHash, userId: user.id } }); + + ctr.cookies.set( + COOKIE_NAME, + new Cookie(sessionHash, { + httpOnly: true, + expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), + path: "/", + sameSite: "Lax", + }), + ); + + log.info({ username }, "First user (founder) created"); + return makeResponse({ ctr, content: { code: 201, data: { id: user.id, username: user.username, isAdmin: true, isFounder: true } } }); +} diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts new file mode 100644 index 00000000..453e0542 --- /dev/null +++ b/backend/src/routes/webhook.ts @@ -0,0 +1,279 @@ +import { createHmac } from "crypto"; +import { prisma } from "../lib/db"; +import { createLogger } from "../lib/logger"; +import { getAdminSettings } from "../lib/adminSettings"; +import { buildPrCommentBody, postComment } from "../services/gitea"; +import { env } from "../lib/env"; + +const log = createLogger("WEBHOOK"); + +const rateLimitMap = new Map(); + +function checkRateLimit(userId: number, limitPerMin: number): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(userId); + if (!entry || now > entry.resetAt) { + rateLimitMap.set(userId, { count: 1, resetAt: now + 60_000 }); + return true; + } + if (entry.count >= limitPerMin) return false; + entry.count++; + return true; +} + +export async function webhookHandler(ctr: any) { + const userId = parseInt(ctr.params.get("userId") || "0", 10); + if (!userId) return ctr.status(400).print({ status: "FAILED", message: "Invalid user ID" }); + + const settings = await getAdminSettings(); + + if (!checkRateLimit(userId, settings.webhookRateLimitPerMinute)) { + return ctr.status(429).print({ status: "FAILED", message: "Rate limit exceeded" }); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return ctr.status(404).print({ status: "FAILED", message: "User not found" }); + + const webhookToken = await prisma.webhookToken.findUnique({ where: { userId } }); + if (!webhookToken) return ctr.status(401).print({ status: "FAILED", message: "No webhook token configured" }); + + const signature = ctr.headers.get("x-gitea-signature-256") || ctr.headers.get("x-hub-signature-256") || ""; + const rawBody = await ctr.$body().text(); + + const expected = "sha256=" + createHmac("sha256", webhookToken.token).update(rawBody).digest("hex"); + if (!timingSafeEqual(signature, expected)) { + log.warn({ userId, signature }, "Invalid webhook signature"); + return ctr.status(401).print({ status: "FAILED", message: "Invalid signature" }); + } + + let payload: any; + try { + payload = JSON.parse(rawBody); + } catch { + return ctr.status(400).print({ status: "FAILED", message: "Invalid JSON payload" }); + } + + const event = ctr.headers.get("x-gitea-event") || ""; + + ctr.status(200).print({ status: "OK" }); + + setImmediate(() => handleWebhookAsync(user, event, payload).catch(e => log.error({ e }, "Webhook processing error"))); +} + +async function handleWebhookAsync(user: any, event: string, payload: any) { + if (event === "pull_request") { + await handlePullRequestEvent(user, payload); + } else if (event === "issue_comment") { + await handleIssueCommentEvent(user, payload); + } +} + +async function handlePullRequestEvent(user: any, payload: any) { + const action = payload.action; + const pr = payload.pull_request; + const repo = payload.repository; + if (!pr || !repo) return; + + const owner = repo.owner?.login || repo.full_name?.split("/")[0]; + const repoName = repo.name; + const prNumber = pr.number; + const prTitle = pr.title || `PR #${prNumber}`; + const commitSha = pr.head?.sha || ""; + const cloneUrl = pr.head?.repo?.clone_url || repo.clone_url; + + const repoConfig = await prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true }, + }); + + if (!repoConfig) { + const existing = await prisma.noConfigComment.findUnique({ + where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } }, + }); + if (!existing) { + const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`; + try { + await postComment(user, owner, repoName, prNumber, body); + await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } }); + } catch {} + } + return; + } + + if (repoConfig.denyList.includes(pr.user?.login || "")) return; + + if (action === "opened" || action === "reopened") { + let preview = await prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" }, + }); + + if (preview && preview.status === "IGNORED") return; + + if (!preview || preview.status === "STOPPED") { + preview = await prisma.preview.create({ + data: { + repoConfigId: repoConfig.id, + prNumber, + prTitle, + commitSha, + status: "PROVISIONING", + port: repoConfig.port, + }, + }); + + await prisma.job.create({ + data: { + previewId: preview.id, + type: "DEPLOY", + status: "PENDING", + payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: true }, + }, + }); + } + } else if (action === "synchronize") { + const preview = await prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" }, + }); + + if (!preview || preview.status === "IGNORED") return; + + await prisma.preview.update({ where: { id: preview.id }, data: { lastActivityAt: new Date() } }); + + await prisma.job.create({ + data: { + previewId: preview.id, + type: "DEPLOY", + status: "PENDING", + payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: preview.status === "STOPPED" }, + }, + }); + } else if (action === "closed") { + const preview = await prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" }, + }); + + if (preview && preview.status !== "STOPPED" && preview.status !== "IGNORED") { + await prisma.job.create({ + data: { + previewId: preview.id, + type: "STOP", + status: "PENDING", + payload: { reason: "PR closed" }, + }, + }); + } + } +} + +async function handleIssueCommentEvent(user: any, payload: any) { + const action = payload.action; + if (action !== "created") return; + + const comment = payload.comment; + const issue = payload.issue; + const repo = payload.repository; + if (!comment || !issue || !repo || !issue.pull_request) return; + + const body = comment.body || ""; + if (!body.trimStart().startsWith("/pp ")) return; + + const owner = repo.owner?.login || repo.full_name?.split("/")[0]; + const repoName = repo.name; + const prNumber = issue.number; + + if (user.giteaUsername && comment.user?.login === user.giteaUsername) return; + + const repoConfig = await prisma.repoConfig.findFirst({ + where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true }, + }); + if (!repoConfig) return; + + const commenter = comment.user?.login || ""; + const prAuthor = issue.user?.login || ""; + const isAllowed = commenter === prAuthor || (await isRepoAdmin(user, owner, repoName, commenter)); + if (!isAllowed) return; + + await prisma.preview.updateMany({ + where: { + repoConfigId: repoConfig.id, + prNumber, + status: { in: ["RUNNING", "BUILDING", "FAILED", "PROVISIONING"] }, + }, + data: { lastActivityAt: new Date() }, + }); + + const commandLine = body.trimStart().split("\n")[0].trim(); + const command = commandLine.replace("/pp ", "").trim(); + + const preview = await prisma.preview.findFirst({ + where: { repoConfigId: repoConfig.id, prNumber }, + orderBy: { createdAt: "desc" }, + }); + + const cloneUrl = repo.clone_url; + + if (command === "rebuild") { + if (!preview || (preview.status !== "RUNNING" && preview.status !== "FAILED")) return; + await prisma.job.create({ + data: { + previewId: preview.id, + type: "DEPLOY", + status: "PENDING", + payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false }, + }, + }); + } else if (command === "stop") { + if (!preview || preview.status === "STOPPED") return; + await prisma.job.create({ + data: { previewId: preview.id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop" } }, + }); + } else if (command === "start") { + if (!preview) { + const newPreview = await prisma.preview.create({ + data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "PROVISIONING", port: repoConfig.port }, + }); + await prisma.job.create({ + data: { previewId: newPreview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: "", prNumber, prTitle: issue.title, cloneUrl, isFirstDeploy: true } }, + }); + } else if (preview.status === "STOPPED" || preview.status === "IGNORED") { + await prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING" } }); + await prisma.job.create({ + data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } }, + }); + } + } else if (command === "logs") { + if (!preview) return; + const lastLines = (preview.logs || "").split("\n").slice(-50).join("\n"); + const logBody = `**PP Logs** (last 50 lines)\n\`\`\`\n${lastLines}\n\`\`\``; + await postComment(user, owner, repoName, prNumber, logBody); + } else if (command === "ignore") { + if (!preview) { + await prisma.preview.create({ + data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "IGNORED", port: repoConfig.port }, + }); + } else { + await prisma.preview.update({ where: { id: preview.id }, data: { status: "IGNORED" } }); + } + } +} + +async function isRepoAdmin(user: any, owner: string, repo: string, username: string): Promise { + try { + const { getRepoCollaboratorPermission } = await import("../services/gitea"); + const permission = await getRepoCollaboratorPermission(user, owner, repo, username); + return permission === "owner" || permission === "admin"; + } catch { + return false; + } +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} diff --git a/backend/src/services/deploy.ts b/backend/src/services/deploy.ts new file mode 100644 index 00000000..ebf502dc --- /dev/null +++ b/backend/src/services/deploy.ts @@ -0,0 +1,466 @@ +import { prisma } from "../lib/db"; +import { createLogger } from "../lib/logger"; +import { decrypt, encrypt } from "../lib/encryption"; +import { getAdminSettings } from "../lib/adminSettings"; +import { env } from "../lib/env"; +import { + makeEc2Client, + generateAndImportKeyPair, + createPreviewSecurityGroup, + launchInstance, + waitForInstanceRunning, + terminateInstance, + deleteKeyPairAws, + deleteSecurityGroupAws, +} from "./ec2"; +import { connectSsh, type SshSession } from "./ssh"; +import { + buildPrCommentBody, + postComment, + updateComment, +} from "./gitea"; +import type { Preview, RepoConfig, User } from "@prisma/client"; + +const log = createLogger("DEPLOY"); + +const activeSshSessions = new Map(); + +export function abortJobForPreview(previewId: number) { + const session = activeSshSessions.get(previewId); + if (session) { + session.abort(); + activeSshSessions.delete(previewId); + } +} + +export async function appendLog(previewId: number, text: string) { + const settings = await getAdminSettings(); + const preview = await prisma.preview.findUnique({ where: { id: previewId } }); + if (!preview) return; + + let logs = (preview.logs || "") + text; + if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) { + const marker = "--- logs truncated ---\n"; + while (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) { + const nl = logs.indexOf("\n"); + if (nl === -1) break; + logs = logs.slice(nl + 1); + } + logs = marker + logs; + } + + await prisma.preview.update({ where: { id: previewId }, data: { logs } }); + + broadcastLogUpdate(previewId, text); +} + +const logSubscribers = new Map void>>(); + +export function subscribeToLogs(previewId: number, cb: (text: string) => void): () => void { + if (!logSubscribers.has(previewId)) logSubscribers.set(previewId, new Set()); + logSubscribers.get(previewId)!.add(cb); + return () => logSubscribers.get(previewId)?.delete(cb); +} + +function broadcastLogUpdate(previewId: number, text: string) { + logSubscribers.get(previewId)?.forEach(cb => cb(text)); +} + +async function updateStatus(previewId: number, status: Preview["status"], extra: Partial = {}) { + await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } }); +} + +async function updateGiteaComment(user: User, preview: Preview, repoConfig: RepoConfig, statusLine: string, lastLogLines?: string) { + const body = buildPrCommentBody({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber: preview.prNumber, + status: statusLine, + commitSha: preview.commitSha, + updatedAt: new Date(), + ppBaseUrl: env.PP_BASE_URL, + lastLogLines, + instanceIp: preview.instanceIp ?? undefined, + port: preview.port, + }); + + if (preview.giteaCommentId) { + try { + await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body); + } catch (e) { + log.warn({ e }, "Failed to update Gitea comment"); + } + } +} + +export async function runDeploy(jobId: number) { + const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } }); + if (!job || !job.preview) { + log.error({ jobId }, "Job or preview not found"); + return; + } + + await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } }); + + const preview = job.preview as any; + const repoConfig: RepoConfig = preview.repoConfig; + const user: User = (preview.repoConfig as any).user; + + const payload = job.payload as any; + const { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy } = payload; + + try { + if (isFirstDeploy) { + await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl); + } else { + await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle); + } + + await prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: new Date() } }); + } catch (e: any) { + if (e.message === "ABORTED") { + log.info({ jobId, previewId: preview.id }, "Job aborted"); + await prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: "Aborted by newer deploy", finishedAt: new Date() } }); + return; + } + log.error({ e, jobId, previewId: preview.id }, "Deploy failed"); + await prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: e.message, finishedAt: new Date() } }); + + const freshPreview = await prisma.preview.findUnique({ where: { id: preview.id } }); + if (!freshPreview) return; + + const lastLines = (freshPreview.logs || "").split("\n").slice(-10).join("\n"); + await updateStatus(preview.id, "FAILED"); + + const failBody = buildPrCommentBody({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber: freshPreview.prNumber, + status: `🔴 Failed — last log lines:\n\`\`\`\n${lastLines}\n\`\`\``, + commitSha: freshPreview.commitSha, + updatedAt: new Date(), + ppBaseUrl: env.PP_BASE_URL, + }); + + if (freshPreview.giteaCommentId) { + try { + await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, freshPreview.giteaCommentId, failBody); + } catch {} + } + } +} + +async function firstDeploy( + jobId: number, + preview: Preview & { repoConfig: RepoConfig }, + repoConfig: RepoConfig, + user: User, + commitSha: string, + prNumber: number, + prTitle: string, + cloneUrl: string, +) { + const settings = await getAdminSettings(); + const ec2 = makeEc2Client(user); + const previewId = preview.id; + + const activeCount = await prisma.preview.count({ + where: { + repoConfig: { userId: user.id }, + status: { in: ["PROVISIONING", "BUILDING", "RUNNING"] }, + id: { not: previewId }, + }, + }); + + if (activeCount >= settings.maxConcurrentInstancesPerUser) { + const body = buildPrCommentBody({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber, + status: `🔴 Cannot provision preview — concurrent instance limit (${settings.maxConcurrentInstancesPerUser}) reached.`, + commitSha, + updatedAt: new Date(), + ppBaseUrl: env.PP_BASE_URL, + }); + const commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, body); + await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } }); + throw new Error("Concurrent instance limit reached"); + } + + await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle }); + + const commentBody = buildPrCommentBody({ + owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, + status: "🟡 Provisioning EC2 instance...", + commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL, + }); + let commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody); + await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } }); + + checkAbort(previewId); + + const keyName = `pp-preview-${previewId}`; + const { privateKey } = await generateAndImportKeyPair(ec2, keyName); + const encPrivateKey = encrypt(privateKey); + + await prisma.preview.update({ where: { id: previewId }, data: { sshPrivateKey: encPrivateKey, sshKeyName: keyName } }); + + checkAbort(previewId); + + const sgName = `pp-preview-${previewId}`; + const securityGroupId = await createPreviewSecurityGroup(ec2, sgName, repoConfig.port); + + checkAbort(previewId); + + const instanceId = await launchInstance({ + ec2, region: user.awsRegion!, + instanceType: repoConfig.instanceType, + keyName, + securityGroupId, + tags: { + "pp:managed": "true", + "pp:userId": String(user.id), + "pp:repo": `${repoConfig.repoOwner}/${repoConfig.repoName}`, + "pp:prNumber": String(prNumber), + "pp:previewId": String(previewId), + }, + }); + + await prisma.preview.update({ where: { id: previewId }, data: { instanceId } }); + + await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\n`); + + checkAbort(previewId); + + const instanceIp = await waitForInstanceRunning(ec2, instanceId); + await prisma.preview.update({ where: { id: previewId }, data: { instanceIp, port: repoConfig.port } }); + + await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId, + buildPrCommentBody({ + owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, + status: `🟡 Building... (EC2 ready at ${instanceIp})`, + commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL, + }) + ); + + await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...\n`); + + checkAbort(previewId); + + const sshSession = await connectSsh(instanceIp, privateKey, 300_000); + activeSshSessions.set(previewId, sshSession); + + try { + if (repoConfig.aptPackages.length > 0) { + await runSshStep(previewId, sshSession, `sudo apt-get install -y ${repoConfig.aptPackages.join(" ")}`); + } + + const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : ""; + const authCloneUrl = cloneUrl.replace("https://", `https://${user.giteaUsername}:${giteaPat}@`); + await runSshStep(previewId, sshSession, `git clone ${authCloneUrl} /opt/app`); + await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`); + + await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true); + + await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() }); + + const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } }); + await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId, + buildPrCommentBody({ + owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, + status: `🟢 Live at http://${instanceIp}:${repoConfig.port}`, + commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL, + }) + ); + } finally { + sshSession.close(); + activeSshSessions.delete(previewId); + } +} + +async function redeploy( + jobId: number, + preview: Preview & { repoConfig: RepoConfig }, + repoConfig: RepoConfig, + user: User, + commitSha: string, + prNumber: number, + prTitle: string, +) { + const previewId = preview.id; + const instanceIp = preview.instanceIp!; + const privateKey = decrypt(preview.sshPrivateKey!); + + const sshSession = await connectSsh(instanceIp, privateKey, 30_000); + activeSshSessions.set(previewId, sshSession); + + await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`); + + const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } }); + + const commentBody = buildPrCommentBody({ + owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, + status: `🟡 Building... (EC2 at ${instanceIp})`, + commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL, + }); + const newCommentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody); + await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: newCommentId, commitSha } }); + + try { + if (repoConfig.useDockerCompose) { + const composePath = repoConfig.composeFilePath || "docker-compose.yml"; + await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`); + } else if (freshPreview?.pid) { + await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`); + } + + await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`); + + await updateStatus(previewId, "BUILDING"); + await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false); + + await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() }); + + await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, newCommentId, + buildPrCommentBody({ + owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, + status: `🟢 Live at http://${instanceIp}:${repoConfig.port}`, + commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL, + }) + ); + } finally { + sshSession.close(); + activeSshSessions.delete(previewId); + } +} + +async function setupAndBuild( + previewId: number, + sshSession: SshSession, + repoConfig: RepoConfig, + preview: Preview, + commitSha: string, + isFirstProvision: boolean, +) { + await detectAndUseNode(previewId, sshSession); + + const envVars = repoConfig.envVars as Record; + const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n"); + await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF'\n${envContent}\nPPEOF`); + + if (isFirstProvision) { + for (const cmd of repoConfig.setupCommands) { + await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); + } + } + + await updateStatus(previewId, "BUILDING"); + + if (repoConfig.useDockerCompose) { + const composePath = repoConfig.composeFilePath || "docker-compose.yml"; + await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`); + } else { + for (const cmd of repoConfig.buildCommands) { + await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); + } + for (const cmd of repoConfig.postBuildCommands) { + await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); + } + + if (repoConfig.runCommand) { + const res = await runSshStep(previewId, sshSession, + `cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!` + ); + const pid = parseInt(res.stdout.trim(), 10); + if (!isNaN(pid)) { + await prisma.preview.update({ where: { id: previewId }, data: { pid } }); + } + } + } +} + +async function detectAndUseNode(previewId: number, sshSession: SshSession) { + const nvmSource = `export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"`; + const res = await runSshStep(previewId, sshSession, `${nvmSource} && [ -f /opt/app/.nvmrc ] && nvm install && nvm use || nvm use default 2>&1`, false); +} + +async function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) { + checkAbortSession(sshSession); + await appendLog(previewId, `$ ${command}\n`); + const res = await sshSession.exec(command); + if (res.stdout) await appendLog(previewId, res.stdout); + if (res.stderr) await appendLog(previewId, res.stderr); + if (throwOnFail && res.code !== 0) { + throw new Error(`Command failed with exit code ${res.code}: ${command}`); + } + return res; +} + +function checkAbortSession(session: SshSession) { + if (session.aborted) throw new Error("ABORTED"); +} + +const abortSignals = new Set(); + +export function signalAbort(previewId: number) { + abortSignals.add(previewId); + abortJobForPreview(previewId); +} + +function checkAbort(previewId: number) { + if (abortSignals.has(previewId)) { + abortSignals.delete(previewId); + throw new Error("ABORTED"); + } +} + +export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED" = "STOPPED") { + const preview = await prisma.preview.findUnique({ + where: { id: previewId }, + include: { repoConfig: { include: { user: true } } }, + }); + if (!preview) return; + + const user = (preview.repoConfig as any).user as User; + const repoConfig = preview.repoConfig; + + if (preview.instanceId) { + const ec2 = makeEc2Client(user); + try { + await deleteKeyPairAws(ec2, `pp-preview-${previewId}`); + } catch {} + try { + await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`); + } catch {} + try { + await terminateInstance(ec2, preview.instanceId); + } catch {} + } + + await prisma.preview.update({ + where: { id: previewId }, + data: { + status: reason, + stoppedAt: new Date(), + sshPrivateKey: null, + sshKeyName: null, + instanceId: null, + }, + }); + + const stoppedBody = buildPrCommentBody({ + owner: repoConfig.repoOwner, + repo: repoConfig.repoName, + prNumber: preview.prNumber, + status: "⚫ Stopped (inactivity timeout / PR closed / manual stop)", + commitSha: preview.commitSha, + updatedAt: new Date(), + ppBaseUrl: env.PP_BASE_URL, + }); + + if (preview.giteaCommentId) { + try { + await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, stoppedBody); + } catch {} + } +} diff --git a/backend/src/services/ec2.ts b/backend/src/services/ec2.ts new file mode 100644 index 00000000..91e5ce0f --- /dev/null +++ b/backend/src/services/ec2.ts @@ -0,0 +1,242 @@ +import { + EC2Client, + RunInstancesCommand, + TerminateInstancesCommand, + DescribeInstancesCommand, + CreateSecurityGroupCommand, + DeleteSecurityGroupCommand, + AuthorizeSecurityGroupIngressCommand, + DescribeSecurityGroupsCommand, + ImportKeyPairCommand, + DeleteKeyPairCommand, + CreateTagsCommand, +} from "@aws-sdk/client-ec2"; +import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; +import { generateKeyPairSync } from "crypto"; +import { createLogger } from "../lib/logger"; +import { decrypt } from "../lib/encryption"; +import type { User } from "@prisma/client"; + +const log = createLogger("EC2"); + +const UBUNTU_22_04_AMI: Record = { + "us-east-1": "ami-0e86e20dae9224db8", + "us-east-2": "ami-0a0d9cf81c479446a", + "us-west-1": "ami-05c969369880fa2c2", + "us-west-2": "ami-03f8acd418785369b", + "eu-west-1": "ami-0694d931cee176e7d", + "eu-west-2": "ami-0f3d9639a5674d559", + "eu-west-3": "ami-022e307f4b9e39f45", + "eu-central-1": "ami-0faab6bdbac9486fb", + "ap-southeast-1": "ami-0823c236601fef765", + "ap-southeast-2": "ami-07620139298af599e", + "ap-northeast-1": "ami-0b7546e839d7ace12", + "ap-northeast-2": "ami-042e76978adeb8c48", + "ap-south-1": "ami-076e3a557efe1aa9c", + "sa-east-1": "ami-0eed58016fbe42de3", + "ca-central-1": "ami-024f768de9e73d4f4", + "eu-north-1": "ami-00381a880aa48c6c6", + "me-south-1": "ami-09574f34b8dcd2eac", + "af-south-1": "ami-08fdcf06b39fe83ec", +}; + +export function makeEc2Client(user: User): EC2Client { + const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : ""; + const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : ""; + return new EC2Client({ + region: user.awsRegion!, + credentials: { accessKeyId, secretAccessKey }, + }); +} + +export function makeStsClient(user: User): STSClient { + const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : ""; + const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : ""; + return new STSClient({ + region: user.awsRegion!, + credentials: { accessKeyId, secretAccessKey }, + }); +} + +export async function validateAwsCredentials(user: User): Promise<{ success: boolean; arn?: string; error?: string }> { + try { + const sts = makeStsClient(user); + const res = await sts.send(new GetCallerIdentityCommand({})); + return { success: true, arn: res.Arn }; + } catch (e: any) { + return { success: false, error: e.message }; + } +} + +export function generateSshKeyPair(): { privateKey: string; publicKey: string } { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey); + return { privateKey, publicKey: pubKeyOpenSsh }; +} + +function rsaPemToOpenSsh(pem: string): string { + const { publicKeyEncoding } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs8", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + void publicKeyEncoding; + const der = Buffer.from( + pem.replace(/-----BEGIN RSA PUBLIC KEY-----/, "") + .replace(/-----END RSA PUBLIC KEY-----/, "") + .replace(/\n/g, ""), + "base64" + ); + const type = Buffer.from("ssh-rsa"); + function encodeBuffer(buf: Buffer): Buffer { + const len = Buffer.allocUnsafe(4); + len.writeUInt32BE(buf.length, 0); + return Buffer.concat([len, buf]); + } + const typeEncoded = encodeBuffer(type); + const rsaKeyData = der; + const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64"); + return `ssh-rsa ${base64Key} pp-generated`; +} + +export async function generateAndImportKeyPair(ec2: EC2Client, keyName: string): Promise<{ privateKey: string }> { + const { privateKey, publicKey } = generateSshKeyPair(); + await ec2.send(new ImportKeyPairCommand({ + KeyName: keyName, + PublicKeyMaterial: Buffer.from(publicKey), + })); + return { privateKey }; +} + +export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: string, port: number): Promise { + const describe = await ec2.send(new DescribeSecurityGroupsCommand({ + Filters: [{ Name: "group-name", Values: [groupName] }], + })); + if (describe.SecurityGroups && describe.SecurityGroups.length > 0) { + return describe.SecurityGroups[0].GroupId!; + } + + const res = await ec2.send(new CreateSecurityGroupCommand({ + GroupName: groupName, + Description: `PP Preview security group: ${groupName}`, + })); + const groupId = res.GroupId!; + + await ec2.send(new AuthorizeSecurityGroupIngressCommand({ + GroupId: groupId, + IpPermissions: [ + { + IpProtocol: "tcp", + FromPort: 22, + ToPort: 22, + IpRanges: [{ CidrIp: "0.0.0.0/0" }], + }, + { + IpProtocol: "tcp", + FromPort: port, + ToPort: port, + IpRanges: [{ CidrIp: "0.0.0.0/0" }], + }, + ], + })); + return groupId; +} + +const BOOTSTRAP_SCRIPT = `#!/bin/bash +set -e +apt-get update -y +apt-get install -y curl git unzip build-essential +curl -fsSL https://get.docker.com | sh +systemctl enable docker +systemctl start docker +apt-get install -y docker-compose-plugin +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +export NVM_DIR="/root/.nvm" +source "$NVM_DIR/nvm.sh" +nvm install --lts +nvm alias default lts/* +`; + +export async function launchInstance(opts: { + ec2: EC2Client; + region: string; + instanceType: string; + keyName: string; + securityGroupId: string; + tags: Record; +}): Promise { + const ami = UBUNTU_22_04_AMI[opts.region] ?? UBUNTU_22_04_AMI["us-east-1"]; + const tagSpecs = Object.entries(opts.tags).map(([k, v]) => ({ Key: k, Value: v })); + tagSpecs.push({ Key: "Name", Value: `pp-preview-${opts.tags["pp:previewId"]}` }); + + const res = await opts.ec2.send(new RunInstancesCommand({ + ImageId: ami, + InstanceType: opts.instanceType as any, + MinCount: 1, + MaxCount: 1, + KeyName: opts.keyName, + SecurityGroupIds: [opts.securityGroupId], + UserData: Buffer.from(BOOTSTRAP_SCRIPT).toString("base64"), + TagSpecifications: [ + { ResourceType: "instance", Tags: tagSpecs }, + ], + })); + + return res.Instances![0].InstanceId!; +} + +export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + const res = await ec2.send(new DescribeInstancesCommand({ + InstanceIds: [instanceId], + })); + const inst = res.Reservations?.[0]?.Instances?.[0]; + if (inst?.State?.Name === "running" && inst.PublicIpAddress) { + return inst.PublicIpAddress; + } + await sleep(5000); + } + throw new Error(`Instance ${instanceId} did not reach running state within timeout`); +} + +export async function terminateInstance(ec2: EC2Client, instanceId: string): Promise { + await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] })); +} + +export async function deleteKeyPairAws(ec2: EC2Client, keyName: string): Promise { + try { + await ec2.send(new DeleteKeyPairCommand({ KeyName: keyName })); + } catch (e) { + log.warn({ e, keyName }, "Failed to delete key pair"); + } +} + +export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string): Promise { + try { + const describe = await ec2.send(new DescribeSecurityGroupsCommand({ + Filters: [{ Name: "group-name", Values: [groupName] }], + })); + const groupId = describe.SecurityGroups?.[0]?.GroupId; + if (groupId) { + await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId })); + } + } catch (e) { + log.warn({ e, groupName }, "Failed to delete security group"); + } +} + +export async function describeAllManagedInstances(ec2: EC2Client): Promise { + const res = await ec2.send(new DescribeInstancesCommand({ + Filters: [{ Name: "tag:pp:managed", Values: ["true"] }, { Name: "instance-state-name", Values: ["running", "pending", "stopping", "stopped"] }], + })); + return (res.Reservations ?? []).flatMap(r => r.Instances ?? []); +} + +function sleep(ms: number) { + return new Promise(r => setTimeout(r, ms)); +} diff --git a/backend/src/services/gitea.ts b/backend/src/services/gitea.ts new file mode 100644 index 00000000..5e1ba84a --- /dev/null +++ b/backend/src/services/gitea.ts @@ -0,0 +1,133 @@ +import axios from "axios"; +import { decrypt } from "../lib/encryption"; +import type { User } from "@prisma/client"; + +export function giteaApi(user: User) { + const pat = user.giteaPAT ? decrypt(user.giteaPAT) : ""; + return axios.create({ + baseURL: `${user.giteaInstanceUrl}/api/v1`, + headers: { + Authorization: `token ${pat}`, + "Content-Type": "application/json", + }, + timeout: 15000, + }); +} + +export async function validateGiteaUrl(url: string): Promise<{ success: boolean; version?: string; error?: string }> { + try { + const res = await axios.get(`${url}/api/v1/version`, { timeout: 10000 }); + return { success: true, version: res.data.version }; + } catch (e: any) { + return { success: false, error: e.message }; + } +} + +export async function fetchUserRepos(user: User): Promise { + const api = giteaApi(user); + const repos: any[] = []; + let page = 1; + while (true) { + const res = await api.get(`/repos/search?limit=50&page=${page}`); + const data = res.data?.data ?? []; + if (data.length === 0) break; + repos.push(...data); + if (data.length < 50) break; + page++; + } + return repos; +} + +export async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise { + const api = giteaApi(user); + const res = await api.post(`/repos/${owner}/${repo}/hooks`, { + type: "gitea", + config: { + url: webhookUrl, + secret, + content_type: "json", + }, + events: ["pull_request", "issue_comment"], + active: true, + }); + return res.data.id; +} + +export async function deleteWebhook(user: User, owner: string, repo: string, hookId: string): Promise { + const api = giteaApi(user); + await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`); +} + +export async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise { + const api = giteaApi(user); + await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, { + config: { + url: webhookUrl, + secret: newSecret, + content_type: "json", + }, + events: ["pull_request", "issue_comment"], + active: true, + }); +} + +export async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise { + const api = giteaApi(user); + const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body }); + return res.data.id; +} + +export async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise { + const api = giteaApi(user); + await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body }); +} + +export async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise { + try { + const api = giteaApi(user); + const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`); + return res.status === 204; + } catch { + return false; + } +} + +export async function getRepoCollaboratorPermission(user: User, owner: string, repo: string, username: string): Promise { + try { + const api = giteaApi(user); + const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`); + return res.data?.permission ?? null; + } catch { + return null; + } +} + +export function buildPrCommentBody(opts: { + owner: string; + repo: string; + prNumber: number; + status: string; + commitSha: string; + updatedAt: Date; + ppBaseUrl: string; + lastLogLines?: string; + instanceIp?: string; + port?: number; +}): string { + const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts; + const ts = updatedAt.toISOString().replace("T", " ").slice(0, 19) + " UTC"; + + let statusLine = status; + if (lastLogLines) { + statusLine += `\n\n\`\`\`\n${lastLogLines}\n\`\`\``; + } + + return `## 🚀 PR Preview — \`${owner}/${repo}\` #${prNumber} + +**Status:** ${statusLine} +**Commit:** \`${commitSha.slice(0, 8)}\` +**Updated:** ${ts} + +--- +_Powered by [PR Previews](${ppBaseUrl})_`; +} diff --git a/backend/src/services/ssh.ts b/backend/src/services/ssh.ts new file mode 100644 index 00000000..8aa1b767 --- /dev/null +++ b/backend/src/services/ssh.ts @@ -0,0 +1,90 @@ +import { Client } from "ssh2"; +import { createLogger } from "../lib/logger"; + +const log = createLogger("SSH"); + +export interface SshSession { + exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>; + close(): void; + aborted: boolean; + abort(): void; +} + +export async function connectSsh(host: string, privateKey: string, maxWaitMs = 300_000): Promise { + const start = Date.now(); + + while (Date.now() - start < maxWaitMs) { + try { + const conn = await tryConnect(host, privateKey, 10000); + let aborted = false; + + return { + get aborted() { return aborted; }, + abort() { + aborted = true; + try { conn.end(); } catch {} + }, + async exec(command: string) { + if (aborted) throw new Error("SSH session aborted"); + return execOnConn(conn, command); + }, + close() { + try { conn.end(); } catch {} + }, + }; + } catch (e: any) { + if (Date.now() - start > maxWaitMs) throw e; + log.debug({ host, error: e.message }, "SSH connect retry"); + await sleep(5000); + } + } + throw new Error(`Could not SSH into ${host} within timeout`); +} + +function tryConnect(host: string, privateKey: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const conn = new Client(); + const timer = setTimeout(() => { + conn.end(); + reject(new Error(`SSH connection to ${host} timed out`)); + }, timeoutMs); + + conn.on("ready", () => { + clearTimeout(timer); + resolve(conn); + }); + conn.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + conn.connect({ + host, + port: 22, + username: "ubuntu", + privateKey, + readyTimeout: timeoutMs, + algorithms: { + serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"], + }, + }); + }); +} + +function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve, reject) => { + conn.exec(command, (err, stream) => { + if (err) return reject(err); + let stdout = ""; + let stderr = ""; + stream.on("data", (d: Buffer) => { stdout += d.toString(); }); + stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); + stream.on("close", (code: number) => { + resolve({ stdout, stderr, code: code ?? 0 }); + }); + }); + }); +} + +function sleep(ms: number) { + return new Promise(r => setTimeout(r, ms)); +} diff --git a/backend/src/workers/cronWorker.ts b/backend/src/workers/cronWorker.ts new file mode 100644 index 00000000..1dffb013 --- /dev/null +++ b/backend/src/workers/cronWorker.ts @@ -0,0 +1,76 @@ +import cron from "node-cron"; +import { prisma } from "../lib/db"; +import { createLogger } from "../lib/logger"; +import { getAdminSettings } from "../lib/adminSettings"; + +const log = createLogger("CRON"); + +export function startCronWorkers() { + // Inactivity check every 30 minutes + cron.schedule("*/30 * * * *", async () => { + try { + await checkInactivity(); + } catch (e) { + log.error({ e }, "Inactivity check error"); + } + }); + + // Daily cleanup + cron.schedule("0 3 * * *", async () => { + try { + await dailyCleanup(); + } catch (e) { + log.error({ e }, "Daily cleanup error"); + } + }); + + log.info("Cron workers started"); +} + +async function checkInactivity() { + const settings = await getAdminSettings(); + const now = new Date(); + + const running = await prisma.preview.findMany({ + where: { status: "RUNNING" }, + }); + + for (const preview of running) { + const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig + const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } }); + if (!repoConfig) continue; + + const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000); + if (now >= deadline) { + log.info({ previewId: preview.id }, "Preview inactive, enqueuing INACTIVITY_STOP"); + await prisma.job.create({ + data: { + previewId: preview.id, + type: "INACTIVITY_STOP", + status: "PENDING", + payload: {}, + }, + }); + } + } +} + +async function dailyCleanup() { + const settings = await getAdminSettings(); + const cutoff = new Date(Date.now() - settings.previewRetentionDays * 86400 * 1000); + + const old = await prisma.preview.findMany({ + where: { + status: { in: ["STOPPED", "FAILED"] }, + stoppedAt: { lt: cutoff }, + }, + select: { id: true }, + }); + + for (const { id } of old) { + await prisma.job.deleteMany({ where: { previewId: id } }); + await prisma.preview.delete({ where: { id } }); + } + + if (old.length > 0) log.info({ count: old.length }, "Cleaned up old previews"); +} diff --git a/backend/src/workers/jobWorker.ts b/backend/src/workers/jobWorker.ts new file mode 100644 index 00000000..077ed47d --- /dev/null +++ b/backend/src/workers/jobWorker.ts @@ -0,0 +1,99 @@ +import { prisma } from "../lib/db"; +import { createLogger } from "../lib/logger"; +import { runDeploy, stopPreview, signalAbort } from "../services/deploy"; + +const log = createLogger("JOB_WORKER"); +let running = false; + +export async function startJobWorker() { + if (running) return; + running = true; + log.info("Job worker started"); + + await resetStuckJobs(); + pollLoop(); +} + +async function resetStuckJobs() { + const count = await prisma.job.updateMany({ + where: { status: "RUNNING" }, + data: { status: "PENDING", startedAt: null }, + }); + if (count.count > 0) log.info({ count: count.count }, "Reset stuck running jobs to PENDING"); +} + +async function pollLoop() { + while (running) { + try { + await processPendingJobs(); + } catch (e) { + log.error({ e }, "Job worker poll error"); + } + await sleep(1000); + } +} + +const activePreviewJobs = new Map(); + +async function processPendingJobs() { + const pending = await prisma.job.findMany({ + where: { status: "PENDING" }, + orderBy: { createdAt: "asc" }, + take: 20, + }); + + for (const job of pending) { + const previewId = job.previewId; + if (!previewId) continue; + + if (activePreviewJobs.has(previewId)) { + const existingJobId = activePreviewJobs.get(previewId)!; + + if (job.type === "DEPLOY") { + log.info({ previewId, newJobId: job.id, abortingJobId: existingJobId }, "New deploy cancels existing"); + signalAbort(previewId); + await sleep(500); + } else { + continue; + } + } + + activePreviewJobs.set(previewId, job.id); + + processJob(job).finally(() => { + if (activePreviewJobs.get(previewId) === job.id) { + activePreviewJobs.delete(previewId); + } + }); + } +} + +async function processJob(job: { id: number; type: string; previewId: number | null; payload: any }) { + log.info({ jobId: job.id, type: job.type, previewId: job.previewId }, "Processing job"); + + try { + if (job.type === "DEPLOY") { + await runDeploy(job.id); + } else if (job.type === "STOP" || job.type === "INACTIVITY_STOP") { + if (job.previewId) { + await prisma.job.update({ where: { id: job.id }, data: { status: "RUNNING", startedAt: new Date() } }); + await stopPreview(job.previewId); + await prisma.job.update({ where: { id: job.id }, data: { status: "DONE", finishedAt: new Date() } }); + } + } + } catch (e: any) { + log.error({ e, jobId: job.id }, "Job processing error"); + await prisma.job.update({ + where: { id: job.id }, + data: { status: "FAILED", error: e.message, finishedAt: new Date() }, + }).catch(() => {}); + } +} + +export function stopJobWorker() { + running = false; +} + +function sleep(ms: number) { + return new Promise(r => setTimeout(r, ms)); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 00000000..6c1846dc --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..16a4f23e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +version: "3.9" + +services: + pp-db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: pp + POSTGRES_PASSWORD: pp_password + POSTGRES_DB: pp + volumes: + - pp_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pp"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "5432:5432" + + pp-backend: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + environment: + NODE_ENV: production + DATABASE_URL: postgresql://pp:pp_password@pp-db:5432/pp + PORT: 5000 + SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required} + PP_BASE_URL: ${PP_BASE_URL:?PP_BASE_URL is required} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required} + LOG_LEVEL: info + depends_on: + pp-db: + condition: service_healthy + ports: + - "5000:5000" + volumes: + - ./prisma:/app/prisma + +volumes: + pp_db_data: diff --git a/example.env b/example.env new file mode 100644 index 00000000..69a50fd1 --- /dev/null +++ b/example.env @@ -0,0 +1,21 @@ +# PP (PR Previews) - Environment Variables +# Copy to .env and fill in values + +# PostgreSQL connection string +DATABASE_URL=postgresql://pp:pp_password@localhost:5432/pp + +# Session signing secret (at least 32 random chars) +SESSION_SECRET=change_me_to_a_random_32_character_string + +# Public URL of this PP instance (no trailing slash) +PP_BASE_URL=https://pp.example.com + +# AES-256 encryption key — 64 hex chars (32 bytes) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 + +# Backend port (default: 5000) +PORT=5000 + +# Log level: trace | debug | info | warn | error +LOG_LEVEL=info diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..5e29bc9b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + PR Previews + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..f4380d55 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "pp-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "react-toastify": "^11.0.0", + "motion": "^12.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..6c36fa83 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1790 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + motion: + specifier: ^12.0.0 + version: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8(react@19.2.8) + react-router-dom: + specifier: ^7.0.0 + version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-toastify: + specifier: ^11.0.0 + version: 11.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@6.4.3(jiti@1.21.7)) + autoprefixer: + specifier: ^10.4.0 + version: 10.5.4(postcss@8.5.23) + postcss: + specifier: ^8.4.0 + version: 8.5.23 + tailwindcss: + specifier: ^3.4.0 + version: 3.4.19 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.3(jiti@1.21.7) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + autoprefixer@10.5.4: + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + electron-to-chromium@1.5.396: + resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.42.2: + resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-toastify@11.1.0: + resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@1.21.7))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + autoprefixer@10.5.4(postcss@8.5.23): + dependencies: + browserslist: 4.28.7 + caniuse-lite: 1.0.30001806 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.23 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.11.1: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.396 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001806: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + clsx@2.1.1: {} + + commander@4.1.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + electron-to-chromium@1.5.396: {} + + es-errors@1.3.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fraction.js@5.3.4: {} + + framer-motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.23): + dependencies: + postcss: 8.5.23 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.23): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.23 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.23): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.23 + + postcss-nested@6.2.0(postcss@8.5.23): + dependencies: + postcss: 8.5.23 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.23: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + queue-microtask@1.2.3: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react-toastify@11.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + clsx: 2.1.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + source-map-js@1.2.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.23 + postcss-import: 15.1.0(postcss@8.5.23) + postcss-js: 4.1.0(postcss@8.5.23) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.23) + postcss-nested: 6.2.0(postcss@8.5.23) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite@6.4.3(jiti@1.21.7): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.23 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + jiti: 1.21.7 + + yallist@3.1.1: {} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..942aae90 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,91 @@ +import React, { useEffect } from "react"; +import { Routes, Route, Navigate, useNavigate } from "react-router-dom"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { AuthContext, useAuthProvider } from "./hooks/useAuth"; +import { useTheme } from "./hooks/useTheme"; +import { Layout } from "./components/Layout"; +import { Login } from "./pages/Login"; +import { Dashboard } from "./pages/Dashboard"; +import { PreviewDetail } from "./pages/PreviewDetail"; +import { Settings } from "./pages/Settings"; +import { Repos } from "./pages/Repos"; +import { Admin } from "./pages/Admin"; +import { SetupWizard } from "./pages/SetupWizard"; +import { Privacy } from "./pages/Privacy"; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = React.useContext(AuthContext); + const navigate = useNavigate(); + + useEffect(() => { + if (!loading && !user) navigate("/login"); + }, [user, loading, navigate]); + + if (loading) return ( +
+
⚙️
+
+ ); + + if (!user) return null; + return <>{children}; +} + +function SetupCheck({ children }: { children: React.ReactNode }) { + const { user } = React.useContext(AuthContext); + const navigate = useNavigate(); + + useEffect(() => { + if (user && !user.setupComplete) { + const path = window.location.pathname; + if (path !== "/setup" && path !== "/settings" && path !== "/privacy") { + navigate("/setup"); + } + } + }, [user, navigate]); + + return <>{children}; +} + +export default function App() { + const auth = useAuthProvider(); + useTheme(); + + return ( + + + + : + } /> + } /> + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + } /> + + + ); +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..82b71512 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { motion, AnimatePresence } from "motion/react"; + +interface Props { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; + danger?: boolean; +} + +export function ConfirmDialog({ open, title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger }: Props) { + return ( + + {open && ( + + e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 00000000..c3384f40 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; +import { useTheme } from "../hooks/useTheme"; +import { api } from "../services/api"; +import { toast } from "react-toastify"; + +export function Layout({ children }: { children: React.ReactNode }) { + const { user, refresh } = useAuth(); + const { dark, toggle } = useTheme(); + const location = useLocation(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await api.auth.logout(); + await refresh(); + navigate("/login"); + }; + + const navLink = (to: string, label: string) => ( + + {label} + + ); + + return ( +
+
+
+
+ + 🚀 PR Previews + +
+ + {user && ( + + )} + +
+ + {user && ( + + )} +
+
+
+ +
+ {children} +
+ +
+ PR Previews — self-hosted preview environments for every pull request.{" "} + Privacy Policy +
+
+ ); +} diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx new file mode 100644 index 00000000..93db8b88 --- /dev/null +++ b/frontend/src/components/LogViewer.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useRef, useState } from "react"; + +function stripAnsi(str: string): string { + return str.replace(/\x1B\[[\d;]*[mGKHFJsu]/g, "").replace(/\x1B\][^\x07]*\x07/g, ""); +} + +function renderLogLine(line: string, idx: number) { + if (line.startsWith("--- ") && (line.includes("Redeploy") || line.includes("truncated"))) { + return ( + + {line} + + ); + } + return {stripAnsi(line)}; +} + +interface Props { + logs: string; + autoScroll?: boolean; + maxHeight?: string; +} + +export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Props) { + const endRef = useRef(null); + const [pinned, setPinned] = useState(autoScroll); + + useEffect(() => { + if (pinned && endRef.current) { + endRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [logs, pinned]); + + const lines = logs.split("\n"); + + return ( +
+
{ + const el = e.currentTarget; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50; + setPinned(atBottom); + }} + > +
+          {lines.map((line, i) => renderLogLine(line, i))}
+        
+
+
+ {!pinned && ( + + )} +
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 00000000..c538f452 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +type Status = "PROVISIONING" | "BUILDING" | "RUNNING" | "FAILED" | "STOPPED" | "IGNORED"; + +const CONFIG: Record = { + PROVISIONING: { label: "Provisioning", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" }, + BUILDING: { label: "Building", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" }, + RUNNING: { label: "Running", icon: "🟢", cls: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" }, + FAILED: { label: "Failed", icon: "🔴", cls: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" }, + STOPPED: { label: "Stopped", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" }, + IGNORED: { label: "Ignored", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" }, +}; + +export function StatusBadge({ status }: { status: Status }) { + const cfg = CONFIG[status] || CONFIG.STOPPED; + return ( + + {cfg.icon} {cfg.label} + + ); +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 00000000..95224765 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,54 @@ +import { useState, useEffect, createContext, useContext } from "react"; +import { api } from "../services/api"; + +export interface AuthUser { + id: number; + username: string; + isAdmin: boolean; + isFounder: boolean; + setupComplete: boolean; + giteaUsername: string | null; + giteaInstanceUrl: string | null; + giteaPatSet: boolean; + awsAccessKeyId: string | null; + awsRegion: string | null; + awsConfigured: boolean; +} + +interface AuthContextType { + user: AuthUser | null; + loading: boolean; + refresh: () => Promise; +} + +import { createContext as _createContext } from "react"; +export const AuthContext = _createContext({ + user: null, + loading: true, + refresh: async () => {}, +}); + +export function useAuth() { + return useContext(AuthContext); +} + +export function useAuthProvider(): AuthContextType { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = async () => { + const res = await api.auth.me(); + if (res.ok && res.data) { + setUser(res.data as AuthUser); + } else { + setUser(null); + } + setLoading(false); + }; + + useEffect(() => { + refresh(); + }, []); + + return { user, loading, refresh }; +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 00000000..2a98e196 --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +export function useTheme() { + const [dark, setDark] = useState(() => { + const stored = localStorage.getItem("pp-theme"); + if (stored) return stored === "dark"; + return window.matchMedia("(prefers-color-scheme: dark)").matches; + }); + + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + localStorage.setItem("pp-theme", dark ? "dark" : "light"); + }, [dark]); + + return { dark, toggle: () => setDark(d => !d) }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..758a06d9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,43 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --bg: #ffffff; + --text: #111827; + } + .dark { + --bg: #0f172a; + --text: #f1f5f9; + } +} + +body { + @apply bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100 transition-colors; + font-family: system-ui, sans-serif; +} + +.log-viewer { + font-family: "JetBrains Mono", "Fira Code", Consolas, monospace; + font-size: 0.8rem; + line-height: 1.5; +} + +.redeploy-separator { + @apply text-slate-400 dark:text-slate-500 italic; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + @apply bg-gray-100 dark:bg-slate-800; +} +::-webkit-scrollbar-thumb { + @apply bg-gray-300 dark:bg-slate-600 rounded; +} + +.ansi-stripped { white-space: pre-wrap; word-break: break-all; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..fe05248c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 00000000..1e3d1f62 --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useState } from "react"; +import { api } from "../services/api"; +import { useAuth } from "../hooks/useAuth"; +import { toast } from "react-toastify"; +import { ConfirmDialog } from "../components/ConfirmDialog"; +import { StatusBadge } from "../components/StatusBadge"; +import { Link } from "react-router-dom"; + +export function Admin() { + const { user } = useAuth(); + const [tab, setTab] = useState<"users" | "settings" | "previews">("users"); + const [users, setUsers] = useState([]); + const [settings, setSettings] = useState(null); + const [previews, setPreviews] = useState([]); + const [loading, setLoading] = useState(false); + + const [newUsername, setNewUsername] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [editUser, setEditUser] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [stopConfirm, setStopConfirm] = useState(null); + const [saving, setSaving] = useState(false); + + const loadUsers = async () => { + const res = await api.admin.listUsers(); + if (res.ok) setUsers(res.data || []); + }; + const loadSettings = async () => { + const res = await api.admin.getSettings(); + if (res.ok) setSettings(res.data); + }; + const loadPreviews = async () => { + const res = await api.admin.listPreviews(); + if (res.ok) setPreviews(res.data || []); + }; + + useEffect(() => { + if (!user?.isAdmin) return; + setLoading(true); + Promise.all([loadUsers(), loadSettings(), loadPreviews()]).finally(() => setLoading(false)); + }, [user]); + + if (!user?.isAdmin) return ( +
+
🚫
+

Access Denied

+
+ ); + + const handleCreateUser = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + const res = await api.admin.createUser(newUsername, newPassword); + setSaving(false); + if (res.ok) { toast.success("User created"); setNewUsername(""); setNewPassword(""); loadUsers(); } + else toast.error(res.message || "Failed to create user"); + }; + + const handleDeleteUser = async () => { + if (!deleteConfirm) return; + const res = await api.admin.deleteUser(deleteConfirm.id); + setDeleteConfirm(null); + if (res.ok) { toast.success("User deleted"); loadUsers(); } + else toast.error(res.message || "Failed to delete user"); + }; + + const handleSaveSettings = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + const res = await api.admin.updateSettings(settings); + setSaving(false); + if (res.ok) toast.success("Settings saved"); + else toast.error(res.message || "Failed to save settings"); + }; + + const handleStopPreview = async () => { + if (!stopConfirm) return; + const res = await api.admin.stopPreview(stopConfirm.id); + setStopConfirm(null); + if (res.ok) { toast.success("Stop job enqueued"); loadPreviews(); } + else toast.error(res.message || "Failed to stop preview"); + }; + + return ( +
+ setDeleteConfirm(null)} + danger + /> + setStopConfirm(null)} + danger + /> + +

Admin Panel

+ +
+ {(["users", "settings", "previews"] as const).map(t => ( + + ))} +
+ + {tab === "users" && ( +
+
+

Create User

+
+ setNewUsername(e.target.value)} + placeholder="Username" className={inputCls} required /> + setNewPassword(e.target.value)} + placeholder="Password" className={inputCls} required /> + +
+
+ +
+ + + + + + + + + + + {users.map(u => ( + + + + + + + ))} + +
UsernameRoleCreatedActions
+ {u.username} + {u.isFounder && Founder} + + + {u.isAdmin ? "Admin" : "User"} + + {new Date(u.createdAt).toLocaleDateString()} +
+ {!u.isFounder && u.id !== user.id && ( + <> + + + + )} +
+
+
+
+ )} + + {tab === "settings" && settings && ( +
+

Global Settings

+
+ + setSettings((s: any) => ({ ...s, defaultInstanceType: e.target.value }))} + className={inputCls} /> + + + setSettings((s: any) => ({ ...s, maxConcurrentInstancesPerUser: Number(e.target.value) }))} + className={inputCls} min={1} max={50} /> + + + setSettings((s: any) => ({ ...s, webhookRateLimitPerMinute: Number(e.target.value) }))} + className={inputCls} min={1} /> + + + setSettings((s: any) => ({ ...s, logSizeLimitBytes: Number(e.target.value) }))} + className={inputCls} min={1024} /> + + + setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))} + className={inputCls} min={1} /> + + + setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))} + className={inputCls} placeholder="admin@example.com" /> + + +
+
+ )} + + {tab === "previews" && ( +
+
+

All Previews

+ +
+
+ + + + + + + + + + + + + {previews.map(p => ( + + + + + + + + + ))} + +
Repo / PRUserStatusIPCreatedActions
+

{p.repoOwner}/{p.repoName}

+ PR #{p.prNumber} +
{p.user?.username} + {p.instanceIp ? ( + + {p.instanceIp}:{p.port} + + ) : "—"} + {new Date(p.createdAt).toLocaleDateString()} + {p.status !== "STOPPED" && p.status !== "IGNORED" && ( + + )} +
+
+
+ )} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"; +const btnCls = "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50"; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 00000000..66c26328 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../services/api"; +import { StatusBadge } from "../components/StatusBadge"; +import { motion } from "motion/react"; +import { useAuth } from "../hooks/useAuth"; + +interface Preview { + id: number; + prNumber: number; + prTitle: string; + commitSha: string; + status: string; + instanceIp: string | null; + port: number; + createdAt: string; + updatedAt: string; + lastActivityAt: string; + repoOwner: string; + repoName: string; +} + +export function Dashboard() { + const [previews, setPreviews] = useState([]); + const [loading, setLoading] = useState(true); + const { user } = useAuth(); + + const load = async () => { + const res = await api.previews.list(); + if (res.ok) setPreviews(res.data || []); + setLoading(false); + }; + + useEffect(() => { load(); }, []); + useEffect(() => { + const t = setInterval(load, 10000); + return () => clearInterval(t); + }, []); + + if (!user?.setupComplete && !loading) { + return ( +
+
⚙️
+

Setup Required

+

Configure your Gitea and AWS credentials to get started.

+ + Go to Settings + +
+ ); + } + + return ( +
+
+

Previews

+ +
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : previews.length === 0 ? ( +
+
🔍
+

No previews yet

+

+ Enable a repo and open a pull request to create your first preview. +

+ + Configure repos → + +
+ ) : ( +
+ {previews.map((p, i) => ( + + +
+
+

{p.repoOwner}/{p.repoName}

+

PR #{p.prNumber}: {p.prTitle}

+
+ +
+ +

+ Commit: {p.commitSha.slice(0, 8)} +

+ + {p.status === "RUNNING" && p.instanceIp && ( + e.stopPropagation()} + className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 hover:underline" + > + 🟢 http://{p.instanceIp}:{p.port} + + )} + +

+ Updated {new Date(p.updatedAt).toLocaleString()} +

+ +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 00000000..125f52cf --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { api } from "../services/api"; +import { useAuth } from "../hooks/useAuth"; +import { toast } from "react-toastify"; +import { motion } from "motion/react"; + +export function Login() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [needsSetup, setNeedsSetup] = useState(null); + const { refresh } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + api.auth.setupStatus().then(res => { + setNeedsSetup(res.ok ? (res.data as any)?.needsSetup : false); + }); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (needsSetup && password !== confirmPassword) { + toast.error("Passwords do not match"); + return; + } + setLoading(true); + + let res; + if (needsSetup) { + res = await api.auth.firstUser(username, password); + } else { + res = await api.auth.login(username, password); + } + + setLoading(false); + if (res.ok) { + await refresh(); + navigate("/"); + } else { + toast.error(res.message || (needsSetup ? "Failed to create account" : "Login failed")); + } + }; + + if (needsSetup === null) { + return
+
⚙️
+
; + } + + return ( +
+ +
+
🚀
+

PR Previews

+ {needsSetup ? ( +

Create your admin account

+ ) : ( +

Sign in to manage your previews

+ )} +
+ + {needsSetup && ( +
+ This is the first time setup. Create the founder admin account. +
+ )} + +
+
+ + setUsername(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + required + autoFocus + /> +
+
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + required + minLength={needsSetup ? 8 : undefined} + /> +
+ {needsSetup && ( +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+ )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/PreviewDetail.tsx b/frontend/src/pages/PreviewDetail.tsx new file mode 100644 index 00000000..c2fbcc8e --- /dev/null +++ b/frontend/src/pages/PreviewDetail.tsx @@ -0,0 +1,195 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { api, openLogsWs } from "../services/api"; +import { StatusBadge } from "../components/StatusBadge"; +import { LogViewer } from "../components/LogViewer"; +import { ConfirmDialog } from "../components/ConfirmDialog"; +import { toast } from "react-toastify"; + +interface Job { + id: number; + type: string; + status: string; + createdAt: string; + startedAt: string | null; + finishedAt: string | null; + error: string | null; +} + +interface Preview { + id: number; + prNumber: number; + prTitle: string; + commitSha: string; + status: string; + instanceIp: string | null; + port: number; + logs: string; + createdAt: string; + updatedAt: string; + stoppedAt: string | null; + lastActivityAt: string; + repoOwner: string; + repoName: string; + jobs: Job[]; +} + +export function PreviewDetail() { + const { id } = useParams<{ id: string }>(); + const previewId = parseInt(id || "0", 10); + const navigate = useNavigate(); + const [preview, setPreview] = useState(null); + const [logs, setLogs] = useState(""); + const [loading, setLoading] = useState(true); + const [confirmStop, setConfirmStop] = useState(false); + const [stopping, setStopping] = useState(false); + + const loadPreview = useCallback(async () => { + const res = await api.previews.get(previewId); + if (res.ok && res.data) { + setPreview(res.data as Preview); + setLogs(res.data.logs || ""); + } else if (res.status === 404) { + navigate("/"); + } + setLoading(false); + }, [previewId, navigate]); + + useEffect(() => { + loadPreview(); + const interval = setInterval(loadPreview, 5000); + return () => clearInterval(interval); + }, [loadPreview]); + + useEffect(() => { + if (!preview) return; + if (preview.status !== "RUNNING" && preview.status !== "BUILDING" && preview.status !== "PROVISIONING") return; + + const ws = openLogsWs(previewId, (msg) => { + if (msg.type === "init") setLogs(msg.logs || ""); + else if (msg.type === "append") setLogs(prev => prev + msg.text); + }); + + return () => { try { ws.close(); } catch {} }; + }, [preview?.status, previewId]); + + const handleStop = async () => { + setStopping(true); + const res = await api.previews.stop(previewId); + setStopping(false); + setConfirmStop(false); + if (res.ok) toast.success("Stop job enqueued"); + else toast.error(res.message || "Failed to enqueue stop"); + await loadPreview(); + }; + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (!preview) return null; + + return ( +
+ setConfirmStop(false)} + danger + /> + +
+ + ← Back to Previews + +
+
+

+ {preview.repoOwner}/{preview.repoName} — PR #{preview.prNumber} +

+

{preview.prTitle}

+
+
+ + {preview.status !== "STOPPED" && preview.status !== "IGNORED" && ( + + )} +
+
+
+ +
+ + + + +
+ + {preview.status === "RUNNING" && preview.instanceIp && ( + + )} + +
+

Logs

+ +
+ +
+

Job History

+
+ {preview.jobs.length === 0 ? ( +

No jobs yet.

+ ) : preview.jobs.map(job => ( +
+ {job.type} + {job.status} + {new Date(job.createdAt).toLocaleString()} + {job.error && ( + {job.error} + )} +
+ ))} +
+
+
+ ); +} + +function InfoCard({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/frontend/src/pages/Privacy.tsx b/frontend/src/pages/Privacy.tsx new file mode 100644 index 00000000..8e25b3ba --- /dev/null +++ b/frontend/src/pages/Privacy.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Link } from "react-router-dom"; + +export function Privacy() { + return ( +
+ ← Back +

Privacy Policy

+

This is a self-hosted instance of PR Previews (PP). The following describes what data PP stores and how it is used.

+ +

What We Store

+
    +
  • Your username and hashed password.
  • +
  • Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256.
  • +
  • Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256.
  • +
  • Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.
  • +
  • SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).
  • +
+ +

How We Use Your Data

+
    +
  • Your Gitea PAT is used solely to register webhooks, clone repositories, and post preview status comments on PRs.
  • +
  • Your AWS credentials are used solely to provision EC2 instances for previews in your own AWS account.
  • +
  • PP does not have access to data on EC2 instances beyond what it deploys.
  • +
  • No data is shared with third parties.
  • +
+ +

Data Retention

+

Preview records are retained for the number of days configured by the administrator (default: 30 days after a preview is stopped or failed). You can view this setting in the admin panel.

+ +

EC2 Instances

+

Preview instances are launched in your own AWS account. PP terminates them on PR close, inactivity timeout, or manual stop. PP does not retain any data from inside EC2 instances.

+ +

Analytics & Tracking

+

No analytics, no tracking, no external data sharing. PP is fully self-contained.

+ +

Contact

+

For questions or concerns, contact the instance administrator.

+
+ ); +} diff --git a/frontend/src/pages/Repos.tsx b/frontend/src/pages/Repos.tsx new file mode 100644 index 00000000..1985e086 --- /dev/null +++ b/frontend/src/pages/Repos.tsx @@ -0,0 +1,346 @@ +import React, { useEffect, useState } from "react"; +import { api } from "../services/api"; +import { toast } from "react-toastify"; +import { ConfirmDialog } from "../components/ConfirmDialog"; + +const INSTANCE_TYPES = [ + { value: "t2.micro", label: "t2.micro", cost: "$0.012/hr" }, + { value: "t2.medium", label: "t2.medium", cost: "$0.046/hr" }, + { value: "t3.medium", label: "t3.medium", cost: "$0.042/hr" }, + { value: "t3.large", label: "t3.large", cost: "$0.083/hr" }, + { value: "m5.large", label: "m5.large", cost: "$0.096/hr" }, + { value: "c5.large", label: "c5.large", cost: "$0.085/hr" }, +]; + +interface Repo { + owner: string; + name: string; + fullName: string; + htmlUrl: string; + isEnabled: boolean; + claimedByOther: boolean; + config: any; +} + +export function Repos() { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedRepo, setExpandedRepo] = useState(null); + const [configs, setConfigs] = useState>({}); + const [disableConfirm, setDisableConfirm] = useState<{ owner: string; repo: string } | null>(null); + const [togglingRepo, setTogglingRepo] = useState(null); + const [savingRepo, setSavingRepo] = useState(null); + + const load = async () => { + setLoading(true); + const res = await api.repos.list(); + if (res.ok) { + const data = (res.data || []) as Repo[]; + setRepos(data); + const initial: Record = {}; + data.forEach(r => { + const key = `${r.owner}/${r.name}`; + initial[key] = r.config || defaultConfig(); + }); + setConfigs(initial); + } else { + toast.error(res.message || "Failed to load repos"); + } + setLoading(false); + }; + + useEffect(() => { load(); }, []); + + function defaultConfig() { + return { + instanceType: "t2.medium", + inactivityHours: 12, + port: 3000, + denyList: [], + envVars: {}, + useDockerCompose: false, + composeFilePath: "docker-compose.yml", + aptPackages: [], + setupCommands: [], + buildCommands: [], + postBuildCommands: [], + runCommand: "", + }; + } + + const handleToggle = async (repo: Repo) => { + const key = `${repo.owner}/${repo.name}`; + if (repo.isEnabled) { + setDisableConfirm({ owner: repo.owner, repo: repo.name }); + return; + } + setTogglingRepo(key); + const res = await api.repos.toggle(repo.owner, repo.name, true); + setTogglingRepo(null); + if (res.ok) { toast.success("Repo enabled"); load(); } + else toast.error(res.message || "Failed to enable repo"); + }; + + const handleDisable = async () => { + if (!disableConfirm) return; + const key = `${disableConfirm.owner}/${disableConfirm.repo}`; + setDisableConfirm(null); + setTogglingRepo(key); + const res = await api.repos.toggle(disableConfirm.owner, disableConfirm.repo, false); + setTogglingRepo(null); + if (res.ok) { toast.success("Repo disabled"); load(); } + else toast.error(res.message || "Failed to disable repo"); + }; + + const handleSaveConfig = async (owner: string, name: string) => { + const key = `${owner}/${name}`; + setSavingRepo(key); + const config = configs[key] || defaultConfig(); + const res = await api.repos.saveConfig({ owner, repo: name, ...config }); + setSavingRepo(null); + if (res.ok) toast.success("Config saved"); + else toast.error(res.message || "Failed to save config"); + }; + + const updateConfig = (key: string, field: string, value: any) => { + setConfigs(prev => ({ ...prev, [key]: { ...(prev[key] || defaultConfig()), [field]: value } })); + }; + + if (loading) { + return
+ {[...Array(4)].map((_, i) =>
)} +
; + } + + return ( +
+ setDisableConfirm(null)} + danger + /> + +
+

Repo Configuration

+ +
+ + {repos.length === 0 && ( +
+
🔗
+

No repos found

+

Configure your Gitea credentials in Settings first.

+
+ )} + + {repos.map(repo => { + const key = `${repo.owner}/${repo.name}`; + const expanded = expandedRepo === key; + const config = configs[key] || defaultConfig(); + const isToggling = togglingRepo === key; + + return ( +
+
+
+

{repo.fullName}

+ {repo.claimedByOther && ( + 🔒 Claimed by another user + )} +
+
+ {!repo.claimedByOther && ( + + )} + {repo.isEnabled && !repo.claimedByOther && ( + + )} +
+
+ + {expanded && ( +
+
+
+ + + {config.instanceType === "custom" && ( + updateConfig(key, "instanceType", e.target.value)} /> + )} +
+
+ + updateConfig(key, "port", Number(e.target.value))} className={inputCls} min={1} max={65535} /> +
+
+ +
+ + updateConfig(key, "inactivityHours", Number(e.target.value))} + className="w-full mt-1" /> +
+ 0.5h12h72h +
+
+ +
+ + updateConfig(key, "denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))} + className={inputCls} + placeholder="dependabot, renovate-bot" + /> +
+ +
+ + updateConfig(key, "aptPackages", e.target.value.split(" ").filter(Boolean))} + className={inputCls} + placeholder="python3 ffmpeg" + /> +
+ +
+
+ updateConfig(key, "useDockerCompose", e.target.checked)} className="rounded" /> + +
+ + {config.useDockerCompose ? ( +
+ + updateConfig(key, "composeFilePath", e.target.value)} + className={inputCls} placeholder="docker-compose.yml" /> +
+ ) : ( +
+ updateConfig(key, "buildCommands", v)} /> + updateConfig(key, "postBuildCommands", v)} /> +
+ + updateConfig(key, "runCommand", e.target.value)} + className={inputCls} placeholder="node dist/index.js" /> +
+
+ )} +
+ + updateConfig(key, "setupCommands", v)} /> + + updateConfig(key, "envVars", v)} /> + +
+ +
+
+ )} +
+ ); + })} +
+ ); +} + +function CommandList({ label, value, onChange }: { label: string; value: string[]; onChange: (v: string[]) => void }) { + return ( +
+ +
+ {value.map((cmd, i) => ( +
+ { const n = [...value]; n[i] = e.target.value; onChange(n); }} + className={inputCls} placeholder="npm run build" /> + +
+ ))} + +
+
+ ); +} + +function EnvVarsEditor({ value, onChange }: { value: Record; onChange: (v: Record) => void }) { + const entries = Object.entries(value); + const addEntry = () => onChange({ ...value, "": "" }); + const updateEntry = (oldKey: string, newKey: string, newVal: string) => { + const next: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (k === oldKey) next[newKey] = newVal; + else next[k] = v; + } + onChange(next); + }; + const removeEntry = (k: string) => { + const next = { ...value }; + delete next[k]; + onChange(next); + }; + + return ( +
+ +
+ {entries.map(([k, v], i) => ( +
+ updateEntry(k, e.target.value, v)} + className={`${inputCls} w-1/3 font-mono text-xs`} /> + updateEntry(k, k, e.target.value)} + className={`${inputCls} flex-1 font-mono text-xs`} /> + +
+ ))} + +
+
+ ); +} + +const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1"; +const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 00000000..e882dcf6 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,290 @@ +import React, { useState, useEffect } from "react"; +import { api } from "../services/api"; +import { useAuth } from "../hooks/useAuth"; +import { toast } from "react-toastify"; +import { ConfirmDialog } from "../components/ConfirmDialog"; + +const AWS_REGIONS = [ + "us-east-1", "us-east-2", "us-west-1", "us-west-2", + "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1", + "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1", + "sa-east-1", "ca-central-1", "me-south-1", "af-south-1", +]; + +export function Settings() { + const { user, refresh } = useAuth(); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + + const [username, setUsername] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + + const [giteaUrl, setGiteaUrl] = useState(""); + const [giteaUsername, setGiteaUsername] = useState(""); + const [giteaPAT, setGiteaPAT] = useState(""); + + const [awsKeyId, setAwsKeyId] = useState(""); + const [awsSecret, setAwsSecret] = useState(""); + const [awsRegion, setAwsRegion] = useState("us-east-1"); + + const [webhookUrl, setWebhookUrl] = useState(""); + const [webhookSecret, setWebhookSecret] = useState(""); + const [confirmRotate, setConfirmRotate] = useState(false); + const [saving, setSaving] = useState(null); + + useEffect(() => { + api.user.settings().then(res => { + if (res.ok && res.data) { + const d = res.data; + setSettings(d); + setUsername(d.username || ""); + setGiteaUrl(d.giteaInstanceUrl || ""); + setGiteaUsername(d.giteaUsername || ""); + setWebhookUrl(d.webhookUrl || ""); + } + }); + api.user.getWebhookSecret().then(res => { + if (res.ok && res.data) setWebhookSecret(res.data.token); + }); + setLoading(false); + }, []); + + const save = async (key: string, fn: () => Promise) => { + setSaving(key); + const res = await fn(); + setSaving(null); + if (res.ok) { + toast.success(res.message || "Saved"); + refresh(); + } else { + toast.error(res.message || "Failed to save"); + } + }; + + const handleRotateSecret = async () => { + setConfirmRotate(false); + setSaving("rotate"); + const res = await api.user.regenerateWebhookSecret(); + setSaving(null); + if (res.ok && res.data) { + setWebhookSecret(res.data.token); + toast.success(res.message || "Secret regenerated"); + } else { + toast.error(res.message || "Failed to regenerate"); + } + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.info(`${label} copied to clipboard`); + }; + + if (loading) return
; + + return ( +
+ setConfirmRotate(false)} + danger + /> + +

Account Settings

+ + {/* Username */} +
+
+ setUsername(e.target.value)} + className={inputCls} + placeholder="Username" + /> + +
+
+ + {/* Password */} +
+
+ setCurrentPassword(e.target.value)} + className={inputCls} + placeholder="Current password" + /> + setNewPassword(e.target.value)} + className={inputCls} + placeholder="New password" + /> + +
+
+ + {/* Gitea */} +
+

+ PP will post PR comments as your Gitea account (@{giteaUsername || "username"}). Make sure your PAT has 'issue' write permission. +

+
+ setGiteaUrl(e.target.value)} + className={inputCls} + placeholder="https://gitea.example.com" + /> + setGiteaUsername(e.target.value)} + className={inputCls} + placeholder="Gitea username" + /> +
+ setGiteaPAT(e.target.value)} + className={inputCls} + placeholder={settings?.giteaPatSet ? "•••••••• (set — enter new value to update)" : "Personal Access Token"} + /> +

+ Required PAT scopes: repository (read), issue (write), admin:repo_hook +

+
+ +
+
+ + {/* AWS */} +
+
+ setAwsKeyId(e.target.value)} + className={inputCls} + placeholder={settings?.awsAccessKeyId ? `${settings.awsAccessKeyId} (set)` : "AWS Access Key ID"} + /> + setAwsSecret(e.target.value)} + className={inputCls} + placeholder="AWS Secret Access Key" + /> + +
+

Required IAM Permissions:

+
{IAM_POLICY}
+ +
+ +
+
+ + {/* Webhook */} +
+
+
+ +
+ + +
+
+
+ +
+ + + +
+
+

+ PP auto-registers per-repo webhooks when you enable a repo. These credentials are for reference only. +

+
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"; +const btnCls = "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50"; +const btnSecCls = "px-3 py-2 text-sm rounded-lg bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300 transition-colors shrink-0"; + +const IAM_POLICY = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "ec2:RunInstances", + "ec2:TerminateInstances", + "ec2:DescribeInstances", + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:DescribeSecurityGroups", + "ec2:ImportKeyPair", + "ec2:DeleteKeyPair", + "ec2:CreateTags", + "sts:GetCallerIdentity" + ], + "Resource": "*" + }] +}`; diff --git a/frontend/src/pages/SetupWizard.tsx b/frontend/src/pages/SetupWizard.tsx new file mode 100644 index 00000000..ffb24fc1 --- /dev/null +++ b/frontend/src/pages/SetupWizard.tsx @@ -0,0 +1,227 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { api } from "../services/api"; +import { useAuth } from "../hooks/useAuth"; +import { toast } from "react-toastify"; +import { motion, AnimatePresence } from "motion/react"; + +const STEPS = ["Welcome", "Gitea Connection", "AWS Setup", "Your Webhook", "Enable a Repo", "Done"]; + +const AWS_REGIONS = [ + "us-east-1", "us-east-2", "us-west-1", "us-west-2", + "eu-west-1", "eu-west-2", "eu-central-1", + "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", +]; + +export function SetupWizard() { + const [step, setStep] = useState(0); + const { user, refresh } = useAuth(); + const navigate = useNavigate(); + + const [giteaUrl, setGiteaUrl] = useState(user?.giteaInstanceUrl || ""); + const [giteaUsername, setGiteaUsername] = useState(user?.giteaUsername || ""); + const [giteaPAT, setGiteaPAT] = useState(""); + const [awsKeyId, setAwsKeyId] = useState(""); + const [awsSecret, setAwsSecret] = useState(""); + const [awsRegion, setAwsRegion] = useState("us-east-1"); + const [saving, setSaving] = useState(false); + const [webhookUrl, setWebhookUrl] = useState(""); + const [webhookSecret, setWebhookSecret] = useState(""); + + const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1)); + const skip = () => navigate("/"); + + const handleGiteaNext = async () => { + setSaving(true); + const res = await api.user.updateGitea({ giteaInstanceUrl: giteaUrl, giteaUsername, giteaPAT }); + setSaving(false); + if (res.ok) { + toast.success("Gitea connected!"); + refresh(); + next(); + } else { + toast.error(res.message || "Failed to connect Gitea"); + } + }; + + const handleAwsNext = async () => { + setSaving(true); + const res = await api.user.updateAws({ awsAccessKeyId: awsKeyId, awsSecretAccessKey: awsSecret, awsRegion }); + setSaving(false); + if (res.ok) { + toast.success("AWS connected!"); + refresh(); + const secretRes = await api.user.getWebhookSecret(); + if (secretRes.ok && secretRes.data) { + setWebhookUrl(`${window.location.origin}/webhook/${user?.id}`); + setWebhookSecret(secretRes.data.token); + } + next(); + } else { + toast.error(res.message || "Failed to connect AWS"); + } + }; + + return ( +
+
+ {/* Progress */} +
+
+ {STEPS.map((s, i) => ( + +
+ + ))} +
+

Step {step + 1} of {STEPS.length}: {STEPS[step]}

+
+ + + + {step === 0 && ( +
+
🚀
+

Welcome to PR Previews

+

+ PP automatically provisions EC2 instances, builds your code, and posts live preview URLs on every pull request. +

+

Let's get you set up in a few steps.

+
+ + +
+
+ )} + + {step === 1 && ( +
+

Gitea Connection

+

Connect to your Gitea instance so PP can register webhooks and post comments.

+ setGiteaUrl(e.target.value)} + className={inputCls} placeholder="https://gitea.example.com" /> + setGiteaUsername(e.target.value)} + className={inputCls} placeholder="Gitea username" /> + setGiteaPAT(e.target.value)} + className={inputCls} placeholder="Personal Access Token" /> +

+ Required PAT scopes: repository, issue, admin:repo_hook +

+
+ + +
+
+ )} + + {step === 2 && ( +
+

AWS Setup

+

PP will launch EC2 instances in your AWS account to host previews.

+ setAwsKeyId(e.target.value)} + className={inputCls} placeholder="AWS Access Key ID" /> + setAwsSecret(e.target.value)} + className={inputCls} placeholder="AWS Secret Access Key" /> + +
+ View required IAM permissions +
+{`{
+  "Version": "2012-10-17",
+  "Statement": [{
+    "Effect": "Allow",
+    "Action": ["ec2:RunInstances","ec2:TerminateInstances",
+      "ec2:DescribeInstances","ec2:CreateSecurityGroup",
+      "ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress",
+      "ec2:DescribeSecurityGroups","ec2:ImportKeyPair",
+      "ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"],
+    "Resource": "*"
+  }]
+}`}
+                  
+
+
+ + +
+
+ )} + + {step === 3 && ( +
+

Your Webhook

+

+ PP auto-registers webhooks when you enable a repo. These are for reference: +

+
+
+ + +
+
+ + +
+
+ +
+ )} + + {step === 4 && ( +
+

Enable Your First Repo

+

+ Go to the Repos page to enable previews for a repository. PP will automatically register the webhook. +

+ +
+ )} + + {step === 5 && ( +
+
🎉
+

You're all set!

+

+ Open a pull request on an enabled repo and PP will provision a preview automatically. +

+ +
+ )} +
+
+
+
+ ); +} + +const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 00000000..74470793 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,74 @@ +const BASE = "/api"; + +async function request( + method: string, + path: string, + body?: any, +): Promise<{ ok: boolean; data?: T; message?: string; status: number }> { + const res = await fetch(`${BASE}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + credentials: "include", + }); + + let json: any = {}; + try { + json = await res.json(); + } catch {} + + return { ok: res.ok, data: json.data, message: json.message, status: res.status }; +} + +export const api = { + auth: { + login: (username: string, password: string) => request("POST", "/auth/login", { username, password }), + logout: () => request("POST", "/auth/logout"), + me: () => request("GET", "/auth/me"), + setupStatus: () => request("GET", "/auth/setup-status"), + firstUser: (username: string, password: string) => request("POST", "/auth/first-user", { username, password }), + }, + user: { + settings: () => request("GET", "/user/settings"), + updateUsername: (username: string) => request("PATCH", "/user/username", { username }), + updatePassword: (currentPassword: string, newPassword: string) => + request("PATCH", "/user/password", { currentPassword, newPassword }), + updateGitea: (data: { giteaInstanceUrl: string; giteaUsername: string; giteaPAT?: string }) => + request("PUT", "/user/gitea", data), + updateAws: (data: { awsAccessKeyId: string; awsSecretAccessKey: string; awsRegion: string }) => + request("PUT", "/user/aws", data), + getWebhookSecret: () => request("GET", "/user/webhook-secret"), + regenerateWebhookSecret: () => request("POST", "/user/webhook-secret/regenerate"), + }, + repos: { + list: () => request("GET", "/repos"), + getConfig: (owner: string, repo: string) => request("GET", `/repos/${owner}/${repo}/config`), + saveConfig: (data: any) => request("POST", "/repos/config", data), + toggle: (owner: string, repo: string, enabled: boolean) => request("POST", "/repos/toggle", { owner, repo, enabled }), + }, + previews: { + list: () => request("GET", "/previews"), + get: (id: number) => request("GET", `/previews/${id}`), + stop: (id: number) => request("POST", `/previews/${id}/stop`), + }, + admin: { + listUsers: () => request("GET", "/admin/users"), + createUser: (username: string, password: string) => request("POST", "/admin/users", { username, password }), + updateUser: (id: number, data: any) => request("PATCH", `/admin/users/${id}`, data), + deleteUser: (id: number) => request("DELETE", `/admin/users/${id}`), + getSettings: () => request("GET", "/admin/settings"), + updateSettings: (data: any) => request("PUT", "/admin/settings", data), + listPreviews: () => request("GET", "/admin/previews"), + stopPreview: (id: number) => request("POST", `/admin/previews/${id}/stop`), + }, +}; + +export function openLogsWs(previewId: number, onMessage: (msg: any) => void, onClose?: () => void): WebSocket { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${protocol}//${location.host}/api/previews/${previewId}/logs`); + ws.onmessage = (e) => { + try { onMessage(JSON.parse(e.data)); } catch {} + }; + ws.onclose = onClose || (() => {}); + return ws; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 00000000..7ca544df --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + darkMode: "class", + theme: { + extend: { + fontFamily: { + mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"], + }, + }, + }, + plugins: [], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..6bfa73af --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..40cbc540 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + "/api": "http://localhost:5000", + "/webhook": "http://localhost:5000", + }, + }, + build: { + outDir: "dist", + }, +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..66066df1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,438 @@ +{ + "name": "pr-previews", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@prisma/client": "^6.19.3" + }, + "devDependencies": { + "prisma": "^6.19.3" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", + "integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..0a498b57 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "prisma": "^6.19.3" + }, + "dependencies": { + "@prisma/client": "^6.19.3" + } +} diff --git a/prisma/migrations/20260724221403_init/migration.sql b/prisma/migrations/20260724221403_init/migration.sql new file mode 100644 index 00000000..b3fe6a0d --- /dev/null +++ b/prisma/migrations/20260724221403_init/migration.sql @@ -0,0 +1,168 @@ +-- CreateEnum +CREATE TYPE "PreviewStatus" AS ENUM ('PROVISIONING', 'BUILDING', 'RUNNING', 'FAILED', 'STOPPED', 'IGNORED'); + +-- CreateEnum +CREATE TYPE "JobType" AS ENUM ('DEPLOY', 'STOP', 'INACTIVITY_STOP'); + +-- CreateEnum +CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'RUNNING', 'DONE', 'FAILED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "username" VARCHAR(128) NOT NULL, + "passwordHash" VARCHAR(256) NOT NULL, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "isFounder" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "giteaUsername" VARCHAR(128), + "giteaPAT" TEXT, + "giteaInstanceUrl" VARCHAR(512), + "awsAccessKeyId" VARCHAR(256), + "awsSecretAccessKey" TEXT, + "awsRegion" VARCHAR(64), + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" SERIAL NOT NULL, + "hash" VARCHAR(512) NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RepoConfig" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "repoOwner" VARCHAR(128) NOT NULL, + "repoName" VARCHAR(128) NOT NULL, + "isEnabled" BOOLEAN NOT NULL DEFAULT false, + "giteaWebhookId" VARCHAR(128), + "denyList" TEXT[], + "instanceType" VARCHAR(64) NOT NULL DEFAULT 't2.medium', + "inactivityHours" DOUBLE PRECISION NOT NULL DEFAULT 12, + "port" INTEGER NOT NULL DEFAULT 3000, + "envVars" JSONB NOT NULL DEFAULT '{}', + "useDockerCompose" BOOLEAN NOT NULL DEFAULT false, + "composeFilePath" VARCHAR(512), + "aptPackages" TEXT[], + "setupCommands" TEXT[], + "buildCommands" TEXT[], + "postBuildCommands" TEXT[], + "runCommand" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RepoConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Preview" ( + "id" SERIAL NOT NULL, + "repoConfigId" INTEGER NOT NULL, + "prNumber" INTEGER NOT NULL, + "prTitle" VARCHAR(512) NOT NULL, + "commitSha" VARCHAR(64) NOT NULL, + "instanceId" VARCHAR(64), + "instanceIp" VARCHAR(64), + "port" INTEGER NOT NULL DEFAULT 3000, + "status" "PreviewStatus" NOT NULL DEFAULT 'PROVISIONING', + "logs" TEXT NOT NULL DEFAULT '', + "pid" INTEGER, + "sshPrivateKey" TEXT, + "sshKeyName" VARCHAR(128), + "giteaCommentId" INTEGER, + "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "stoppedAt" TIMESTAMP(3), + + CONSTRAINT "Preview_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Job" ( + "id" SERIAL NOT NULL, + "previewId" INTEGER, + "type" "JobType" NOT NULL, + "status" "JobStatus" NOT NULL DEFAULT 'PENDING', + "payload" JSONB NOT NULL DEFAULT '{}', + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "startedAt" TIMESTAMP(3), + "finishedAt" TIMESTAMP(3), + + CONSTRAINT "Job_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WebhookToken" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "token" VARCHAR(256) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WebhookToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NoConfigComment" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "repoOwner" VARCHAR(128) NOT NULL, + "repoName" VARCHAR(128) NOT NULL, + "prNumber" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "NoConfigComment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AdminSettings" ( + "id" INTEGER NOT NULL DEFAULT 1, + "defaultInstanceType" VARCHAR(64) NOT NULL DEFAULT 't2.medium', + "maxConcurrentInstancesPerUser" INTEGER NOT NULL DEFAULT 5, + "logSizeLimitBytes" INTEGER NOT NULL DEFAULT 1048576, + "previewRetentionDays" INTEGER NOT NULL DEFAULT 30, + "webhookRateLimitPerMinute" INTEGER NOT NULL DEFAULT 10, + "contactEmail" VARCHAR(256) NOT NULL DEFAULT '', + + CONSTRAINT "AdminSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "RepoConfig_repoOwner_repoName_key" ON "RepoConfig"("repoOwner", "repoName"); + +-- CreateIndex +CREATE UNIQUE INDEX "WebhookToken_userId_key" ON "WebhookToken"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "NoConfigComment_userId_repoOwner_repoName_prNumber_key" ON "NoConfigComment"("userId", "repoOwner", "repoName", "prNumber"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepoConfig" ADD CONSTRAINT "RepoConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Preview" ADD CONSTRAINT "Preview_repoConfigId_fkey" FOREIGN KEY ("repoConfigId") REFERENCES "RepoConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_previewId_fkey" FOREIGN KEY ("previewId") REFERENCES "Preview"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WebhookToken" ADD CONSTRAINT "WebhookToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NoConfigComment" ADD CONSTRAINT "NoConfigComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..d06a4073 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,160 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum PreviewStatus { + PROVISIONING + BUILDING + RUNNING + FAILED + STOPPED + IGNORED +} + +enum JobType { + DEPLOY + STOP + INACTIVITY_STOP +} + +enum JobStatus { + PENDING + RUNNING + DONE + FAILED +} + +model User { + id Int @id @default(autoincrement()) + username String @unique @db.VarChar(128) + passwordHash String @db.VarChar(256) + isAdmin Boolean @default(false) + isFounder Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + giteaUsername String? @db.VarChar(128) + giteaPAT String? @db.Text + giteaInstanceUrl String? @db.VarChar(512) + + awsAccessKeyId String? @db.VarChar(256) + awsSecretAccessKey String? @db.Text + awsRegion String? @db.VarChar(64) + + sessions Session[] + repoConfigs RepoConfig[] + webhookToken WebhookToken? + noConfigComments NoConfigComment[] +} + +model Session { + id Int @id @default(autoincrement()) + hash String @db.VarChar(512) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model RepoConfig { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + repoOwner String @db.VarChar(128) + repoName String @db.VarChar(128) + isEnabled Boolean @default(false) + + giteaWebhookId String? @db.VarChar(128) + + denyList String[] + instanceType String @default("t2.medium") @db.VarChar(64) + inactivityHours Float @default(12) + port Int @default(3000) + envVars Json @default("{}") + useDockerCompose Boolean @default(false) + composeFilePath String? @db.VarChar(512) + aptPackages String[] + setupCommands String[] + buildCommands String[] + postBuildCommands String[] + runCommand String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + previews Preview[] + + @@unique([repoOwner, repoName]) +} + +model Preview { + id Int @id @default(autoincrement()) + repoConfigId Int + repoConfig RepoConfig @relation(fields: [repoConfigId], references: [id], onDelete: Cascade) + prNumber Int + prTitle String @db.VarChar(512) + commitSha String @db.VarChar(64) + instanceId String? @db.VarChar(64) + instanceIp String? @db.VarChar(64) + port Int @default(3000) + status PreviewStatus @default(PROVISIONING) + logs String @default("") @db.Text + pid Int? + sshPrivateKey String? @db.Text + sshKeyName String? @db.VarChar(128) + giteaCommentId Int? + lastActivityAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + stoppedAt DateTime? + + jobs Job[] +} + +model Job { + id Int @id @default(autoincrement()) + previewId Int? + preview Preview? @relation(fields: [previewId], references: [id], onDelete: Cascade) + type JobType + status JobStatus @default(PENDING) + payload Json @default("{}") + error String? @db.Text + createdAt DateTime @default(now()) + startedAt DateTime? + finishedAt DateTime? +} + +model WebhookToken { + id Int @id @default(autoincrement()) + userId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @db.VarChar(256) + createdAt DateTime @default(now()) +} + +model NoConfigComment { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + repoOwner String @db.VarChar(128) + repoName String @db.VarChar(128) + prNumber Int + createdAt DateTime @default(now()) + + @@unique([userId, repoOwner, repoName, prNumber]) +} + +model AdminSettings { + id Int @id @default(1) + defaultInstanceType String @default("t2.medium") @db.VarChar(64) + maxConcurrentInstancesPerUser Int @default(5) + logSizeLimitBytes Int @default(1048576) + previewRetentionDays Int @default(30) + webhookRateLimitPerMinute Int @default(10) + contactEmail String @default("") @db.VarChar(256) +}