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 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:18:08 +02:00
parent ca2efdadea
commit 40d484bede
108 changed files with 13393 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
*/node_modules
*/dist
.git
*.md
.env
+30
View File
@@ -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"
+478
View File
@@ -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.572)
- 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=<hmac>`. 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 = <User.id>
pp:repo = <repoOwner>/<repoName>
pp:prNumber = <prNumber>
pp:previewId = <Preview.id>
```
**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-<previewId>` and store the name in `Preview.sshKeyName`.
2. Create a security group named `pp-preview-<previewId>` in the default VPC. Allow inbound: TCP 22 (SSH) and TCP `<port>` 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-<previewId>`
- Security group: `pp-preview-<previewId>`
- 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-<previewId>`.
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 <aptPackages>`. Only runs on first provision.
5. **Clone repo:**
- Normal PR: `git clone https://<giteaUsername>:<PAT>@<giteaInstanceUrl>/<owner>/<repo>.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/<prNumber>/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 <composeFilePath> 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 <Preview.pid>` (SIGTERM, wait 5s, SIGKILL if still running).
- Docker Compose: `cd /opt/app && docker compose -f <composeFilePath> down`.
3. **Pull latest:**
```bash
cd /opt/app && git fetch origin pull/<prNumber>/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: <commitSha> ---\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 <giteaInstanceUrl>/api/v1/repos/search?limit=50&token=<PAT>` (paginate as needed).
- These appear in the Repo Configuration page for the user to enable.
- When the user **enables** a repo: PP calls `POST <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks` to register a webhook with:
- `type: gitea`
- `config.url`: `https://<PP_BASE_URL>/webhook/<userId>`
- `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 <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks/<giteaWebhookId>`.
- **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 <giteaInstanceUrl>/api/v1/repos/{owner}/{repo}/hooks/<giteaWebhookId>` 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 <giteaInstanceUrl>/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://<PP_BASE_URL>/webhook/<userId>`. 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 — `<repoOwner>/<repoName>` #<prNumber>
**Status:** 🟡 Provisioning EC2 instance...
**Commit:** `<sha>`
**Updated:** <timestamp>
---
_Powered by [PR Previews](<PP_BASE_URL>)_
```
Status line updates as the deploy progresses:
- `🟡 Provisioning EC2 instance...`
- `🟡 Building... (EC2 ready at <ip>)`
- `🟢 Live at http://<ip>:<port>`
- `🔴 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).
+7
View File
@@ -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
+119
View File
@@ -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
+7
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -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
+7
View File
@@ -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": []
}
+33
View File
@@ -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
+7
View File
@@ -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": []
}
+50
View File
@@ -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
+7
View File
@@ -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": []
}
+58
View File
@@ -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
+7
View File
@@ -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"]
}
+37
View File
@@ -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
+7
View File
@@ -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": []
}
+49
View File
@@ -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
+7
View File
@@ -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"]
}
+88
View File
@@ -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
+7
View File
@@ -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": []
}
+40
View File
@@ -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
+7
View File
@@ -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": []
}
+38
View File
@@ -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
+7
View File
@@ -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": []
}
+64
View File
@@ -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
+7
View File
@@ -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": []
}
+192
View File
@@ -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
File diff suppressed because one or more lines are too long
+153
View File
@@ -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
+7
View File
@@ -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": []
}
+180
View File
@@ -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
File diff suppressed because one or more lines are too long
+204
View File
@@ -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
File diff suppressed because one or more lines are too long
+161
View File
@@ -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
File diff suppressed because one or more lines are too long
+279
View File
@@ -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
File diff suppressed because one or more lines are too long
+440
View File
@@ -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
File diff suppressed because one or more lines are too long
+249
View File
@@ -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
File diff suppressed because one or more lines are too long
+170
View File
@@ -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
+7
View File
@@ -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<any[]> {\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<number> {\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<void> {\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<void> {\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<number> {\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<void> {\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<boolean> {\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<string | null> {\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": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAkB;AAClB,wBAAwB;AAGjB,SAAS,SAAS,MAAY;AACnC,QAAM,MAAM,KAAK,eAAW,2BAAQ,KAAK,QAAQ,IAAI;AACrD,SAAO,aAAAA,QAAM,OAAO;AAAA,IAClB,SAAS,GAAG,KAAK,gBAAgB;AAAA,IACjC,SAAS;AAAA,MACP,eAAe,SAAS,GAAG;AAAA,MAC3B,gBAAgB;AAAA,IAClB;AAAA,IACA,SAAS;AAAA,EACX,CAAC;AACH;AAEA,eAAsB,iBAAiB,KAA8E;AACnH,MAAI;AACF,UAAM,MAAM,MAAM,aAAAA,QAAM,IAAI,GAAG,GAAG,mBAAmB,EAAE,SAAS,IAAM,CAAC;AACvE,WAAO,EAAE,SAAS,MAAM,SAAS,IAAI,KAAK,QAAQ;AAAA,EACpD,SAAS,GAAQ;AACf,WAAO,EAAE,SAAS,OAAO,OAAO,EAAE,QAAQ;AAAA,EAC5C;AACF;AAEA,eAAsB,eAAe,MAA4B;AAC/D,QAAM,MAAM,SAAS,IAAI;AACzB,QAAM,QAAe,CAAC;AACtB,MAAI,OAAO;AACX,SAAO,MAAM;AACX,UAAM,MAAM,MAAM,IAAI,IAAI,+BAA+B,IAAI,EAAE;AAC/D,UAAM,OAAO,IAAI,MAAM,QAAQ,CAAC;AAChC,QAAI,KAAK,WAAW,EAAG;AACvB,UAAM,KAAK,GAAG,IAAI;AAClB,QAAI,KAAK,SAAS,GAAI;AACtB;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAsB,gBAAgB,MAAY,OAAe,MAAc,YAAoB,QAAiC;AAClI,QAAM,MAAM,SAAS,IAAI;AACzB,QAAM,MAAM,MAAM,IAAI,KAAK,UAAU,KAAK,IAAI,IAAI,UAAU;AAAA,IAC1D,MAAM;AAAA,IACN,QAAQ;AAAA,MACN,KAAK;AAAA,MACL;AAAA,MACA,cAAc;AAAA,IAChB;AAAA,IACA,QAAQ,CAAC,gBAAgB,eAAe;AAAA,IACxC,QAAQ;AAAA,EACV,CAAC;AACD,SAAO,IAAI,KAAK;AAClB;AAEA,eAAsB,cAAc,MAAY,OAAe,MAAc,QAA+B;AAC1G,QAAM,MAAM,SAAS,IAAI;AACzB,QAAM,IAAI,OAAO,UAAU,KAAK,IAAI,IAAI,UAAU,MAAM,EAAE;AAC5D;AAEA,eAAsB,oBAAoB,MAAY,OAAe,MAAc,QAAgB,YAAoB,WAAkC;AACvJ,QAAM,MAAM,SAAS,IAAI;AACzB,QAAM,IAAI,MAAM,UAAU,KAAK,IAAI,IAAI,UAAU,MAAM,IAAI;AAAA,IACzD,QAAQ;AAAA,MACN,KAAK;AAAA,MACL,QAAQ;AAAA,MACR,cAAc;AAAA,IAChB;AAAA,IACA,QAAQ,CAAC,gBAAgB,eAAe;AAAA,IACxC,QAAQ;AAAA,EACV,CAAC;AACH;AAEA,eAAsB,YAAY,MAAY,OAAe,MAAc,aAAqB,MAA+B;AAC7H,QAAM,MAAM,SAAS,IAAI;AACzB,QAAM,MAAM,MAAM,IAAI,KAAK,UAAU,KAAK,IAAI,IAAI,WAAW,WAAW,aAAa,EAAE,KAAK,CAAC;AAC7F,SAAO,IAAI,KAAK;AAClB;AAEA,eAAsB,cAAc,MAAY,OAAe,MAAc,WAAmB,MAA6B;AAC3H,QAAM,MAAM,SAAS,IAAI;AACzB,QAAM,IAAI,MAAM,UAAU,KAAK,IAAI,IAAI,oBAAoB,SAAS,IAAI,EAAE,KAAK,CAAC;AAClF;AAEA,eAAsB,oBAAoB,MAAY,OAAe,MAAc,UAAoC;AACrH,MAAI;AACF,UAAM,MAAM,SAAS,IAAI;AACzB,UAAM,MAAM,MAAM,IAAI,IAAI,UAAU,KAAK,IAAI,IAAI,kBAAkB,QAAQ,EAAE;AAC7E,WAAO,IAAI,WAAW;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,8BAA8B,MAAY,OAAe,MAAc,UAA0C;AACrI,MAAI;AACF,UAAM,MAAM,SAAS,IAAI;AACzB,UAAM,MAAM,MAAM,IAAI,IAAI,UAAU,KAAK,IAAI,IAAI,kBAAkB,QAAQ,aAAa;AACxF,WAAO,IAAI,MAAM,cAAc;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,mBAAmB,MAWxB;AACT,QAAM,EAAE,OAAO,MAAM,UAAU,QAAQ,WAAW,WAAW,WAAW,cAAc,YAAY,KAAK,IAAI;AAC3G,QAAM,KAAK,UAAU,YAAY,EAAE,QAAQ,KAAK,GAAG,EAAE,MAAM,GAAG,EAAE,IAAI;AAEpE,MAAI,aAAa;AACjB,MAAI,cAAc;AAChB,kBAAc;AAAA;AAAA;AAAA,EAAe,YAAY;AAAA;AAAA,EAC3C;AAEA,SAAO,oCAAwB,KAAK,IAAI,IAAI,OAAO,QAAQ;AAAA;AAAA,cAE/C,UAAU;AAAA,gBACR,UAAU,MAAM,GAAG,CAAC,CAAC;AAAA,eACtB,EAAE;AAAA;AAAA;AAAA,4BAGW,SAAS;AACrC;",
"names": ["axios"]
}
+115
View File
@@ -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
+7
View File
@@ -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<SshSession> {\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<Client> {\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": []
}
+100
View File
@@ -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
+7
View File
@@ -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"]
}
+114
View File
@@ -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
+7
View File
@@ -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<number, number>();\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": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAAuB;AACvB,oBAA6B;AAC7B,oBAAoD;AAEpD,MAAM,UAAM,4BAAa,YAAY;AACrC,IAAI,UAAU;AAEd,eAAsB,iBAAiB;AACrC,MAAI,QAAS;AACb,YAAU;AACV,MAAI,KAAK,oBAAoB;AAE7B,QAAM,eAAe;AACrB,WAAS;AACX;AAEA,eAAe,iBAAiB;AAC9B,QAAM,QAAQ,MAAM,iBAAO,IAAI,WAAW;AAAA,IACxC,OAAO,EAAE,QAAQ,UAAU;AAAA,IAC3B,MAAM,EAAE,QAAQ,WAAW,WAAW,KAAK;AAAA,EAC7C,CAAC;AACD,MAAI,MAAM,QAAQ,EAAG,KAAI,KAAK,EAAE,OAAO,MAAM,MAAM,GAAG,qCAAqC;AAC7F;AAEA,eAAe,WAAW;AACxB,SAAO,SAAS;AACd,QAAI;AACF,YAAM,mBAAmB;AAAA,IAC3B,SAAS,GAAG;AACV,UAAI,MAAM,EAAE,EAAE,GAAG,uBAAuB;AAAA,IAC1C;AACA,UAAM,MAAM,GAAI;AAAA,EAClB;AACF;AAEA,MAAM,oBAAoB,oBAAI,IAAoB;AAElD,eAAe,qBAAqB;AAClC,QAAM,UAAU,MAAM,iBAAO,IAAI,SAAS;AAAA,IACxC,OAAO,EAAE,QAAQ,UAAU;AAAA,IAC3B,SAAS,EAAE,WAAW,MAAM;AAAA,IAC5B,MAAM;AAAA,EACR,CAAC;AAED,aAAW,OAAO,SAAS;AACzB,UAAM,YAAY,IAAI;AACtB,QAAI,CAAC,UAAW;AAEhB,QAAI,kBAAkB,IAAI,SAAS,GAAG;AACpC,YAAM,gBAAgB,kBAAkB,IAAI,SAAS;AAErD,UAAI,IAAI,SAAS,UAAU;AACzB,YAAI,KAAK,EAAE,WAAW,UAAU,IAAI,IAAI,eAAe,cAAc,GAAG,6BAA6B;AACrG,uCAAY,SAAS;AACrB,cAAM,MAAM,GAAG;AAAA,MACjB,OAAO;AACL;AAAA,MACF;AAAA,IACF;AAEA,sBAAkB,IAAI,WAAW,IAAI,EAAE;AAEvC,eAAW,GAAG,EAAE,QAAQ,MAAM;AAC5B,UAAI,kBAAkB,IAAI,SAAS,MAAM,IAAI,IAAI;AAC/C,0BAAkB,OAAO,SAAS;AAAA,MACpC;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAEA,eAAe,WAAW,KAA2E;AACnG,MAAI,KAAK,EAAE,OAAO,IAAI,IAAI,MAAM,IAAI,MAAM,WAAW,IAAI,UAAU,GAAG,gBAAgB;AAEtF,MAAI;AACF,QAAI,IAAI,SAAS,UAAU;AACzB,gBAAM,yBAAU,IAAI,EAAE;AAAA,IACxB,WAAW,IAAI,SAAS,UAAU,IAAI,SAAS,mBAAmB;AAChE,UAAI,IAAI,WAAW;AACjB,cAAM,iBAAO,IAAI,OAAO,EAAE,OAAO,EAAE,IAAI,IAAI,GAAG,GAAG,MAAM,EAAE,QAAQ,WAAW,WAAW,oBAAI,KAAK,EAAE,EAAE,CAAC;AACrG,kBAAM,2BAAY,IAAI,SAAS;AAC/B,cAAM,iBAAO,IAAI,OAAO,EAAE,OAAO,EAAE,IAAI,IAAI,GAAG,GAAG,MAAM,EAAE,QAAQ,QAAQ,YAAY,oBAAI,KAAK,EAAE,EAAE,CAAC;AAAA,MACrG;AAAA,IACF;AAAA,EACF,SAAS,GAAQ;AACf,QAAI,MAAM,EAAE,GAAG,OAAO,IAAI,GAAG,GAAG,sBAAsB;AACtD,UAAM,iBAAO,IAAI,OAAO;AAAA,MACtB,OAAO,EAAE,IAAI,IAAI,GAAG;AAAA,MACpB,MAAM,EAAE,QAAQ,UAAU,OAAO,EAAE,SAAS,YAAY,oBAAI,KAAK,EAAE;AAAA,IACrE,CAAC,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACnB;AACF;AAEO,SAAS,gBAAgB;AAC9B,YAAU;AACZ;AAEA,SAAS,MAAM,IAAY;AACzB,SAAO,IAAI,QAAQ,OAAK,WAAW,GAAG,EAAE,CAAC;AAC3C;",
"names": []
}
+40
View File
@@ -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"
}
}
+2253
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -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"));
+9
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: ["error", "warn"],
errorFormat: "pretty",
});
+23
View File
@@ -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");
}
+26
View File
@@ -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;
+9
View File
@@ -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;
+14
View File
@@ -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 });
}
+83
View File
@@ -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;
+16
View File
@@ -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();
+15
View File
@@ -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();
+49
View File
@@ -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();
}
+170
View File
@@ -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" } });
}
+133
View File
@@ -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);
}
+172
View File
@@ -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" } });
}
}
+182
View File
@@ -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 },
}
});
}
+134
View File
@@ -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 } } });
}
+279
View File
@@ -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<number, { count: number; resetAt: number }>();
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<boolean> {
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;
}
+466
View File
@@ -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<number, SshSession>();
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<number, Set<(text: string) => 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<Preview> = {}) {
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<string, string>;
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<number>();
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 {}
}
}
+242
View File
@@ -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<string, string> = {
"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<string> {
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<string, string>;
}): Promise<string> {
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<string> {
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<void> {
await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] }));
}
export async function deleteKeyPairAws(ec2: EC2Client, keyName: string): Promise<void> {
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<void> {
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<any[]> {
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));
}
+133
View File
@@ -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<any[]> {
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<number> {
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<void> {
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<void> {
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<number> {
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<void> {
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<boolean> {
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<string | null> {
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})_`;
}
+90
View File
@@ -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<SshSession> {
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<Client> {
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));
}
+76
View File
@@ -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");
}
+99
View File
@@ -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<number, number>();
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));
}
+18
View File
@@ -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"]
}
+43
View File
@@ -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:
+21
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PR Previews</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -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"
}
}
+1790
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+91
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center">
<div className="text-2xl animate-spin"></div>
</div>
);
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 (
<AuthContext.Provider value={auth}>
<ToastContainer
position="top-right"
autoClose={4000}
hideProgressBar={false}
newestOnTop
closeOnClick
theme="colored"
/>
<Routes>
<Route path="/login" element={
auth.user ? <Navigate to="/" replace /> : <Login />
} />
<Route path="/privacy" element={<Layout><Privacy /></Layout>} />
<Route path="/*" element={
<ProtectedRoute>
<SetupCheck>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/previews/:id" element={<PreviewDetail />} />
<Route path="/repos" element={<Repos />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="/setup" element={<SetupWizard />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</SetupCheck>
</ProtectedRoute>
} />
</Routes>
</AuthContext.Provider>
);
}
+56
View File
@@ -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 (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onCancel}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white dark:bg-slate-800 rounded-xl shadow-xl p-6 max-w-md w-full"
onClick={e => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-gray-600 dark:text-slate-300 text-sm mb-6">{message}</p>
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm text-white font-medium transition-colors ${
danger ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700"
}`}
>
{confirmLabel}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+82
View File
@@ -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) => (
<Link
to={to}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
location.pathname === to || location.pathname.startsWith(to + "/")
? "bg-blue-600 text-white"
: "text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-800"
}`}
>
{label}
</Link>
);
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80">
🚀 PR Previews
</Link>
</div>
{user && (
<nav className="flex items-center gap-1 overflow-x-auto">
{navLink("/", "Previews")}
{navLink("/repos", "Repos")}
{navLink("/settings", "Settings")}
{user.isAdmin && navLink("/admin", "Admin")}
</nav>
)}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={toggle}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-800 text-lg"
title="Toggle theme"
>
{dark ? "☀️" : "🌙"}
</button>
{user && (
<button
onClick={handleLogout}
className="text-sm px-3 py-1.5 rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors"
>
Logout
</button>
)}
</div>
</div>
</header>
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
{children}
</main>
<footer className="border-t border-gray-200 dark:border-slate-700 py-4 text-center text-xs text-gray-500 dark:text-slate-400">
PR Previews self-hosted preview environments for every pull request.{" "}
<Link to="/privacy" className="underline hover:text-gray-700 dark:hover:text-slate-200">Privacy Policy</Link>
</footer>
</div>
);
}
+62
View File
@@ -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 (
<span key={idx} className="text-slate-400 dark:text-slate-500 italic block">
{line}
</span>
);
}
return <span key={idx} className="block">{stripAnsi(line)}</span>;
}
interface Props {
logs: string;
autoScroll?: boolean;
maxHeight?: string;
}
export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(autoScroll);
useEffect(() => {
if (pinned && endRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [logs, pinned]);
const lines = logs.split("\n");
return (
<div className="relative">
<div
className="log-viewer bg-gray-950 dark:bg-black text-green-400 rounded-lg p-4 overflow-auto border border-gray-800"
style={{ maxHeight }}
onScroll={(e) => {
const el = e.currentTarget;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
setPinned(atBottom);
}}
>
<pre className="ansi-stripped text-xs">
{lines.map((line, i) => renderLogLine(line, i))}
</pre>
<div ref={endRef} />
</div>
{!pinned && (
<button
onClick={() => { setPinned(true); endRef.current?.scrollIntoView({ behavior: "smooth" }); }}
className="absolute bottom-4 right-4 text-xs bg-blue-600 text-white px-2 py-1 rounded shadow hover:bg-blue-700"
>
Jump to bottom
</button>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
type Status = "PROVISIONING" | "BUILDING" | "RUNNING" | "FAILED" | "STOPPED" | "IGNORED";
const CONFIG: Record<Status, { label: string; icon: string; cls: string }> = {
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 (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.cls}`}>
<span>{cfg.icon}</span> {cfg.label}
</span>
);
}
+54
View File
@@ -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<void>;
}
import { createContext as _createContext } from "react";
export const AuthContext = _createContext<AuthContextType>({
user: null,
loading: true,
refresh: async () => {},
});
export function useAuth() {
return useContext(AuthContext);
}
export function useAuthProvider(): AuthContextType {
const [user, setUser] = useState<AuthUser | null>(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 };
}
+16
View File
@@ -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) };
}
+43
View File
@@ -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; }
+13
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+286
View File
@@ -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<any[]>([]);
const [settings, setSettings] = useState<any>(null);
const [previews, setPreviews] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [editUser, setEditUser] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [stopConfirm, setStopConfirm] = useState<any>(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 (
<div className="text-center py-20">
<div className="text-5xl mb-4">🚫</div>
<h2 className="text-xl font-semibold">Access Denied</h2>
</div>
);
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 (
<div className="max-w-5xl">
<ConfirmDialog
open={!!deleteConfirm}
title="Delete User"
message={`Delete user "${deleteConfirm?.username}"? All their data will be removed.`}
confirmLabel="Delete User"
onConfirm={handleDeleteUser}
onCancel={() => setDeleteConfirm(null)}
danger
/>
<ConfirmDialog
open={!!stopConfirm}
title="Stop Preview"
message={`Stop preview #${stopConfirm?.id} for ${stopConfirm?.user?.username}?`}
confirmLabel="Stop"
onConfirm={handleStopPreview}
onCancel={() => setStopConfirm(null)}
danger
/>
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
<div className="flex gap-2 mb-6 border-b border-gray-200 dark:border-slate-700">
{(["users", "settings", "previews"] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
tab === t ? "border-blue-600 text-blue-600 dark:text-blue-400" : "border-transparent text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-slate-200"
}`}>
{t}
</button>
))}
</div>
{tab === "users" && (
<div className="space-y-6">
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-5">
<h2 className="font-semibold mb-4">Create User</h2>
<form onSubmit={handleCreateUser} className="flex gap-3">
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)}
placeholder="Username" className={inputCls} required />
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
placeholder="Password" className={inputCls} required />
<button type="submit" disabled={saving} className={btnCls}>Create</button>
</form>
</div>
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 text-xs uppercase text-gray-500 dark:text-slate-400">
<th className="px-4 py-3 text-left">Username</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Created</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b border-gray-100 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3 font-medium">
{u.username}
{u.isFounder && <span className="ml-2 text-xs bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300 px-1.5 py-0.5 rounded-full">Founder</span>}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${u.isAdmin ? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" : "bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300"}`}>
{u.isAdmin ? "Admin" : "User"}
</span>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right">
<div className="flex gap-2 justify-end">
{!u.isFounder && u.id !== user.id && (
<>
<button
onClick={async () => {
const res = await api.admin.updateUser(u.id, { isAdmin: !u.isAdmin });
if (res.ok) { toast.success(`User ${u.isAdmin ? "demoted" : "promoted"}`); loadUsers(); }
else toast.error(res.message || "Failed");
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{u.isAdmin ? "Demote" : "Promote"}
</button>
<button onClick={() => setDeleteConfirm(u)}
className="text-xs text-red-600 dark:text-red-400 hover:underline">Delete</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === "settings" && settings && (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-5">
<h2 className="font-semibold mb-4">Global Settings</h2>
<form onSubmit={handleSaveSettings} className="space-y-4">
<Field label="Default EC2 Instance Type">
<input type="text" value={settings.defaultInstanceType}
onChange={e => setSettings((s: any) => ({ ...s, defaultInstanceType: e.target.value }))}
className={inputCls} />
</Field>
<Field label="Max Concurrent Instances Per User">
<input type="number" value={settings.maxConcurrentInstancesPerUser}
onChange={e => setSettings((s: any) => ({ ...s, maxConcurrentInstancesPerUser: Number(e.target.value) }))}
className={inputCls} min={1} max={50} />
</Field>
<Field label="Webhook Rate Limit (per minute per user)">
<input type="number" value={settings.webhookRateLimitPerMinute}
onChange={e => setSettings((s: any) => ({ ...s, webhookRateLimitPerMinute: Number(e.target.value) }))}
className={inputCls} min={1} />
</Field>
<Field label="Log Size Limit (bytes)">
<input type="number" value={settings.logSizeLimitBytes}
onChange={e => setSettings((s: any) => ({ ...s, logSizeLimitBytes: Number(e.target.value) }))}
className={inputCls} min={1024} />
</Field>
<Field label="Preview Retention (days)">
<input type="number" value={settings.previewRetentionDays}
onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))}
className={inputCls} min={1} />
</Field>
<Field label="Contact Email">
<input type="email" value={settings.contactEmail}
onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))}
className={inputCls} placeholder="admin@example.com" />
</Field>
<button type="submit" disabled={saving} className={btnCls}>
{saving ? "Saving..." : "Save Settings"}
</button>
</form>
</div>
)}
{tab === "previews" && (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
<h2 className="font-semibold">All Previews</h2>
<button onClick={loadPreviews} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> Refresh</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 text-xs uppercase text-gray-500 dark:text-slate-400">
<th className="px-4 py-3 text-left">Repo / PR</th>
<th className="px-4 py-3 text-left">User</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">IP</th>
<th className="px-4 py-3 text-left">Created</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{previews.map(p => (
<tr key={p.id} className="border-b border-gray-100 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3">
<p className="text-xs text-gray-500 dark:text-slate-400">{p.repoOwner}/{p.repoName}</p>
<Link to={`/previews/${p.id}`} className="font-medium hover:text-blue-600 dark:hover:text-blue-400">PR #{p.prNumber}</Link>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{p.user?.username}</td>
<td className="px-4 py-3"><StatusBadge status={p.status} /></td>
<td className="px-4 py-3">
{p.instanceIp ? (
<a href={`http://${p.instanceIp}:${p.port}`} target="_blank" rel="noreferrer"
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline">
{p.instanceIp}:{p.port}
</a>
) : "—"}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(p.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right">
{p.status !== "STOPPED" && p.status !== "IGNORED" && (
<button onClick={() => setStopConfirm(p)}
className="text-xs text-red-600 dark:text-red-400 hover:underline">Stop</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">{label}</label>
{children}
</div>
);
}
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";
+129
View File
@@ -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<Preview[]>([]);
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 (
<div className="text-center py-20">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold mb-2">Setup Required</h2>
<p className="text-gray-600 dark:text-slate-400 mb-6">Configure your Gitea and AWS credentials to get started.</p>
<Link
to="/settings"
className="inline-flex px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Go to Settings
</Link>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Previews</h1>
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
Refresh
</button>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-40 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />
))}
</div>
) : previews.length === 0 ? (
<div className="text-center py-20">
<div className="text-5xl mb-4">🔍</div>
<h2 className="text-xl font-semibold mb-2">No previews yet</h2>
<p className="text-gray-500 dark:text-slate-400 mb-4">
Enable a repo and open a pull request to create your first preview.
</p>
<Link to="/repos" className="text-blue-600 dark:text-blue-400 hover:underline">
Configure repos
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{previews.map((p, i) => (
<motion.div
key={p.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
>
<Link
to={`/previews/${p.id}`}
className="block bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
>
<div className="flex items-start justify-between mb-2">
<div>
<p className="text-xs text-gray-500 dark:text-slate-400">{p.repoOwner}/{p.repoName}</p>
<p className="font-semibold text-sm mt-0.5 line-clamp-1">PR #{p.prNumber}: {p.prTitle}</p>
</div>
<StatusBadge status={p.status as any} />
</div>
<p className="text-xs text-gray-500 dark:text-slate-400 mb-2">
Commit: <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded">{p.commitSha.slice(0, 8)}</code>
</p>
{p.status === "RUNNING" && p.instanceIp && (
<a
href={`http://${p.instanceIp}:${p.port}`}
target="_blank"
rel="noreferrer"
onClick={e => 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}
</a>
)}
<p className="text-xs text-gray-400 dark:text-slate-500 mt-2">
Updated {new Date(p.updatedAt).toLocaleString()}
</p>
</Link>
</motion.div>
))}
</div>
)}
</div>
);
}
+122
View File
@@ -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<boolean | null>(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 <div className="min-h-screen flex items-center justify-center">
<div className="text-2xl animate-spin"></div>
</div>;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-slate-900 px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white dark:bg-slate-800 shadow-xl rounded-2xl p-8 w-full max-w-sm"
>
<div className="text-center mb-6">
<div className="text-4xl mb-2">🚀</div>
<h1 className="text-2xl font-bold">PR Previews</h1>
{needsSetup ? (
<p className="text-sm text-blue-600 dark:text-blue-400 mt-1 font-medium">Create your admin account</p>
) : (
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">Sign in to manage your previews</p>
)}
</div>
{needsSetup && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-xs text-blue-700 dark:text-blue-300">
This is the first time setup. Create the founder admin account.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
type="text"
value={username}
onChange={e => 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
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={e => 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}
/>
</div>
{needsSetup && (
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => 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
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm transition-colors disabled:opacity-50"
>
{loading ? (needsSetup ? "Creating..." : "Signing in...") : (needsSetup ? "Create Admin Account" : "Sign in")}
</button>
</form>
</motion.div>
</div>
);
}
+195
View File
@@ -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<Preview | null>(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 (
<div className="space-y-4 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-slate-700 rounded w-64" />
<div className="h-4 bg-gray-200 dark:bg-slate-700 rounded w-96" />
<div className="h-96 bg-gray-200 dark:bg-slate-700 rounded" />
</div>
);
}
if (!preview) return null;
return (
<div className="space-y-6">
<ConfirmDialog
open={confirmStop}
title="Stop Preview"
message="Are you sure you want to stop this preview? The EC2 instance will be terminated."
confirmLabel="Stop Preview"
onConfirm={handleStop}
onCancel={() => setConfirmStop(false)}
danger
/>
<div>
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-2 inline-block">
Back to Previews
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">
{preview.repoOwner}/{preview.repoName} PR #{preview.prNumber}
</h1>
<p className="text-gray-600 dark:text-slate-300 mt-1">{preview.prTitle}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={preview.status as any} />
{preview.status !== "STOPPED" && preview.status !== "IGNORED" && (
<button
onClick={() => setConfirmStop(true)}
disabled={stopping}
className="px-3 py-1.5 text-sm bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60 rounded-lg transition-colors disabled:opacity-50"
>
Stop
</button>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoCard label="Commit" value={preview.commitSha.slice(0, 8)} mono />
<InfoCard label="Port" value={String(preview.port)} />
<InfoCard label="Created" value={new Date(preview.createdAt).toLocaleDateString()} />
<InfoCard label="Last Activity" value={new Date(preview.lastActivityAt).toLocaleString()} />
</div>
{preview.status === "RUNNING" && preview.instanceIp && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4">
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-1">🟢 Preview Live</p>
<a
href={`http://${preview.instanceIp}:${preview.port}`}
target="_blank"
rel="noreferrer"
className="text-green-700 dark:text-green-400 hover:underline font-mono text-sm"
>
http://{preview.instanceIp}:{preview.port}
</a>
</div>
)}
<div>
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer logs={logs} autoScroll maxHeight="600px" />
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Job History</h2>
<div className="space-y-2">
{preview.jobs.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-slate-400">No jobs yet.</p>
) : preview.jobs.map(job => (
<div key={job.id} className="flex items-center gap-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-3">
<span className="text-xs font-mono bg-gray-100 dark:bg-slate-700 px-2 py-0.5 rounded">{job.type}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
job.status === "DONE" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300" :
job.status === "FAILED" ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" :
job.status === "RUNNING" ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300" :
"bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300"
}`}>{job.status}</span>
<span className="text-xs text-gray-500 dark:text-slate-400">{new Date(job.createdAt).toLocaleString()}</span>
{job.error && (
<span className="text-xs text-red-600 dark:text-red-400 truncate max-w-xs" title={job.error}>{job.error}</span>
)}
</div>
))}
</div>
</div>
</div>
);
}
function InfoCard({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-3">
<p className="text-xs text-gray-500 dark:text-slate-400 mb-1">{label}</p>
<p className={`text-sm font-medium truncate ${mono ? "font-mono" : ""}`}>{value}</p>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from "react";
import { Link } from "react-router-dom";
export function Privacy() {
return (
<div className="max-w-2xl prose dark:prose-invert">
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-4 inline-block"> Back</Link>
<h1>Privacy Policy</h1>
<p>This is a self-hosted instance of PR Previews (PP). The following describes what data PP stores and how it is used.</p>
<h2>What We Store</h2>
<ul>
<li>Your username and hashed password.</li>
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256.</li>
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256.</li>
<li>Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.</li>
<li>SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).</li>
</ul>
<h2>How We Use Your Data</h2>
<ul>
<li>Your Gitea PAT is used solely to register webhooks, clone repositories, and post preview status comments on PRs.</li>
<li>Your AWS credentials are used solely to provision EC2 instances for previews in your own AWS account.</li>
<li>PP does not have access to data on EC2 instances beyond what it deploys.</li>
<li>No data is shared with third parties.</li>
</ul>
<h2>Data Retention</h2>
<p>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.</p>
<h2>EC2 Instances</h2>
<p>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.</p>
<h2>Analytics & Tracking</h2>
<p>No analytics, no tracking, no external data sharing. PP is fully self-contained.</p>
<h2>Contact</h2>
<p>For questions or concerns, contact the instance administrator.</p>
</div>
);
}
+346
View File
@@ -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<Repo[]>([]);
const [loading, setLoading] = useState(true);
const [expandedRepo, setExpandedRepo] = useState<string | null>(null);
const [configs, setConfigs] = useState<Record<string, any>>({});
const [disableConfirm, setDisableConfirm] = useState<{ owner: string; repo: string } | null>(null);
const [togglingRepo, setTogglingRepo] = useState<string | null>(null);
const [savingRepo, setSavingRepo] = useState<string | null>(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<string, any> = {};
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 <div className="space-y-3">
{[...Array(4)].map((_, i) => <div key={i} className="h-16 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />)}
</div>;
}
return (
<div className="max-w-3xl space-y-4">
<ConfirmDialog
open={!!disableConfirm}
title="Disable Repo"
message={`Disabling "${disableConfirm?.owner}/${disableConfirm?.repo}" will delete the Gitea webhook. PR preview events will no longer be received.`}
confirmLabel="Disable & Delete Webhook"
onConfirm={handleDisable}
onCancel={() => setDisableConfirm(null)}
danger
/>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Repo Configuration</h1>
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> Refresh</button>
</div>
{repos.length === 0 && (
<div className="text-center py-16">
<div className="text-5xl mb-4">🔗</div>
<h2 className="text-lg font-semibold mb-2">No repos found</h2>
<p className="text-gray-500 dark:text-slate-400">Configure your Gitea credentials in Settings first.</p>
</div>
)}
{repos.map(repo => {
const key = `${repo.owner}/${repo.name}`;
const expanded = expandedRepo === key;
const config = configs[key] || defaultConfig();
const isToggling = togglingRepo === key;
return (
<div key={key} className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="p-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{repo.fullName}</p>
{repo.claimedByOther && (
<span className="text-xs text-amber-600 dark:text-amber-400">🔒 Claimed by another user</span>
)}
</div>
<div className="flex items-center gap-2">
{!repo.claimedByOther && (
<button
onClick={() => handleToggle(repo)}
disabled={isToggling}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
repo.isEnabled ? "bg-blue-600" : "bg-gray-300 dark:bg-slate-600"
} disabled:opacity-50`}
title={repo.isEnabled ? "Disable previews" : "Enable previews"}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${repo.isEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
)}
{repo.isEnabled && !repo.claimedByOther && (
<button
onClick={() => setExpandedRepo(expanded ? null : key)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{expanded ? "▲ Hide" : "▼ Config"}
</button>
)}
</div>
</div>
{expanded && (
<div className="border-t border-gray-200 dark:border-slate-700 p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>Instance Type</label>
<select value={config.instanceType} onChange={e => updateConfig(key, "instanceType", e.target.value)} className={inputCls}>
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} {t.cost}</option>)}
<option value="custom">Custom...</option>
</select>
{config.instanceType === "custom" && (
<input type="text" placeholder="Custom instance type" className={`${inputCls} mt-1`}
onChange={e => updateConfig(key, "instanceType", e.target.value)} />
)}
</div>
<div>
<label className={labelCls}>App Port (default: 3000)</label>
<input type="number" value={config.port} onChange={e => updateConfig(key, "port", Number(e.target.value))} className={inputCls} min={1} max={65535} />
</div>
</div>
<div>
<label className={labelCls}>Inactivity Kill Timer: {config.inactivityHours}h</label>
<input type="range" min={0.5} max={72} step={0.5} value={config.inactivityHours}
onChange={e => updateConfig(key, "inactivityHours", Number(e.target.value))}
className="w-full mt-1" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0.5h</span><span>12h</span><span>72h</span>
</div>
</div>
<div>
<label className={labelCls}>Deny List (comma-separated usernames)</label>
<input type="text"
value={config.denyList.join(", ")}
onChange={e => updateConfig(key, "denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))}
className={inputCls}
placeholder="dependabot, renovate-bot"
/>
</div>
<div>
<label className={labelCls}>Apt Packages (space-separated)</label>
<input type="text"
value={config.aptPackages.join(" ")}
onChange={e => updateConfig(key, "aptPackages", e.target.value.split(" ").filter(Boolean))}
className={inputCls}
placeholder="python3 ffmpeg"
/>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<input type="checkbox" id={`compose-${key}`} checked={config.useDockerCompose}
onChange={e => updateConfig(key, "useDockerCompose", e.target.checked)} className="rounded" />
<label htmlFor={`compose-${key}`} className="text-sm font-medium">Use Docker Compose</label>
</div>
{config.useDockerCompose ? (
<div>
<label className={labelCls}>Compose File Path</label>
<input type="text" value={config.composeFilePath || "docker-compose.yml"}
onChange={e => updateConfig(key, "composeFilePath", e.target.value)}
className={inputCls} placeholder="docker-compose.yml" />
</div>
) : (
<div className="space-y-3">
<CommandList label="Build Commands" value={config.buildCommands}
onChange={v => updateConfig(key, "buildCommands", v)} />
<CommandList label="Post-Build Commands (optional)" value={config.postBuildCommands}
onChange={v => updateConfig(key, "postBuildCommands", v)} />
<div>
<label className={labelCls}>Run Command</label>
<input type="text" value={config.runCommand || ""}
onChange={e => updateConfig(key, "runCommand", e.target.value)}
className={inputCls} placeholder="node dist/index.js" />
</div>
</div>
)}
</div>
<CommandList label="Setup Commands (run once on first provision)"
value={config.setupCommands}
onChange={v => updateConfig(key, "setupCommands", v)} />
<EnvVarsEditor value={config.envVars || {}}
onChange={v => updateConfig(key, "envVars", v)} />
<div className="flex justify-end">
<button
onClick={() => handleSaveConfig(repo.owner, repo.name)}
disabled={savingRepo === key}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{savingRepo === key ? "Saving..." : "Save Config"}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
function CommandList({ label, value, onChange }: { label: string; value: string[]; onChange: (v: string[]) => void }) {
return (
<div>
<label className={labelCls}>{label}</label>
<div className="space-y-1">
{value.map((cmd, i) => (
<div key={i} className="flex gap-2">
<input type="text" value={cmd}
onChange={e => { const n = [...value]; n[i] = e.target.value; onChange(n); }}
className={inputCls} placeholder="npm run build" />
<button onClick={() => onChange(value.filter((_, j) => j !== i))} className="text-red-500 hover:text-red-700 px-2"></button>
</div>
))}
<button onClick={() => onChange([...value, ""])} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
+ Add command
</button>
</div>
</div>
);
}
function EnvVarsEditor({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
const entries = Object.entries(value);
const addEntry = () => onChange({ ...value, "": "" });
const updateEntry = (oldKey: string, newKey: string, newVal: string) => {
const next: Record<string, string> = {};
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 (
<div>
<label className={labelCls}>Environment Variables</label>
<div className="space-y-1">
{entries.map(([k, v], i) => (
<div key={i} className="flex gap-2">
<input type="text" value={k} placeholder="KEY"
onChange={e => updateEntry(k, e.target.value, v)}
className={`${inputCls} w-1/3 font-mono text-xs`} />
<input type="password" value={v} placeholder="value"
onChange={e => updateEntry(k, k, e.target.value)}
className={`${inputCls} flex-1 font-mono text-xs`} />
<button onClick={() => removeEntry(k)} className="text-red-500 hover:text-red-700 px-2"></button>
</div>
))}
<button onClick={addEntry} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
+ Add variable
</button>
</div>
</div>
);
}
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";
+290
View File
@@ -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<any>(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<string | null>(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<any>) => {
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 <div className="animate-pulse h-96 bg-gray-100 dark:bg-slate-800 rounded-xl" />;
return (
<div className="max-w-2xl space-y-8">
<ConfirmDialog
open={confirmRotate}
title="Regenerate Webhook Secret"
message="This will generate a new secret and update all registered Gitea webhooks. The old secret will become invalid immediately."
confirmLabel="Regenerate"
onConfirm={handleRotateSecret}
onCancel={() => setConfirmRotate(false)}
danger
/>
<h1 className="text-2xl font-bold">Account Settings</h1>
{/* Username */}
<Section title="Username">
<div className="flex gap-2">
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className={inputCls}
placeholder="Username"
/>
<button
onClick={() => save("username", () => api.user.updateUsername(username))}
disabled={saving === "username"}
className={btnCls}
>
{saving === "username" ? "Saving..." : "Save"}
</button>
</div>
</Section>
{/* Password */}
<Section title="Change Password">
<div className="space-y-2">
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
className={inputCls}
placeholder="Current password"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className={inputCls}
placeholder="New password"
/>
<button
onClick={() => save("password", () => api.user.updatePassword(currentPassword, newPassword))}
disabled={saving === "password"}
className={btnCls}
>
{saving === "password" ? "Saving..." : "Update Password"}
</button>
</div>
</Section>
{/* Gitea */}
<Section title="Gitea Connection">
<p className="text-xs text-gray-500 dark:text-slate-400 mb-3">
PP will post PR comments as your Gitea account (@{giteaUsername || "username"}). Make sure your PAT has 'issue' write permission.
</p>
<div className="space-y-2">
<input
type="url"
value={giteaUrl}
onChange={e => setGiteaUrl(e.target.value)}
className={inputCls}
placeholder="https://gitea.example.com"
/>
<input
type="text"
value={giteaUsername}
onChange={e => setGiteaUsername(e.target.value)}
className={inputCls}
placeholder="Gitea username"
/>
<div className="relative">
<input
type="password"
value={giteaPAT}
onChange={e => setGiteaPAT(e.target.value)}
className={inputCls}
placeholder={settings?.giteaPatSet ? "•••••••• (set — enter new value to update)" : "Personal Access Token"}
/>
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1">
Required PAT scopes: <code>repository</code> (read), <code>issue</code> (write), <code>admin:repo_hook</code>
</p>
</div>
<button
onClick={() => save("gitea", () => api.user.updateGitea({ giteaInstanceUrl: giteaUrl, giteaUsername, giteaPAT: giteaPAT || undefined }))}
disabled={saving === "gitea"}
className={btnCls}
>
{saving === "gitea" ? "Connecting..." : "Save & Validate"}
</button>
</div>
</Section>
{/* AWS */}
<Section title="AWS Credentials">
<div className="space-y-2">
<input
type="text"
value={awsKeyId}
onChange={e => setAwsKeyId(e.target.value)}
className={inputCls}
placeholder={settings?.awsAccessKeyId ? `${settings.awsAccessKeyId} (set)` : "AWS Access Key ID"}
/>
<input
type="password"
value={awsSecret}
onChange={e => setAwsSecret(e.target.value)}
className={inputCls}
placeholder="AWS Secret Access Key"
/>
<select
value={awsRegion}
onChange={e => setAwsRegion(e.target.value)}
className={inputCls}
>
{AWS_REGIONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
<p className="text-xs font-medium mb-2">Required IAM Permissions:</p>
<pre className="text-xs text-gray-600 dark:text-slate-300 overflow-auto">{IAM_POLICY}</pre>
<button onClick={() => copyToClipboard(IAM_POLICY, "IAM policy")} className="text-xs text-blue-600 dark:text-blue-400 mt-1 hover:underline">
Copy IAM policy
</button>
</div>
<button
onClick={() => save("aws", () => api.user.updateAws({ awsAccessKeyId: awsKeyId, awsSecretAccessKey: awsSecret, awsRegion }))}
disabled={saving === "aws"}
className={btnCls}
>
{saving === "aws" ? "Validating..." : "Save & Validate"}
</button>
</div>
</Section>
{/* Webhook */}
<Section title="Webhook">
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500 dark:text-slate-400 block mb-1">Webhook URL (Target URL in Gitea)</label>
<div className="flex gap-2">
<input type="text" readOnly value={webhookUrl} className={`${inputCls} bg-gray-50 dark:bg-slate-700/50 font-mono text-xs`} />
<button onClick={() => copyToClipboard(webhookUrl, "Webhook URL")} className={btnSecCls}>Copy</button>
</div>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-slate-400 block mb-1">Webhook Secret</label>
<div className="flex gap-2">
<input type="text" readOnly value={webhookSecret} className={`${inputCls} bg-gray-50 dark:bg-slate-700/50 font-mono text-xs`} />
<button onClick={() => copyToClipboard(webhookSecret, "Webhook secret")} className={btnSecCls}>Copy</button>
<button onClick={() => setConfirmRotate(true)} disabled={saving === "rotate"} className="px-3 py-2 text-sm rounded-lg bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 hover:bg-yellow-200 transition-colors">
Rotate
</button>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-slate-400">
PP auto-registers per-repo webhooks when you enable a repo. These credentials are for reference only.
</p>
</div>
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-5">
<h2 className="text-base font-semibold mb-4">{title}</h2>
{children}
</div>
);
}
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": "*"
}]
}`;
+227
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-slate-900 px-4">
<div className="bg-white dark:bg-slate-800 shadow-xl rounded-2xl p-8 w-full max-w-lg">
{/* Progress */}
<div className="mb-8">
<div className="flex items-center gap-1">
{STEPS.map((s, i) => (
<React.Fragment key={i}>
<div className={`h-1.5 flex-1 rounded-full transition-colors ${i <= step ? "bg-blue-600" : "bg-gray-200 dark:bg-slate-600"}`} />
</React.Fragment>
))}
</div>
<p className="text-xs text-gray-500 dark:text-slate-400 mt-2">Step {step + 1} of {STEPS.length}: {STEPS[step]}</p>
</div>
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
>
{step === 0 && (
<div className="text-center space-y-4">
<div className="text-5xl">🚀</div>
<h2 className="text-2xl font-bold">Welcome to PR Previews</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm">
PP automatically provisions EC2 instances, builds your code, and posts live preview URLs on every pull request.
</p>
<p className="text-sm text-gray-500 dark:text-slate-400">Let's get you set up in a few steps.</p>
<div className="flex gap-3 justify-center mt-4">
<button onClick={next} className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Get Started
</button>
<button onClick={skip} className="px-4 py-2.5 text-sm text-gray-500 dark:text-slate-400 hover:underline">
Skip wizard
</button>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Gitea Connection</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">Connect to your Gitea instance so PP can register webhooks and post comments.</p>
<input type="url" value={giteaUrl} onChange={e => setGiteaUrl(e.target.value)}
className={inputCls} placeholder="https://gitea.example.com" />
<input type="text" value={giteaUsername} onChange={e => setGiteaUsername(e.target.value)}
className={inputCls} placeholder="Gitea username" />
<input type="password" value={giteaPAT} onChange={e => setGiteaPAT(e.target.value)}
className={inputCls} placeholder="Personal Access Token" />
<p className="text-xs text-gray-400 dark:text-slate-500">
Required PAT scopes: <code>repository</code>, <code>issue</code>, <code>admin:repo_hook</code>
</p>
<div className="flex gap-3">
<button onClick={handleGiteaNext} disabled={saving || !giteaUrl || !giteaUsername || !giteaPAT}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50">
{saving ? "Validating..." : "Connect & Next →"}
</button>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">AWS Setup</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">PP will launch EC2 instances in your AWS account to host previews.</p>
<input type="text" value={awsKeyId} onChange={e => setAwsKeyId(e.target.value)}
className={inputCls} placeholder="AWS Access Key ID" />
<input type="password" value={awsSecret} onChange={e => setAwsSecret(e.target.value)}
className={inputCls} placeholder="AWS Secret Access Key" />
<select value={awsRegion} onChange={e => setAwsRegion(e.target.value)} className={inputCls}>
{AWS_REGIONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<details className="text-xs">
<summary className="cursor-pointer text-blue-600 dark:text-blue-400">View required IAM permissions</summary>
<pre className="mt-2 bg-gray-50 dark:bg-slate-700 rounded p-3 overflow-auto text-gray-700 dark:text-slate-300">
{`{
"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": "*"
}]
}`}
</pre>
</details>
<div className="flex gap-3">
<button onClick={handleAwsNext} disabled={saving || !awsKeyId || !awsSecret}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50">
{saving ? "Validating..." : "Connect & Next →"}
</button>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Your Webhook</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">
PP auto-registers webhooks when you enable a repo. These are for reference:
</p>
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500 block mb-1">Webhook URL (Target URL in Gitea)</label>
<input type="text" readOnly value={webhookUrl || `${window.location.origin}/webhook/${user?.id}`}
className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Webhook Secret</label>
<input type="text" readOnly value={webhookSecret || "(loading...)"}
className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div>
</div>
<button onClick={next} className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Next
</button>
</div>
)}
{step === 4 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Enable Your First Repo</h2>
<p className="text-sm text-gray-600 dark:text-slate-300">
Go to the Repos page to enable previews for a repository. PP will automatically register the webhook.
</p>
<div className="flex gap-3">
<a href="/repos"
className="flex-1 py-2.5 text-center bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Repos
</a>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
</div>
)}
{step === 5 && (
<div className="text-center space-y-4">
<div className="text-5xl">🎉</div>
<h2 className="text-2xl font-bold">You're all set!</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm">
Open a pull request on an enabled repo and PP will provision a preview automatically.
</p>
<button onClick={() => navigate("/")} className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Dashboard
</button>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
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";
+74
View File
@@ -0,0 +1,74 @@
const BASE = "/api";
async function request<T = any>(
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;
}

Some files were not shown because too many files have changed in this diff Show More