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:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
*/node_modules
|
||||||
|
*/dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
+30
@@ -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"
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
# PR Previews (PP)
|
||||||
|
> PP is a self-hosted service that connects to a Gitea instance via webhook. When a PR is opened or updated, PP automatically provisions an AWS EC2 instance, builds and runs the project, and comments a live preview URL back on the PR. Designed for teams and agents iterating on code without needing local environments.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
- Provide a shareable live preview of any PR before it's merged.
|
||||||
|
- Remove the need for reviewers to pull and run code locally.
|
||||||
|
- Works well in agent-driven workflows where humans review from mobile or external environments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Backend:** `rj-web` from `../shsf` (same setup pattern), logging + auth middleware + Prisma from `../shsf` adapted to this project's needs.
|
||||||
|
- **Frontend:** React + Vite + Tailwind CSS (dark/light mode toggle, responsive).
|
||||||
|
- **Database:** PostgreSQL via Prisma (schema at `./prisma/schema.prisma`).
|
||||||
|
- **CI/CD:** Reuse `../shsf` CI/CD pipeline; build and push Docker image to the same registry under `/pp-previews/core`.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
./backend/ # Node backend (rj-web)
|
||||||
|
./frontend/ # React + Vite frontend
|
||||||
|
./prisma/schema.prisma # Prisma schema
|
||||||
|
./docker-compose.yml # Compose + Dockerfile for PP itself
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
You're building this from scratch. Refer to `../shsf` for patterns on rj-web setup, logging, auth middleware, and Prisma client initialization.
|
||||||
|
|
||||||
|
**Testing:** No automated tests for V1. Build to fully working state locally first. The first real test will be against a live Gitea instance with a real repo. Build as if it will be tested end-to-end immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model (Prisma)
|
||||||
|
|
||||||
|
### User
|
||||||
|
PP account.
|
||||||
|
- id, username, passwordHash, isAdmin, isFounder, createdAt
|
||||||
|
- giteaUsername, giteaPAT (encrypted), giteaInstanceUrl (e.g. `https://gitea.example.com`, no trailing slash)
|
||||||
|
- awsAccessKeyId (encrypted), awsSecretAccessKey (encrypted), awsRegion
|
||||||
|
|
||||||
|
### RepoConfig
|
||||||
|
Per-repo settings, one per user per repo. A repo can only be configured by one user — first to save wins; others receive an error.
|
||||||
|
- id, userId, repoOwner, repoName
|
||||||
|
- isEnabled (bool)
|
||||||
|
- giteaWebhookId (string, stored after auto-registration so PP can delete/update it later)
|
||||||
|
- denyList (string[], Gitea usernames to skip — e.g. dependabot)
|
||||||
|
- instanceType (string, default from AdminSettings.defaultInstanceType)
|
||||||
|
- inactivityHours (float, default 12, range 0.5–72)
|
||||||
|
- port (int, default 3000 — the port the app listens on inside the EC2 instance)
|
||||||
|
- envVars (JSON key-value)
|
||||||
|
- useDockerCompose (bool)
|
||||||
|
- composeFilePath (string, nullable — path within repo, default `docker-compose.yml`)
|
||||||
|
- aptPackages (string[])
|
||||||
|
- setupCommands (string[])
|
||||||
|
- buildCommands (string[])
|
||||||
|
- postBuildCommands (string[])
|
||||||
|
- runCommand (string, single — non-compose mode only)
|
||||||
|
|
||||||
|
### Preview
|
||||||
|
One record per PR. Persists across pushes; the EC2 instance is reused on each new push (only build + run steps re-run).
|
||||||
|
- id, repoConfigId, prNumber, prTitle
|
||||||
|
- commitSha (updated on each push)
|
||||||
|
- instanceId (AWS EC2 instance ID)
|
||||||
|
- instanceIp (public IP)
|
||||||
|
- port (copied from RepoConfig at deploy time)
|
||||||
|
- status (enum: `PROVISIONING` | `BUILDING` | `RUNNING` | `FAILED` | `STOPPED` | `IGNORED`)
|
||||||
|
- logs (text, appended each redeploy, capped at AdminSettings.logSizeLimitBytes — oldest lines truncated with a `--- logs truncated ---` marker)
|
||||||
|
- pid (int, nullable — non-compose mode only, the background process PID on the EC2 instance)
|
||||||
|
- sshPrivateKey (encrypted — ephemeral, generated per launch, deleted on instance termination)
|
||||||
|
- sshKeyName (string — AWS key pair name, for deletion on terminate)
|
||||||
|
- giteaCommentId (int, nullable — the Gitea comment ID of PP's status comment, for in-place edits)
|
||||||
|
- lastActivityAt (DateTime — updated on each push webhook AND each PR comment webhook)
|
||||||
|
- createdAt, updatedAt, stoppedAt
|
||||||
|
|
||||||
|
### Job
|
||||||
|
Durable async job queue for deployments and stops.
|
||||||
|
- id, previewId (nullable), type (enum: `DEPLOY` | `STOP` | `INACTIVITY_STOP`), status (enum: `PENDING` | `RUNNING` | `DONE` | `FAILED`)
|
||||||
|
- payload (JSON), error (text, nullable)
|
||||||
|
- createdAt, startedAt, finishedAt
|
||||||
|
|
||||||
|
### WebhookToken
|
||||||
|
Per-user HMAC secret for verifying Gitea webhook payloads.
|
||||||
|
- id, userId, token (random secret), createdAt
|
||||||
|
|
||||||
|
### NoConfigComment
|
||||||
|
Tracks which PRs have already received a "no previews configured" comment to avoid repeat spam.
|
||||||
|
- id, userId, repoOwner, repoName, prNumber, createdAt
|
||||||
|
|
||||||
|
### AdminSettings
|
||||||
|
Single row global config (seeded on first run).
|
||||||
|
- id
|
||||||
|
- defaultInstanceType (string, default `t2.medium`)
|
||||||
|
- maxConcurrentInstancesPerUser (int, default 5)
|
||||||
|
- logSizeLimitBytes (int, default 1048576 — 1MB)
|
||||||
|
- previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged)
|
||||||
|
- webhookRateLimitPerMinute (int, default 10 — per user)
|
||||||
|
- contactEmail (string — shown in privacy policy footer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- Session-based auth (reuse shsf middleware).
|
||||||
|
- Registration endpoint is disabled — only admins can create users via the admin panel.
|
||||||
|
- First registered user automatically becomes admin and is flagged as `isFounder = true`. The founder cannot be demoted or deleted.
|
||||||
|
|
||||||
|
### Webhook Endpoint
|
||||||
|
`POST /webhook/:userId`
|
||||||
|
|
||||||
|
**Rate limiting:** Each user is limited to `AdminSettings.webhookRateLimitPerMinute` webhook events per minute. Excess requests receive `429 Too Many Requests` and are dropped (not queued).
|
||||||
|
|
||||||
|
**Signature verification:** Gitea sends `X-Gitea-Signature-256: sha256=<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).
|
||||||
@@ -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
|
||||||
Vendored
+119
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+36
@@ -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
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+33
@@ -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
|
||||||
Vendored
+7
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+50
@@ -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
|
||||||
Vendored
+7
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+58
@@ -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
|
||||||
Vendored
+7
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
+37
@@ -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
|
||||||
Vendored
+7
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+49
@@ -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
|
||||||
Vendored
+7
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
+88
@@ -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
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+40
@@ -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
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+38
@@ -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
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+64
@@ -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
|
||||||
Vendored
+7
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+192
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+153
@@ -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
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+180
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+204
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+161
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+279
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+440
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+249
@@ -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
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+170
@@ -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
|
||||||
Vendored
+7
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
+115
@@ -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
|
||||||
Vendored
+7
@@ -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": []
|
||||||
|
}
|
||||||
Vendored
+100
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
+114
@@ -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
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2253
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient({
|
||||||
|
log: ["error", "warn"],
|
||||||
|
errorFormat: "pretty",
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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" } });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 } } });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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})_`;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1790
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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": "*"
|
||||||
|
}]
|
||||||
|
}`;
|
||||||
@@ -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";
|
||||||
@@ -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
Reference in New Issue
Block a user