v1
A.gitignore
| @@ -0,0 +1,39 @@ | |||
|---|---|---|---|
| 1 | + | # dependencies (bun install) | |
| 2 | + | node_modules | |
| 3 | + | ||
| 4 | + | # output | |
| 5 | + | out | |
| 6 | + | dist | |
| 7 | + | *.tgz | |
| 8 | + | ||
| 9 | + | # code coverage | |
| 10 | + | coverage | |
| 11 | + | *.lcov | |
| 12 | + | ||
| 13 | + | # logs | |
| 14 | + | logs | |
| 15 | + | _.log | |
| 16 | + | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json | |
| 17 | + | ||
| 18 | + | # caches | |
| 19 | + | .eslintcache | |
| 20 | + | .cache | |
| 21 | + | *.tsbuildinfo | |
| 22 | + | ||
| 23 | + | # IntelliJ based IDEs | |
| 24 | + | .idea | |
| 25 | + | ||
| 26 | + | # Finder (MacOS) folder config | |
| 27 | + | .DS_Store | |
| 28 | + | ||
| 29 | + | memory/ | |
| 30 | + | public/assets/simplewebauthn-browser.js | |
| 31 | + | public/assets/jxl-polyfill.js | |
| 32 | + | public/assets/*.wasm | |
| 33 | + | ||
| 34 | + | # Hearthforge data | |
| 35 | + | data/ | |
| 36 | + | .env | |
| 37 | + | ||
| 38 | + | # Test data | |
| 39 | + | data-test/ | |
AContainerfile
| @@ -0,0 +1,25 @@ | |||
|---|---|---|---|
| 1 | + | FROM oven/bun:1 AS base | |
| 2 | + | WORKDIR /app | |
| 3 | + | ||
| 4 | + | RUN apt-get --update install -y --no-install-recommends \ | |
| 5 | + | tini openssh-client git-core zstd \ | |
| 6 | + | && rm -rf /var/lib/apt/lists/* | |
| 7 | + | ||
| 8 | + | # Copy public first so postinstall can write vendor files into it | |
| 9 | + | COPY public ./public | |
| 10 | + | ||
| 11 | + | # Install dependencies (postinstall vendors simplewebauthn + jxl-polyfill into public/) | |
| 12 | + | COPY package.json bun.lock* ./ | |
| 13 | + | RUN bun install --frozen-lockfile --production | |
| 14 | + | ||
| 15 | + | # Copy source | |
| 16 | + | COPY src ./src | |
| 17 | + | COPY tsconfig.json ./ | |
| 18 | + | ||
| 19 | + | EXPOSE 3000 | |
| 20 | + | ||
| 21 | + | ENV DATA_DIR=/data | |
| 22 | + | VOLUME ["/data"] | |
| 23 | + | ||
| 24 | + | ENTRYPOINT ["/usr/bin/tini", "--"] | |
| 25 | + | CMD ["sh", "-c", "bun run db:init && bun run start"] | |
AREADME.md
| @@ -0,0 +1,93 @@ | |||
|---|---|---|---|
| 1 | + | # Hearthforge  | |
| 2 | + |  | |
| 3 | + | ||
| 4 | + | A self-hosted git forge designed to host your own repositories, but allow others to interact with them via issues and patch proposals. | |
| 5 | + | Frontend works without any JS at all enabled, just required for WebAuthn (with graceful fallback to password-only). | |
| 6 | + | ||
| 7 | + | ## Features | |
| 8 | + | ||
| 9 | + | - **Repository browser** — file tree, blob view, commit log, README rendering | |
| 10 | + | - **Issues** — create, comment, react | |
| 11 | + | - **Patches** — submit diffs for review & comments. Admin can merge applicable patches directly into the repository. | |
| 12 | + | - **Releases** — tag-based releases with source archives and extra uploaded assets | |
| 13 | + | - **SSH push/pull** — built-in SSH server, no external git daemon needed | |
| 14 | + | - **Auth** — password login or passkeys (WebAuthn/FIDO2) | |
| 15 | + | - **Optional registration** — others can create accounts to file issues and patches; can be disabled | |
| 16 | + | ||
| 17 | + | ## Stack | |
| 18 | + | ||
| 19 | + | - [Bun](https://bun.sh) — runtime and package manager | |
| 20 | + | - [ElysiaJS](https://elysiajs.com) — HTTP framework | |
| 21 | + | - SQLite — single-file database via Kysely | |
| 22 | + | - Server-side JSX via @kitajs/html (no client-side framework) | |
| 23 | + | - Shiki — syntax highlighting | |
| 24 | + | ||
| 25 | + | ## Running | |
| 26 | + | ||
| 27 | + | ```bash | |
| 28 | + | bun install | |
| 29 | + | bun run db:init # creates database and admin account | |
| 30 | + | bun run start # http://localhost:3000, SSH on port 2222 | |
| 31 | + | ``` | |
| 32 | + | ||
| 33 | + | Default admin credentials: `admin` / `changeme` | |
| 34 | + | ||
| 35 | + | ## Manual repository creation | |
| 36 | + | ||
| 37 | + | Repositories can bei either created through the UI, or existing ones can be copied manually to the `data/repos` directory. | |
| 38 | + | Non-bare repos are automatically converted to bare repos on startup, discarding uncomitted changes and worktrees. | |
| 39 | + | ||
| 40 | + | ### Docker / Podman | |
| 41 | + | Container images are provided. | |
| 42 | + | ```bash | |
| 43 | + | docker compose up # or | |
| 44 | + | podman compose up | |
| 45 | + | ``` | |
| 46 | + | ||
| 47 | + | ### Configuration | |
| 48 | + | ||
| 49 | + | All settings are environment variables: | |
| 50 | + | ||
| 51 | + | | Variable | Default | Description | | |
| 52 | + | |-------------------------|-------------------------|-------------------------------------------------| | |
| 53 | + | | `PORT` | `3000` | HTTP port | | |
| 54 | + | | `SSH_PORT` | `2222` | SSH port | | |
| 55 | + | | `DATA_DIR` | `./data` | Repos, database, uploads | | |
| 56 | + | | `ADMIN_PASSWORD` | `changeme` | Initial admin password | | |
| 57 | + | | `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner | | |
| 58 | + | | `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links | | |
| 59 | + | | `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups | | |
| 60 | + | | `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) | | |
| 61 | + | | `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) | | |
| 62 | + | | `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view | | |
| 63 | + | | `SSH_DISABLED` | `0` | Disable the embedded SSH-server | | |
| 64 | + | | `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` | | |
| 65 | + | | `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting | | |
| 66 | + | | `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* | | |
| 67 | + | \* More workers mean more CPU cores can be used to parallelize highlighting of files. | |
| 68 | + | Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB. | |
| 69 | + | So be careful when increasing this. | |
| 70 | + | ### SSH access | |
| 71 | + | ||
| 72 | + | Add your public key under Settings → SSH keys, then clone with: | |
| 73 | + | ||
| 74 | + | ``` | |
| 75 | + | git clone ssh://git@localhost:{SSH_PORT}/{REPO_NAME} | |
| 76 | + | ``` | |
| 77 | + | ||
| 78 | + | Pushing is also supported for the admin. | |
| 79 | + | ||
| 80 | + | ## Development | |
| 81 | + | ||
| 82 | + | ```bash | |
| 83 | + | bun run dev # watch mode | |
| 84 | + | bun run lint # Biome lint | |
| 85 | + | bun run format # Biome format | |
| 86 | + | bun run test # Playwright E2E tests (don't use bun test, it doesn't respect the timeout) | |
| 87 | + | ``` | |
| 88 | + | ||
| 89 | + | ## Roadmap | |
| 90 | + | - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation | |
| 91 | + | - Issue labels | |
| 92 | + | - Repository list reordering (e.g. last committed) and starring | |
| 93 | + | - redirect image urls in readme | |
Abiome.json
| @@ -0,0 +1,40 @@ | |||
|---|---|---|---|
| 1 | + | { | |
| 2 | + | "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", | |
| 3 | + | "vcs": { | |
| 4 | + | "enabled": true, | |
| 5 | + | "clientKind": "git", | |
| 6 | + | "useIgnoreFile": true | |
| 7 | + | }, | |
| 8 | + | "files": { | |
| 9 | + | "ignoreUnknown": true, | |
| 10 | + | "includes": ["src/**"] | |
| 11 | + | }, | |
| 12 | + | "formatter": { | |
| 13 | + | "enabled": true, | |
| 14 | + | "indentStyle": "space", | |
| 15 | + | "indentWidth": 4 | |
| 16 | + | }, | |
| 17 | + | "linter": { | |
| 18 | + | "enabled": true, | |
| 19 | + | "rules": { | |
| 20 | + | "recommended": true, | |
| 21 | + | "style": { | |
| 22 | + | "noNonNullAssertion": "off" | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | }, | |
| 26 | + | "javascript": { | |
| 27 | + | "formatter": { | |
| 28 | + | "quoteStyle": "double", | |
| 29 | + | "trailingCommas": "all" | |
| 30 | + | } | |
| 31 | + | }, | |
| 32 | + | "assist": { | |
| 33 | + | "enabled": true, | |
| 34 | + | "actions": { | |
| 35 | + | "source": { | |
| 36 | + | "organizeImports": "on" | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
Abun.lock
| @@ -0,0 +1,461 @@ | |||
|---|---|---|---|
| 1 | + | { | |
| 2 | + | "lockfileVersion": 1, | |
| 3 | + | "configVersion": 1, | |
| 4 | + | "workspaces": { | |
| 5 | + | "": { | |
| 6 | + | "name": "hearthforge", | |
| 7 | + | "dependencies": { | |
| 8 | + | "@elysiajs/static": "^1.4.7", | |
| 9 | + | "@jsquash/jxl": "^1.3.0", | |
| 10 | + | "@kitajs/html": "^4.2.13", | |
| 11 | + | "@simplewebauthn/browser": "^13.3.0", | |
| 12 | + | "@simplewebauthn/server": "^13.3.0", | |
| 13 | + | "argon2": "^0.44.0", | |
| 14 | + | "elysia": "^1.4.27", | |
| 15 | + | "file-type": "^21.3.2", | |
| 16 | + | "isomorphic-dompurify": "^3.3.0", | |
| 17 | + | "jxl-rs-polyfill": "^0.1.1", | |
| 18 | + | "kysely": "^0.28.12", | |
| 19 | + | "kysely-bun-sqlite": "^0.4.0", | |
| 20 | + | "linguist-languages": "^9.3.1", | |
| 21 | + | "marked": "^17.0.4", | |
| 22 | + | "sharp": "^0.34.5", | |
| 23 | + | "shiki": "^4.0.2", | |
| 24 | + | "ssh2": "^1.17.0", | |
| 25 | + | }, | |
| 26 | + | "devDependencies": { | |
| 27 | + | "@biomejs/biome": "^2.4.7", | |
| 28 | + | "@types/argon2": "^0.15.4", | |
| 29 | + | "@types/bun": "latest", | |
| 30 | + | "@types/ssh2": "^1.15.5", | |
| 31 | + | "playwright": "^1.58.2", | |
| 32 | + | }, | |
| 33 | + | "peerDependencies": { | |
| 34 | + | "typescript": "^5", | |
| 35 | + | }, | |
| 36 | + | }, | |
| 37 | + | }, | |
| 38 | + | "packages": { | |
| 39 | + | "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], | |
| 40 | + | ||
| 41 | + | "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="], | |
| 42 | + | ||
| 43 | + | "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], | |
| 44 | + | ||
| 45 | + | "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], | |
| 46 | + | ||
| 47 | + | "@biomejs/biome": ["@biomejs/biome@2.4.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.7", "@biomejs/cli-darwin-x64": "2.4.7", "@biomejs/cli-linux-arm64": "2.4.7", "@biomejs/cli-linux-arm64-musl": "2.4.7", "@biomejs/cli-linux-x64": "2.4.7", "@biomejs/cli-linux-x64-musl": "2.4.7", "@biomejs/cli-win32-arm64": "2.4.7", "@biomejs/cli-win32-x64": "2.4.7" }, "bin": { "biome": "bin/biome" } }, "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng=="], | |
| 48 | + | ||
| 49 | + | "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA=="], | |
| 50 | + | ||
| 51 | + | "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ=="], | |
| 52 | + | ||
| 53 | + | "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w=="], | |
| 54 | + | ||
| 55 | + | "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw=="], | |
| 56 | + | ||
| 57 | + | "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ=="], | |
| 58 | + | ||
| 59 | + | "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g=="], | |
| 60 | + | ||
| 61 | + | "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw=="], | |
| 62 | + | ||
| 63 | + | "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ=="], | |
| 64 | + | ||
| 65 | + | "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], | |
| 66 | + | ||
| 67 | + | "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], | |
| 68 | + | ||
| 69 | + | "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], | |
| 70 | + | ||
| 71 | + | "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="], | |
| 72 | + | ||
| 73 | + | "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="], | |
| 74 | + | ||
| 75 | + | "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], | |
| 76 | + | ||
| 77 | + | "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.1", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w=="], | |
| 78 | + | ||
| 79 | + | "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], | |
| 80 | + | ||
| 81 | + | "@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="], | |
| 82 | + | ||
| 83 | + | "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], | |
| 84 | + | ||
| 85 | + | "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], | |
| 86 | + | ||
| 87 | + | "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], | |
| 88 | + | ||
| 89 | + | "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], | |
| 90 | + | ||
| 91 | + | "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], | |
| 92 | + | ||
| 93 | + | "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], | |
| 94 | + | ||
| 95 | + | "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], | |
| 96 | + | ||
| 97 | + | "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], | |
| 98 | + | ||
| 99 | + | "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], | |
| 100 | + | ||
| 101 | + | "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], | |
| 102 | + | ||
| 103 | + | "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], | |
| 104 | + | ||
| 105 | + | "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], | |
| 106 | + | ||
| 107 | + | "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], | |
| 108 | + | ||
| 109 | + | "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], | |
| 110 | + | ||
| 111 | + | "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], | |
| 112 | + | ||
| 113 | + | "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], | |
| 114 | + | ||
| 115 | + | "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], | |
| 116 | + | ||
| 117 | + | "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], | |
| 118 | + | ||
| 119 | + | "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], | |
| 120 | + | ||
| 121 | + | "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], | |
| 122 | + | ||
| 123 | + | "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], | |
| 124 | + | ||
| 125 | + | "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], | |
| 126 | + | ||
| 127 | + | "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], | |
| 128 | + | ||
| 129 | + | "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], | |
| 130 | + | ||
| 131 | + | "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], | |
| 132 | + | ||
| 133 | + | "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], | |
| 134 | + | ||
| 135 | + | "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], | |
| 136 | + | ||
| 137 | + | "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], | |
| 138 | + | ||
| 139 | + | "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], | |
| 140 | + | ||
| 141 | + | "@jsquash/jxl": ["@jsquash/jxl@1.3.0", "", { "dependencies": { "wasm-feature-detect": "^1.5.1" } }, "sha512-IVOPTneyOd9eBAuow+FnCYIP6wkIc7CjrCDnZGiqAPyJ63vMVxv3zoWiucGiZVh6u9vi/l40AkcS9QwkoVSAqA=="], | |
| 142 | + | ||
| 143 | + | "@kitajs/html": ["@kitajs/html@4.2.13", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-o+8e61EsoLDPTP7rsPkYolca1YFybHuxU2Lr5fWDZCUkYT/6uBlVkvnZUdCXMQKentJL9dxwpR8/xK2Q+U4LhA=="], | |
| 144 | + | ||
| 145 | + | "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], | |
| 146 | + | ||
| 147 | + | "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="], | |
| 148 | + | ||
| 149 | + | "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="], | |
| 150 | + | ||
| 151 | + | "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="], | |
| 152 | + | ||
| 153 | + | "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="], | |
| 154 | + | ||
| 155 | + | "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="], | |
| 156 | + | ||
| 157 | + | "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="], | |
| 158 | + | ||
| 159 | + | "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="], | |
| 160 | + | ||
| 161 | + | "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="], | |
| 162 | + | ||
| 163 | + | "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="], | |
| 164 | + | ||
| 165 | + | "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="], | |
| 166 | + | ||
| 167 | + | "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="], | |
| 168 | + | ||
| 169 | + | "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], | |
| 170 | + | ||
| 171 | + | "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], | |
| 172 | + | ||
| 173 | + | "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], | |
| 174 | + | ||
| 175 | + | "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], | |
| 176 | + | ||
| 177 | + | "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], | |
| 178 | + | ||
| 179 | + | "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], | |
| 180 | + | ||
| 181 | + | "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], | |
| 182 | + | ||
| 183 | + | "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], | |
| 184 | + | ||
| 185 | + | "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | |
| 186 | + | ||
| 187 | + | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], | |
| 188 | + | ||
| 189 | + | "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], | |
| 190 | + | ||
| 191 | + | "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="], | |
| 192 | + | ||
| 193 | + | "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], | |
| 194 | + | ||
| 195 | + | "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], | |
| 196 | + | ||
| 197 | + | "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], | |
| 198 | + | ||
| 199 | + | "@types/argon2": ["@types/argon2@0.15.4", "", { "dependencies": { "argon2": "*" } }, "sha512-fZPJNvZTvoyt/okKhbyj99UsDOwqzEXYPvKqfK26mViH9JB/hOwzUwDUn5qbwq9XJkLw0kDcFNu6+bnb3u5a0A=="], | |
| 200 | + | ||
| 201 | + | "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], | |
| 202 | + | ||
| 203 | + | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], | |
| 204 | + | ||
| 205 | + | "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], | |
| 206 | + | ||
| 207 | + | "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], | |
| 208 | + | ||
| 209 | + | "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], | |
| 210 | + | ||
| 211 | + | "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], | |
| 212 | + | ||
| 213 | + | "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], | |
| 214 | + | ||
| 215 | + | "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], | |
| 216 | + | ||
| 217 | + | "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], | |
| 218 | + | ||
| 219 | + | "argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="], | |
| 220 | + | ||
| 221 | + | "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], | |
| 222 | + | ||
| 223 | + | "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], | |
| 224 | + | ||
| 225 | + | "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], | |
| 226 | + | ||
| 227 | + | "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], | |
| 228 | + | ||
| 229 | + | "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], | |
| 230 | + | ||
| 231 | + | "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], | |
| 232 | + | ||
| 233 | + | "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], | |
| 234 | + | ||
| 235 | + | "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], | |
| 236 | + | ||
| 237 | + | "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], | |
| 238 | + | ||
| 239 | + | "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], | |
| 240 | + | ||
| 241 | + | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], | |
| 242 | + | ||
| 243 | + | "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], | |
| 244 | + | ||
| 245 | + | "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="], | |
| 246 | + | ||
| 247 | + | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], | |
| 248 | + | ||
| 249 | + | "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], | |
| 250 | + | ||
| 251 | + | "cssstyle": ["cssstyle@6.2.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.28", "css-tree": "^3.1.0", "lru-cache": "^11.2.6" } }, "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig=="], | |
| 252 | + | ||
| 253 | + | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], | |
| 254 | + | ||
| 255 | + | "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], | |
| 256 | + | ||
| 257 | + | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], | |
| 258 | + | ||
| 259 | + | "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], | |
| 260 | + | ||
| 261 | + | "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], | |
| 262 | + | ||
| 263 | + | "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], | |
| 264 | + | ||
| 265 | + | "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], | |
| 266 | + | ||
| 267 | + | "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], | |
| 268 | + | ||
| 269 | + | "elysia": ["elysia@1.4.27", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="], | |
| 270 | + | ||
| 271 | + | "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], | |
| 272 | + | ||
| 273 | + | "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], | |
| 274 | + | ||
| 275 | + | "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], | |
| 276 | + | ||
| 277 | + | "file-type": ["file-type@21.3.2", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w=="], | |
| 278 | + | ||
| 279 | + | "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], | |
| 280 | + | ||
| 281 | + | "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], | |
| 282 | + | ||
| 283 | + | "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], | |
| 284 | + | ||
| 285 | + | "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], | |
| 286 | + | ||
| 287 | + | "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], | |
| 288 | + | ||
| 289 | + | "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], | |
| 290 | + | ||
| 291 | + | "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], | |
| 292 | + | ||
| 293 | + | "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], | |
| 294 | + | ||
| 295 | + | "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], | |
| 296 | + | ||
| 297 | + | "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], | |
| 298 | + | ||
| 299 | + | "isomorphic-dompurify": ["isomorphic-dompurify@3.3.0", "", { "dependencies": { "dompurify": "^3.3.3", "jsdom": "^28.1.0" } }, "sha512-Xx3GlNZNAXIISX49OMpfNgxrRU49KVQ5hqnK2zbkqkPucdWnCZXquTMK/COnb8pmIOJlfiaNYav9qyHX3gRDMA=="], | |
| 300 | + | ||
| 301 | + | "jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="], | |
| 302 | + | ||
| 303 | + | "jxl-rs-polyfill": ["jxl-rs-polyfill@0.1.1", "", {}, "sha512-BFrrvbnInmDYqEcZZZ2nYJq248VMq6BBBcnc7uhAndz1uhIEclW6uMPLQjqTrEVcDg1JeRhq796kuSc+C/JasQ=="], | |
| 304 | + | ||
| 305 | + | "kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="], | |
| 306 | + | ||
| 307 | + | "kysely-bun-sqlite": ["kysely-bun-sqlite@0.4.0", "", { "dependencies": { "bun-types": "^1.1.31" }, "peerDependencies": { "kysely": "^0.28.2" } }, "sha512-2EkQE5sT4ewiw7IWfJsAkpxJ/QPVKXKO5sRYI/xjjJIJlECuOdtG+ssYM0twZJySrdrmuildNPFYVreyu1EdZg=="], | |
| 308 | + | ||
| 309 | + | "linguist-languages": ["linguist-languages@9.3.1", "", {}, "sha512-Mum2sqg3MyhgKfpulFhKZMAK/1VnV6m9vCV8YQCSqWs+pbKouKn9EqRshZjVWUaJjl6NTTDcYJk/1+C02siXEQ=="], | |
| 310 | + | ||
| 311 | + | "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], | |
| 312 | + | ||
| 313 | + | "marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="], | |
| 314 | + | ||
| 315 | + | "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], | |
| 316 | + | ||
| 317 | + | "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], | |
| 318 | + | ||
| 319 | + | "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], | |
| 320 | + | ||
| 321 | + | "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], | |
| 322 | + | ||
| 323 | + | "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], | |
| 324 | + | ||
| 325 | + | "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], | |
| 326 | + | ||
| 327 | + | "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], | |
| 328 | + | ||
| 329 | + | "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], | |
| 330 | + | ||
| 331 | + | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], | |
| 332 | + | ||
| 333 | + | "nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], | |
| 334 | + | ||
| 335 | + | "node-addon-api": ["node-addon-api@8.6.0", "", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="], | |
| 336 | + | ||
| 337 | + | "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], | |
| 338 | + | ||
| 339 | + | "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], | |
| 340 | + | ||
| 341 | + | "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], | |
| 342 | + | ||
| 343 | + | "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], | |
| 344 | + | ||
| 345 | + | "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], | |
| 346 | + | ||
| 347 | + | "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], | |
| 348 | + | ||
| 349 | + | "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], | |
| 350 | + | ||
| 351 | + | "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], | |
| 352 | + | ||
| 353 | + | "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], | |
| 354 | + | ||
| 355 | + | "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], | |
| 356 | + | ||
| 357 | + | "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], | |
| 358 | + | ||
| 359 | + | "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], | |
| 360 | + | ||
| 361 | + | "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], | |
| 362 | + | ||
| 363 | + | "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], | |
| 364 | + | ||
| 365 | + | "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], | |
| 366 | + | ||
| 367 | + | "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], | |
| 368 | + | ||
| 369 | + | "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], | |
| 370 | + | ||
| 371 | + | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], | |
| 372 | + | ||
| 373 | + | "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], | |
| 374 | + | ||
| 375 | + | "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], | |
| 376 | + | ||
| 377 | + | "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], | |
| 378 | + | ||
| 379 | + | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], | |
| 380 | + | ||
| 381 | + | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], | |
| 382 | + | ||
| 383 | + | "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], | |
| 384 | + | ||
| 385 | + | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], | |
| 386 | + | ||
| 387 | + | "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], | |
| 388 | + | ||
| 389 | + | "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], | |
| 390 | + | ||
| 391 | + | "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], | |
| 392 | + | ||
| 393 | + | "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], | |
| 394 | + | ||
| 395 | + | "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], | |
| 396 | + | ||
| 397 | + | "tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="], | |
| 398 | + | ||
| 399 | + | "tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="], | |
| 400 | + | ||
| 401 | + | "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], | |
| 402 | + | ||
| 403 | + | "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], | |
| 404 | + | ||
| 405 | + | "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], | |
| 406 | + | ||
| 407 | + | "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], | |
| 408 | + | ||
| 409 | + | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], | |
| 410 | + | ||
| 411 | + | "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], | |
| 412 | + | ||
| 413 | + | "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], | |
| 414 | + | ||
| 415 | + | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], | |
| 416 | + | ||
| 417 | + | "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], | |
| 418 | + | ||
| 419 | + | "undici": ["undici@7.24.3", "", {}, "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA=="], | |
| 420 | + | ||
| 421 | + | "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], | |
| 422 | + | ||
| 423 | + | "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], | |
| 424 | + | ||
| 425 | + | "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], | |
| 426 | + | ||
| 427 | + | "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], | |
| 428 | + | ||
| 429 | + | "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], | |
| 430 | + | ||
| 431 | + | "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], | |
| 432 | + | ||
| 433 | + | "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], | |
| 434 | + | ||
| 435 | + | "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], | |
| 436 | + | ||
| 437 | + | "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], | |
| 438 | + | ||
| 439 | + | "wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="], | |
| 440 | + | ||
| 441 | + | "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], | |
| 442 | + | ||
| 443 | + | "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], | |
| 444 | + | ||
| 445 | + | "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], | |
| 446 | + | ||
| 447 | + | "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], | |
| 448 | + | ||
| 449 | + | "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], | |
| 450 | + | ||
| 451 | + | "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], | |
| 452 | + | ||
| 453 | + | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], | |
| 454 | + | ||
| 455 | + | "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], | |
| 456 | + | ||
| 457 | + | "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], | |
| 458 | + | ||
| 459 | + | "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], | |
| 460 | + | } | |
| 461 | + | } | |
Acompose.yml
| @@ -0,0 +1,20 @@ | |||
|---|---|---|---|
| 1 | + | services: | |
| 2 | + | hearthforge: | |
| 3 | + | build: | |
| 4 | + | context: . | |
| 5 | + | ports: | |
| 6 | + | - "3000:3000" | |
| 7 | + | - "2222:2222" | |
| 8 | + | volumes: | |
| 9 | + | - ./data:/data | |
| 10 | + | environment: | |
| 11 | + | DATA_DIR: /data | |
| 12 | + | PORT: 3000 | |
| 13 | + | SSH_PORT: 2222 | |
| 14 | + | OWNER_DISPLAY_NAME: Admin | |
| 15 | + | BASE_URL: "http://localhost:3000" | |
| 16 | + | # ADMIN_PASSWORD: change-me | |
| 17 | + | # SSH_DISABLED: 0 | |
| 18 | + | # ARCHIVE_ZST_ENABLED: 0 | |
| 19 | + | # TRUSTED_PROXY: 1 | |
| 20 | + | # REGISTRATION_DISABLED: 0 | |
Apackage.json
| @@ -0,0 +1,47 @@ | |||
|---|---|---|---|
| 1 | + | { | |
| 2 | + | "name": "hearthforge", | |
| 3 | + | "module": "src/index.tsx", | |
| 4 | + | "scripts": { | |
| 5 | + | "dev": "bun run --watch src/index.tsx", | |
| 6 | + | "start": "bun run src/index.tsx", | |
| 7 | + | "db:init": "bun run src/db/init.ts", | |
| 8 | + | "db:seed": "bun run src/db/seed.ts", | |
| 9 | + | "test": "bun test tests/e2e.test.ts --timeout 60000", | |
| 10 | + | "vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm", | |
| 11 | + | "vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js", | |
| 12 | + | "postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill", | |
| 13 | + | "lint": "biome check src/", | |
| 14 | + | "format": "biome check --write src/" | |
| 15 | + | }, | |
| 16 | + | "type": "module", | |
| 17 | + | "private": true, | |
| 18 | + | "devDependencies": { | |
| 19 | + | "@biomejs/biome": "^2.4.7", | |
| 20 | + | "@types/argon2": "^0.15.4", | |
| 21 | + | "@types/bun": "latest", | |
| 22 | + | "@types/ssh2": "^1.15.5", | |
| 23 | + | "playwright": "^1.58.2" | |
| 24 | + | }, | |
| 25 | + | "peerDependencies": { | |
| 26 | + | "typescript": "^5" | |
| 27 | + | }, | |
| 28 | + | "dependencies": { | |
| 29 | + | "@elysiajs/static": "^1.4.7", | |
| 30 | + | "@jsquash/jxl": "^1.3.0", | |
| 31 | + | "@kitajs/html": "^4.2.13", | |
| 32 | + | "@simplewebauthn/browser": "^13.3.0", | |
| 33 | + | "@simplewebauthn/server": "^13.3.0", | |
| 34 | + | "argon2": "^0.44.0", | |
| 35 | + | "elysia": "^1.4.27", | |
| 36 | + | "file-type": "^21.3.2", | |
| 37 | + | "isomorphic-dompurify": "^3.3.0", | |
| 38 | + | "jxl-rs-polyfill": "^0.1.1", | |
| 39 | + | "kysely": "^0.28.12", | |
| 40 | + | "kysely-bun-sqlite": "^0.4.0", | |
| 41 | + | "linguist-languages": "^9.3.1", | |
| 42 | + | "marked": "^17.0.4", | |
| 43 | + | "sharp": "^0.34.5", | |
| 44 | + | "shiki": "^4.0.2", | |
| 45 | + | "ssh2": "^1.17.0" | |
| 46 | + | } | |
| 47 | + | } | |
Apublic/assets/favicon.svg
| @@ -0,0 +1,18 @@ | |||
|---|---|---|---|
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 32 32"> | |
| 2 | + | <!-- Hearth stone base --> | |
| 3 | + | <rect x="3" y="23" width="26" height="7" rx="2" fill="#6b2e0c"/> | |
| 4 | + | <!-- Stone highlight band --> | |
| 5 | + | <rect x="5" y="24" width="22" height="2" rx="1" fill="#8b4220"/> | |
| 6 | + | ||
| 7 | + | <!-- Outer flame (deep red-orange) --> | |
| 8 | + | <path d="M16 3 C21 7 26.5 12 25 19 C24 23 21 26 16 26 C11 26 8 23 7 19 C5.5 12 11 7 16 3Z" fill="#b83c14"/> | |
| 9 | + | ||
| 10 | + | <!-- Mid flame (bright orange) --> | |
| 11 | + | <path d="M16 8 C19.5 11 22.5 15.5 21.5 20 C21 22.5 19 26 16 26 C13 26 11 22.5 10.5 20 C9.5 15.5 12.5 11 16 8Z" fill="#e06018"/> | |
| 12 | + | ||
| 13 | + | <!-- Inner glow (amber) --> | |
| 14 | + | <path d="M16 13 C18.2 16 19.5 19 18.8 21.5 C18.4 23 17.2 25 16 25 C14.8 25 13.6 23 13.2 21.5 C12.5 19 13.8 16 16 13Z" fill="#f5a020"/> | |
| 15 | + | ||
| 16 | + | <!-- Hot core (golden yellow) --> | |
| 17 | + | <path d="M16 17.5 C17 19.5 17.5 21.5 17 23 C16.8 23.8 16 24.8 16 24.8 C16 24.8 15.2 23.8 15 23 C14.5 21.5 15 19.5 16 17.5Z" fill="#ffe060"/> | |
| 18 | + | </svg> | |
Apublic/assets/main.css
| @@ -0,0 +1,1807 @@ | |||
|---|---|---|---|
| 1 | + | /* View Transitions */ | |
| 2 | + | @view-transition { | |
| 3 | + | navigation: auto; | |
| 4 | + | } | |
| 5 | + | ||
| 6 | + | .site-nav { view-transition-name: site-nav; } | |
| 7 | + | .repo-header { view-transition-name: repo-header; } | |
| 8 | + | .breadcrumb { view-transition-name: breadcrumb; } | |
| 9 | + | ||
| 10 | + | /* ============================================================ | |
| 11 | + | @layer reset | |
| 12 | + | ============================================================ */ | |
| 13 | + | @layer reset { | |
| 14 | + | *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| 15 | + | html { -webkit-text-size-adjust: 100%; } | |
| 16 | + | body { min-height: 100dvh; min-width: 500px; } | |
| 17 | + | img, video { max-width: 100%; height: auto; } | |
| 18 | + | button, input, textarea, select { font: inherit; } | |
| 19 | + | a { color: inherit; } | |
| 20 | + | ul, ol { list-style: none; } | |
| 21 | + | h1, h2, h3, h4, h5, h6 { font-weight: 600; line-height: 1.2; } | |
| 22 | + | pre, code { font-family: var(--font-mono); } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /* ============================================================ | |
| 26 | + | @layer tokens | |
| 27 | + | ============================================================ */ | |
| 28 | + | @layer tokens { | |
| 29 | + | :root { | |
| 30 | + | /* Colors — light */ | |
| 31 | + | --color-bg: #ffffff; | |
| 32 | + | --color-bg-subtle: #f6f8fa; | |
| 33 | + | --color-bg-inset: #eef0f2; | |
| 34 | + | --color-border: #d0d7de; | |
| 35 | + | --color-border-muted: #e4e7eb; | |
| 36 | + | --color-text: #1f2328; | |
| 37 | + | --color-text-muted: #656d76; | |
| 38 | + | --color-link: #0969da; | |
| 39 | + | --color-link-hover: #0550ae; | |
| 40 | + | ||
| 41 | + | --color-accent: #fd7e14; | |
| 42 | + | --color-accent-bg: #fff3cd; | |
| 43 | + | ||
| 44 | + | --color-success: #1a7f37; | |
| 45 | + | --color-success-bg: #dafbe1; | |
| 46 | + | --color-danger: #cf222e; | |
| 47 | + | --color-danger-bg: #ffebe9; | |
| 48 | + | --color-warning: #9a6700; | |
| 49 | + | --color-warning-bg: #fff8c5; | |
| 50 | + | --color-merged: #8250df; | |
| 51 | + | --color-merged-bg: #fbefff; | |
| 52 | + | ||
| 53 | + | /* Typography */ | |
| 54 | + | --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| 55 | + | --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; | |
| 56 | + | ||
| 57 | + | --text-xs: 0.75rem; | |
| 58 | + | --text-sm: 0.875rem; | |
| 59 | + | --text-base: 1rem; | |
| 60 | + | --text-lg: 1.125rem; | |
| 61 | + | --text-xl: 1.25rem; | |
| 62 | + | --text-2xl: 1.5rem; | |
| 63 | + | --text-3xl: 1.875rem; | |
| 64 | + | ||
| 65 | + | /* Spacing */ | |
| 66 | + | --space-1: 0.25rem; | |
| 67 | + | --space-2: 0.5rem; | |
| 68 | + | --space-3: 0.75rem; | |
| 69 | + | --space-4: 1rem; | |
| 70 | + | --space-5: 1.25rem; | |
| 71 | + | --space-6: 1.5rem; | |
| 72 | + | --space-8: 2rem; | |
| 73 | + | --space-10: 2.5rem; | |
| 74 | + | --space-12: 3rem; | |
| 75 | + | --space-16: 4rem; | |
| 76 | + | ||
| 77 | + | /* Border radius */ | |
| 78 | + | --radius-sm: 4px; | |
| 79 | + | --radius-md: 6px; | |
| 80 | + | --radius-lg: 8px; | |
| 81 | + | --radius-full: 9999px; | |
| 82 | + | ||
| 83 | + | /* Shadows */ | |
| 84 | + | --shadow-sm: 0 1px 0 rgba(27,31,36,0.04); | |
| 85 | + | --shadow-md: 0 3px 6px rgba(140,149,159,0.15); | |
| 86 | + | --shadow-lg: 0 8px 24px rgba(140,149,159,0.2); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | @media (prefers-color-scheme: dark) { | |
| 90 | + | :root { | |
| 91 | + | --color-bg: #0d1117; | |
| 92 | + | --color-bg-subtle: #161b22; | |
| 93 | + | --color-bg-inset: #1c2128; | |
| 94 | + | --color-border: #30363d; | |
| 95 | + | --color-border-muted: #21262d; | |
| 96 | + | --color-text: #e6edf3; | |
| 97 | + | --color-text-muted: #7d8590; | |
| 98 | + | --color-link: #58a6ff; | |
| 99 | + | --color-link-hover: #79c0ff; | |
| 100 | + | ||
| 101 | + | --color-accent: #f0883e; | |
| 102 | + | --color-accent-bg: #3d2f00; | |
| 103 | + | ||
| 104 | + | --color-success: #3fb950; | |
| 105 | + | --color-success-bg: #0d2b19; | |
| 106 | + | --color-danger: #f85149; | |
| 107 | + | --color-danger-bg: #2d1419; | |
| 108 | + | --color-warning: #d29922; | |
| 109 | + | --color-warning-bg: #2c2000; | |
| 110 | + | --color-merged: #bc8cff; | |
| 111 | + | --color-merged-bg: #1e0b3e; | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /* Explicit theme overrides — applied via data-theme attribute set by cookie */ | |
| 116 | + | :root[data-theme="dark"] { | |
| 117 | + | --color-bg: #0d1117; | |
| 118 | + | --color-bg-subtle: #161b22; | |
| 119 | + | --color-bg-inset: #1c2128; | |
| 120 | + | --color-border: #30363d; | |
| 121 | + | --color-border-muted: #21262d; | |
| 122 | + | --color-text: #e6edf3; | |
| 123 | + | --color-text-muted: #7d8590; | |
| 124 | + | --color-link: #58a6ff; | |
| 125 | + | --color-link-hover: #79c0ff; | |
| 126 | + | --color-accent: #f0883e; | |
| 127 | + | --color-accent-bg: #3d2f00; | |
| 128 | + | --color-success: #3fb950; | |
| 129 | + | --color-success-bg: #0d2b19; | |
| 130 | + | --color-danger: #f85149; | |
| 131 | + | --color-danger-bg: #2d1419; | |
| 132 | + | --color-warning: #d29922; | |
| 133 | + | --color-warning-bg: #2c2000; | |
| 134 | + | --color-merged: #bc8cff; | |
| 135 | + | --color-merged-bg: #1e0b3e; | |
| 136 | + | } | |
| 137 | + | :root[data-theme="light"] { | |
| 138 | + | --color-bg: #ffffff; | |
| 139 | + | --color-bg-subtle: #f6f8fa; | |
| 140 | + | --color-bg-inset: #eef0f2; | |
| 141 | + | --color-border: #d0d7de; | |
| 142 | + | --color-border-muted: #e4e7eb; | |
| 143 | + | --color-text: #1f2328; | |
| 144 | + | --color-text-muted: #656d76; | |
| 145 | + | --color-link: #0969da; | |
| 146 | + | --color-link-hover: #0550ae; | |
| 147 | + | --color-accent: #fd7e14; | |
| 148 | + | --color-accent-bg: #fff3cd; | |
| 149 | + | --color-success: #1a7f37; | |
| 150 | + | --color-success-bg: #dafbe1; | |
| 151 | + | --color-danger: #cf222e; | |
| 152 | + | --color-danger-bg: #ffebe9; | |
| 153 | + | --color-warning: #9a6700; | |
| 154 | + | --color-warning-bg: #fff8c5; | |
| 155 | + | --color-merged: #8250df; | |
| 156 | + | --color-merged-bg: #fbefff; | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /* ============================================================ | |
| 161 | + | @layer layout | |
| 162 | + | ============================================================ */ | |
| 163 | + | @layer layout { | |
| 164 | + | body { | |
| 165 | + | font-family: var(--font-sans); | |
| 166 | + | font-size: var(--text-base); | |
| 167 | + | color: var(--color-text); | |
| 168 | + | background: var(--color-bg); | |
| 169 | + | display: grid; | |
| 170 | + | grid-template-rows: auto 1fr auto; | |
| 171 | + | min-height: 100dvh; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | .site-header { | |
| 175 | + | background: var(--color-bg-subtle); | |
| 176 | + | border-bottom: 1px solid var(--color-border); | |
| 177 | + | position: sticky; | |
| 178 | + | top: 0; | |
| 179 | + | z-index: 100; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | .site-nav { | |
| 183 | + | max-width: 1200px; | |
| 184 | + | margin: 0 auto; | |
| 185 | + | padding: var(--space-3) var(--space-4); | |
| 186 | + | display: flex; | |
| 187 | + | align-items: center; | |
| 188 | + | gap: var(--space-4); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | .site-logo { | |
| 192 | + | display: flex; | |
| 193 | + | align-items: center; | |
| 194 | + | gap: var(--space-2); | |
| 195 | + | text-decoration: none; | |
| 196 | + | font-weight: 700; | |
| 197 | + | font-size: var(--text-lg); | |
| 198 | + | color: var(--color-text); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | .logo-icon { width: 1.6em; height: 1.6em; display: block; flex-shrink: 0; } | |
| 202 | + | ||
| 203 | + | .nav-links { | |
| 204 | + | margin-left: auto; | |
| 205 | + | display: flex; | |
| 206 | + | align-items: center; | |
| 207 | + | gap: var(--space-3); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | .nav-user { | |
| 211 | + | font-size: var(--text-sm); | |
| 212 | + | color: var(--color-text-muted); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | .site-main { | |
| 216 | + | padding: var(--space-8) var(--space-4); | |
| 217 | + | min-width: 0; | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | .site-footer { | |
| 221 | + | text-align: center; | |
| 222 | + | padding: var(--space-6) var(--space-4); | |
| 223 | + | font-size: var(--text-sm); | |
| 224 | + | color: var(--color-text-muted); | |
| 225 | + | border-top: 1px solid var(--color-border); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | .container { | |
| 229 | + | max-width: 1000px; | |
| 230 | + | margin: 0 auto; | |
| 231 | + | overflow-x: clip; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | .container-narrow { | |
| 235 | + | max-width: 680px; | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | .inline-form { | |
| 239 | + | display: inline; | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /* --- Two-step delete confirmation --- */ | |
| 243 | + | .confirm-details { position: relative; display: inline-block; } | |
| 244 | + | .confirm-details > summary { list-style: none; } | |
| 245 | + | .confirm-details > summary::-webkit-details-marker { display: none; } | |
| 246 | + | .confirm-popup { | |
| 247 | + | position: absolute; | |
| 248 | + | right: 0; | |
| 249 | + | top: calc(100% + var(--space-1)); | |
| 250 | + | background: var(--color-bg); | |
| 251 | + | border: 1px solid var(--color-border); | |
| 252 | + | border-radius: var(--radius-md); | |
| 253 | + | padding: var(--space-3) var(--space-4); | |
| 254 | + | display: flex; | |
| 255 | + | align-items: center; | |
| 256 | + | gap: var(--space-3); | |
| 257 | + | white-space: nowrap; | |
| 258 | + | z-index: 100; | |
| 259 | + | font-size: var(--text-sm); | |
| 260 | + | color: var(--color-text-muted); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /* ============================================================ | |
| 265 | + | @layer components | |
| 266 | + | ============================================================ */ | |
| 267 | + | @layer components { | |
| 268 | + | ||
| 269 | + | /* --- Buttons --- */ | |
| 270 | + | .btn { | |
| 271 | + | display: inline-flex; | |
| 272 | + | align-items: center; | |
| 273 | + | gap: var(--space-2); | |
| 274 | + | padding: var(--space-2) var(--space-4); | |
| 275 | + | border: 1px solid var(--color-border); | |
| 276 | + | border-radius: var(--radius-md); | |
| 277 | + | font-size: var(--text-sm); | |
| 278 | + | font-weight: 500; | |
| 279 | + | text-decoration: none; | |
| 280 | + | cursor: pointer; | |
| 281 | + | background: var(--color-bg-subtle); | |
| 282 | + | color: var(--color-text); | |
| 283 | + | transition: background 0.1s, border-color 0.1s; | |
| 284 | + | white-space: nowrap; | |
| 285 | + | } | |
| 286 | + | .btn:hover { background: var(--color-bg-inset); border-color: var(--color-border); } | |
| 287 | + | .btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| 288 | + | .btn-primary { | |
| 289 | + | background: var(--color-accent); | |
| 290 | + | border-color: transparent; | |
| 291 | + | color: #fff; | |
| 292 | + | } | |
| 293 | + | .btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 82%, #000); } | |
| 294 | + | .btn-secondary { | |
| 295 | + | background: var(--color-bg-subtle); | |
| 296 | + | border-color: var(--color-border); | |
| 297 | + | } | |
| 298 | + | .btn-ghost { | |
| 299 | + | background: transparent; | |
| 300 | + | border-color: transparent; | |
| 301 | + | } | |
| 302 | + | .btn-ghost:hover { background: var(--color-bg-inset); } | |
| 303 | + | /* Keep ghost transparent even when combined with size variants */ | |
| 304 | + | .btn-ghost.btn-xs, | |
| 305 | + | .btn-ghost.btn-sm { background: transparent; border-color: transparent; } | |
| 306 | + | .btn-ghost.btn-xs:hover, | |
| 307 | + | .btn-ghost.btn-sm:hover { background: var(--color-bg-inset); } | |
| 308 | + | .btn-sm { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); } | |
| 309 | + | .btn-block { width: 100%; justify-content: center; } | |
| 310 | + | ||
| 311 | + | /* --- Forms --- */ | |
| 312 | + | .form-group { | |
| 313 | + | display: flex; | |
| 314 | + | flex-direction: column; | |
| 315 | + | gap: var(--space-2); | |
| 316 | + | margin-bottom: var(--space-4); | |
| 317 | + | } | |
| 318 | + | .form-group label { | |
| 319 | + | font-size: var(--text-sm); | |
| 320 | + | font-weight: 500; | |
| 321 | + | color: var(--color-text); | |
| 322 | + | } | |
| 323 | + | .form-group input:not([type="file"]):not([type="checkbox"]), | |
| 324 | + | .form-group textarea, | |
| 325 | + | .form-group select { | |
| 326 | + | padding: var(--space-2) var(--space-3); | |
| 327 | + | border: 1px solid var(--color-border); | |
| 328 | + | border-radius: var(--radius-md); | |
| 329 | + | background: var(--color-bg); | |
| 330 | + | color: var(--color-text); | |
| 331 | + | font-size: var(--text-sm); | |
| 332 | + | } | |
| 333 | + | .form-group input:not([type="file"]):not([type="checkbox"]):focus, | |
| 334 | + | .form-group textarea:focus { | |
| 335 | + | outline: 2px solid var(--color-accent); | |
| 336 | + | outline-offset: -1px; | |
| 337 | + | border-color: var(--color-accent); | |
| 338 | + | } | |
| 339 | + | .form-group input[type="file"] { | |
| 340 | + | font-size: var(--text-sm); | |
| 341 | + | color: var(--color-text); | |
| 342 | + | padding: 0; | |
| 343 | + | border: none; | |
| 344 | + | background: none; | |
| 345 | + | } | |
| 346 | + | .form-group textarea { resize: vertical; min-height: 120px; } | |
| 347 | + | .form-success { | |
| 348 | + | background: var(--color-success-bg); | |
| 349 | + | color: var(--color-success); | |
| 350 | + | border: 1px solid var(--color-success); | |
| 351 | + | border-radius: var(--radius-md); | |
| 352 | + | padding: var(--space-3) var(--space-4); | |
| 353 | + | font-size: var(--text-sm); | |
| 354 | + | margin-bottom: var(--space-4); | |
| 355 | + | } | |
| 356 | + | .form-hint { | |
| 357 | + | font-size: var(--text-sm); | |
| 358 | + | color: var(--color-text-muted); | |
| 359 | + | margin: 0; | |
| 360 | + | } | |
| 361 | + | .form-error { | |
| 362 | + | background: var(--color-danger-bg); | |
| 363 | + | color: var(--color-danger); | |
| 364 | + | border: 1px solid var(--color-danger); | |
| 365 | + | border-radius: var(--radius-md); | |
| 366 | + | padding: var(--space-3) var(--space-4); | |
| 367 | + | font-size: var(--text-sm); | |
| 368 | + | margin-bottom: var(--space-4); | |
| 369 | + | } | |
| 370 | + | .form-actions { | |
| 371 | + | display: flex; | |
| 372 | + | gap: var(--space-3); | |
| 373 | + | align-items: center; | |
| 374 | + | flex-wrap: wrap; | |
| 375 | + | } | |
| 376 | + | .form-card { | |
| 377 | + | background: var(--color-bg-subtle); | |
| 378 | + | border: 1px solid var(--color-border); | |
| 379 | + | border-radius: var(--radius-lg); | |
| 380 | + | padding: var(--space-6); | |
| 381 | + | margin-top: var(--space-4); | |
| 382 | + | } | |
| 383 | + | .checkbox-label { | |
| 384 | + | display: flex; | |
| 385 | + | align-items: center; | |
| 386 | + | gap: var(--space-2); | |
| 387 | + | cursor: pointer; | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | /* --- Auth --- */ | |
| 391 | + | .auth-container { | |
| 392 | + | max-width: 400px; | |
| 393 | + | margin: var(--space-12) auto; | |
| 394 | + | } | |
| 395 | + | .auth-container .page-title { margin-bottom: var(--space-6); } | |
| 396 | + | .auth-form { margin-bottom: var(--space-4); } | |
| 397 | + | .auth-footer { | |
| 398 | + | text-align: center; | |
| 399 | + | font-size: var(--text-sm); | |
| 400 | + | color: var(--color-text-muted); | |
| 401 | + | margin-top: var(--space-4); | |
| 402 | + | } | |
| 403 | + | .passkey-btn { display: none; margin-top: var(--space-3); } | |
| 404 | + | ||
| 405 | + | /* --- Page header --- */ | |
| 406 | + | .page-header { | |
| 407 | + | display: flex; | |
| 408 | + | align-items: center; | |
| 409 | + | justify-content: space-between; | |
| 410 | + | margin-bottom: var(--space-6); | |
| 411 | + | gap: var(--space-4); | |
| 412 | + | } | |
| 413 | + | .page-title { | |
| 414 | + | font-size: var(--text-2xl); | |
| 415 | + | color: var(--color-text); | |
| 416 | + | margin-bottom: var(--space-4); | |
| 417 | + | } | |
| 418 | + | .page-title a { text-decoration: none; color: var(--color-link); } | |
| 419 | + | .page-title a:hover { text-decoration: underline; } | |
| 420 | + | .section-title { | |
| 421 | + | font-size: var(--text-lg); | |
| 422 | + | margin-bottom: var(--space-4); | |
| 423 | + | padding-bottom: var(--space-2); | |
| 424 | + | border-bottom: 1px solid var(--color-border); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | /* --- Repo header --- */ | |
| 428 | + | .repo-header { | |
| 429 | + | margin-bottom: var(--space-2); | |
| 430 | + | } | |
| 431 | + | .repo-title-row { | |
| 432 | + | display: flex; | |
| 433 | + | align-items: center; | |
| 434 | + | gap: var(--space-3); | |
| 435 | + | flex-wrap: wrap; | |
| 436 | + | } | |
| 437 | + | .repo-title-row .page-title { margin-bottom: 0; } | |
| 438 | + | .repo-header-title { | |
| 439 | + | display: flex; | |
| 440 | + | align-items: center; | |
| 441 | + | gap: var(--space-3); | |
| 442 | + | flex-wrap: wrap; | |
| 443 | + | } | |
| 444 | + | .repo-description { | |
| 445 | + | color: var(--color-text-muted); | |
| 446 | + | margin-top: var(--space-2); | |
| 447 | + | } | |
| 448 | + | .repo-header-meta { | |
| 449 | + | display: flex; | |
| 450 | + | align-items: center; | |
| 451 | + | gap: var(--space-4); | |
| 452 | + | margin-bottom: var(--space-3); | |
| 453 | + | font-size: var(--text-sm); | |
| 454 | + | color: var(--color-text-muted); | |
| 455 | + | } | |
| 456 | + | .icon { font-style: normal; } | |
| 457 | + | ||
| 458 | + | /* --- Branch selector --- */ | |
| 459 | + | .branch-selector { | |
| 460 | + | display: inline-flex; | |
| 461 | + | align-items: center; | |
| 462 | + | gap: var(--space-2); | |
| 463 | + | } | |
| 464 | + | .branch-selector-icon { | |
| 465 | + | color: var(--color-text-muted); | |
| 466 | + | font-size: var(--text-sm); | |
| 467 | + | user-select: none; | |
| 468 | + | } | |
| 469 | + | .branch-select { | |
| 470 | + | padding: var(--space-1) var(--space-2); | |
| 471 | + | padding-right: var(--space-5); | |
| 472 | + | border: 1px solid var(--color-border); | |
| 473 | + | border-radius: var(--radius-md); | |
| 474 | + | background: var(--color-bg-subtle); | |
| 475 | + | font-size: var(--text-sm); | |
| 476 | + | font-family: var(--font-mono); | |
| 477 | + | color: var(--color-text); | |
| 478 | + | cursor: pointer; | |
| 479 | + | appearance: auto; | |
| 480 | + | max-width: 180px; | |
| 481 | + | } | |
| 482 | + | .branch-select:hover { border-color: var(--color-text-muted); } | |
| 483 | + | .branch-select:focus { outline: 2px solid var(--color-accent); outline-offset: -1px; } | |
| 484 | + | ||
| 485 | + | /* toolbar row above file tree */ | |
| 486 | + | .tree-toolbar { | |
| 487 | + | display: flex; | |
| 488 | + | align-items: center; | |
| 489 | + | justify-content: space-between; | |
| 490 | + | margin-bottom: var(--space-3); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | /* commits-header flex row */ | |
| 494 | + | .commits-header { | |
| 495 | + | display: flex; | |
| 496 | + | align-items: center; | |
| 497 | + | gap: var(--space-4); | |
| 498 | + | margin-bottom: var(--space-4); | |
| 499 | + | flex-wrap: wrap; | |
| 500 | + | } | |
| 501 | + | .commits-header .section-title { margin-bottom: 0; } | |
| 502 | + | ||
| 503 | + | /* --- Repo nav bar (tabs + private badge) --- */ | |
| 504 | + | .repo-nav-bar { | |
| 505 | + | display: flex; | |
| 506 | + | align-items: center; | |
| 507 | + | justify-content: space-between; | |
| 508 | + | border-bottom: 1px solid var(--color-border); | |
| 509 | + | margin-bottom: var(--space-6); | |
| 510 | + | gap: var(--space-4); | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | ||
| 514 | + | /* --- Repo tabs --- */ | |
| 515 | + | .repo-tabs { | |
| 516 | + | display: flex; | |
| 517 | + | gap: 0; | |
| 518 | + | flex: 1; | |
| 519 | + | overflow-x: auto; | |
| 520 | + | overflow-y: hidden; | |
| 521 | + | } | |
| 522 | + | .repo-tab { | |
| 523 | + | padding: var(--space-3) var(--space-4); | |
| 524 | + | font-size: var(--text-sm); | |
| 525 | + | text-decoration: none; | |
| 526 | + | color: var(--color-text-muted); | |
| 527 | + | border-bottom: 2px solid transparent; | |
| 528 | + | margin-bottom: -1px; | |
| 529 | + | transition: color 0.1s; | |
| 530 | + | } | |
| 531 | + | .repo-tab:hover { color: var(--color-text); } | |
| 532 | + | .repo-tab.active { | |
| 533 | + | color: var(--color-text); | |
| 534 | + | font-weight: 600; | |
| 535 | + | border-bottom-color: var(--color-accent); | |
| 536 | + | } | |
| 537 | + | /* Align active indicator with nav-bar border */ | |
| 538 | + | .repo-tab { margin-bottom: -1px; } | |
| 539 | + | ||
| 540 | + | /* --- Repo list --- */ | |
| 541 | + | .repo-list { display: flex; flex-direction: column; gap: var(--space-2); } | |
| 542 | + | .repo-card { | |
| 543 | + | display: flex; | |
| 544 | + | align-items: flex-start; | |
| 545 | + | justify-content: space-between; | |
| 546 | + | padding: var(--space-4) var(--space-5); | |
| 547 | + | border: 1px solid var(--color-border); | |
| 548 | + | border-radius: var(--radius-lg); | |
| 549 | + | background: var(--color-bg); | |
| 550 | + | gap: var(--space-4); | |
| 551 | + | transition: border-color 0.15s; | |
| 552 | + | } | |
| 553 | + | .repo-card:hover { border-color: var(--color-accent); } | |
| 554 | + | .repo-card-main { flex: 1; min-width: 0; } | |
| 555 | + | .repo-card-title { | |
| 556 | + | display: flex; | |
| 557 | + | align-items: center; | |
| 558 | + | gap: var(--space-2); | |
| 559 | + | flex-wrap: wrap; | |
| 560 | + | margin-bottom: var(--space-1); | |
| 561 | + | } | |
| 562 | + | .repo-name { | |
| 563 | + | font-size: var(--text-lg); | |
| 564 | + | font-weight: 600; | |
| 565 | + | color: var(--color-link); | |
| 566 | + | text-decoration: none; | |
| 567 | + | } | |
| 568 | + | .repo-name:hover { text-decoration: underline; } | |
| 569 | + | .repo-card-meta { | |
| 570 | + | display: flex; | |
| 571 | + | flex-direction: column; | |
| 572 | + | align-items: flex-end; | |
| 573 | + | gap: var(--space-1); | |
| 574 | + | font-size: var(--text-xs); | |
| 575 | + | color: var(--color-text-muted); | |
| 576 | + | white-space: nowrap; | |
| 577 | + | } | |
| 578 | + | .repo-date { color: var(--color-text-muted); } | |
| 579 | + | ||
| 580 | + | /* --- Badges --- */ | |
| 581 | + | .badge { | |
| 582 | + | display: inline-flex; | |
| 583 | + | align-items: center; | |
| 584 | + | padding: 1px var(--space-2); | |
| 585 | + | border-radius: var(--radius-full); | |
| 586 | + | font-size: var(--text-xs); | |
| 587 | + | font-weight: 500; | |
| 588 | + | border: 1px solid; | |
| 589 | + | } | |
| 590 | + | .badge-private { | |
| 591 | + | color: var(--color-warning); | |
| 592 | + | border-color: var(--color-warning); | |
| 593 | + | background: var(--color-warning-bg); | |
| 594 | + | } | |
| 595 | + | .issue-badge, .patch-badge { | |
| 596 | + | display: inline-flex; | |
| 597 | + | align-items: center; | |
| 598 | + | padding: var(--space-1) var(--space-3); | |
| 599 | + | border-radius: var(--radius-full); | |
| 600 | + | font-size: var(--text-xs); | |
| 601 | + | font-weight: 500; | |
| 602 | + | text-transform: capitalize; | |
| 603 | + | } | |
| 604 | + | .issue-badge.open, .patch-badge.open { | |
| 605 | + | background: var(--color-success-bg); | |
| 606 | + | color: var(--color-success); | |
| 607 | + | } | |
| 608 | + | .issue-badge.closed, .patch-badge.closed { | |
| 609 | + | background: var(--color-danger-bg); | |
| 610 | + | color: var(--color-danger); | |
| 611 | + | } | |
| 612 | + | .patch-badge.merged { | |
| 613 | + | background: var(--color-merged-bg); | |
| 614 | + | color: var(--color-merged); | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | /* --- Empty state --- */ | |
| 618 | + | .empty-state { | |
| 619 | + | text-align: center; | |
| 620 | + | padding: var(--space-16) var(--space-4); | |
| 621 | + | color: var(--color-text-muted); | |
| 622 | + | } | |
| 623 | + | .empty-state p { margin-bottom: var(--space-4); font-size: var(--text-lg); } | |
| 624 | + | ||
| 625 | + | /* --- Breadcrumb --- */ | |
| 626 | + | .breadcrumb { | |
| 627 | + | display: flex; | |
| 628 | + | align-items: center; | |
| 629 | + | flex-wrap: wrap; | |
| 630 | + | gap: var(--space-1); | |
| 631 | + | font-size: var(--text-sm); | |
| 632 | + | margin-bottom: var(--space-4); | |
| 633 | + | } | |
| 634 | + | .breadcrumb a { color: var(--color-link); text-decoration: none; } | |
| 635 | + | .breadcrumb a:hover { text-decoration: underline; } | |
| 636 | + | .breadcrumb-sep { color: var(--color-text-muted); } | |
| 637 | + | .breadcrumb-current { color: var(--color-text-muted); } | |
| 638 | + | ||
| 639 | + | /* --- File tree --- */ | |
| 640 | + | .file-tree { | |
| 641 | + | width: 100%; | |
| 642 | + | border: 1px solid var(--color-border); | |
| 643 | + | border-radius: var(--radius-lg); | |
| 644 | + | border-collapse: collapse; | |
| 645 | + | overflow: hidden; | |
| 646 | + | margin-bottom: var(--space-6); | |
| 647 | + | } | |
| 648 | + | .file-tree-row { | |
| 649 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 650 | + | } | |
| 651 | + | .file-tree-row:last-child { border-bottom: none; } | |
| 652 | + | .file-tree-row:hover { background: var(--color-bg-subtle); } | |
| 653 | + | .file-icon, .file-name, .file-size { | |
| 654 | + | padding: var(--space-2) var(--space-3); | |
| 655 | + | font-size: var(--text-sm); | |
| 656 | + | vertical-align: middle; | |
| 657 | + | } | |
| 658 | + | .file-icon { | |
| 659 | + | width: 36px; | |
| 660 | + | padding-right: var(--space-1); | |
| 661 | + | text-align: center; | |
| 662 | + | line-height: 0; | |
| 663 | + | } | |
| 664 | + | .file-name { width: 100%; } | |
| 665 | + | .file-name a { color: var(--color-link); text-decoration: none; } | |
| 666 | + | .file-name a:hover { text-decoration: underline; } | |
| 667 | + | .file-name a.file-name-dir { font-weight: 500; } | |
| 668 | + | .file-size { | |
| 669 | + | text-align: right; | |
| 670 | + | color: var(--color-text-muted); | |
| 671 | + | font-size: var(--text-xs); | |
| 672 | + | white-space: nowrap; | |
| 673 | + | padding-right: var(--space-4); | |
| 674 | + | } | |
| 675 | + | .file-icon-dir { color: var(--color-link); } | |
| 676 | + | .file-icon-file { color: var(--color-text-muted); } | |
| 677 | + | .tree-icon { display: inline-block; vertical-align: text-bottom; } | |
| 678 | + | .file-tree-row-up .file-name a { color: var(--color-text-muted); } | |
| 679 | + | .file-tree-row-up .file-name a:hover { color: var(--color-link); } | |
| 680 | + | ||
| 681 | + | /* --- File blob --- */ | |
| 682 | + | .file-blob-header { | |
| 683 | + | display: flex; | |
| 684 | + | align-items: center; | |
| 685 | + | justify-content: space-between; | |
| 686 | + | padding: var(--space-3) var(--space-4); | |
| 687 | + | background: var(--color-bg-subtle); | |
| 688 | + | border: 1px solid var(--color-border); | |
| 689 | + | border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 690 | + | } | |
| 691 | + | .file-blob-name { font-size: var(--text-sm); font-weight: 500; } | |
| 692 | + | .file-blob-body { | |
| 693 | + | border: 1px solid var(--color-border); | |
| 694 | + | border-top: none; | |
| 695 | + | border-radius: 0 0 var(--radius-lg) var(--radius-lg); | |
| 696 | + | overflow: auto; | |
| 697 | + | } | |
| 698 | + | .file-blob-body .shiki-wrapper pre { | |
| 699 | + | margin: 0; | |
| 700 | + | padding: var(--space-4); | |
| 701 | + | font-size: var(--text-sm); | |
| 702 | + | line-height: 1.6; | |
| 703 | + | min-width: max-content; | |
| 704 | + | } | |
| 705 | + | .file-download-notice { | |
| 706 | + | padding: var(--space-8); | |
| 707 | + | text-align: center; | |
| 708 | + | color: var(--color-text-muted); | |
| 709 | + | } | |
| 710 | + | .file-download-notice p { margin-bottom: var(--space-4); } | |
| 711 | + | ||
| 712 | + | /* --- Commit list --- */ | |
| 713 | + | .commit-list { display: flex; flex-direction: column; } | |
| 714 | + | .commit-item { | |
| 715 | + | display: flex; | |
| 716 | + | align-items: flex-start; | |
| 717 | + | justify-content: space-between; | |
| 718 | + | padding: var(--space-3) 0; | |
| 719 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 720 | + | gap: var(--space-4); | |
| 721 | + | flex-wrap: wrap; | |
| 722 | + | } | |
| 723 | + | .commit-item:last-child { border-bottom: none; } | |
| 724 | + | .commit-main { flex: 1; min-width: 0; } | |
| 725 | + | .commit-subject { | |
| 726 | + | color: var(--color-text); | |
| 727 | + | text-decoration: none; | |
| 728 | + | font-size: var(--text-sm); | |
| 729 | + | font-weight: 500; | |
| 730 | + | } | |
| 731 | + | .commit-subject:hover { color: var(--color-link); } | |
| 732 | + | .commit-meta { | |
| 733 | + | display: flex; | |
| 734 | + | align-items: center; | |
| 735 | + | gap: var(--space-3); | |
| 736 | + | font-size: var(--text-xs); | |
| 737 | + | color: var(--color-text-muted); | |
| 738 | + | white-space: nowrap; | |
| 739 | + | } | |
| 740 | + | .commit-hash { | |
| 741 | + | font-family: var(--font-mono); | |
| 742 | + | color: var(--color-link); | |
| 743 | + | text-decoration: none; | |
| 744 | + | background: var(--color-bg-inset); | |
| 745 | + | padding: 1px var(--space-2); | |
| 746 | + | border-radius: var(--radius-sm); | |
| 747 | + | } | |
| 748 | + | .commit-hash:hover { text-decoration: underline; } | |
| 749 | + | .mono { font-family: var(--font-mono); } | |
| 750 | + | ||
| 751 | + | /* --- Commit detail viewer --- */ | |
| 752 | + | .commit-page-top { | |
| 753 | + | display: flex; | |
| 754 | + | align-items: center; | |
| 755 | + | justify-content: space-between; | |
| 756 | + | margin-bottom: var(--space-4); | |
| 757 | + | } | |
| 758 | + | .commit-card { | |
| 759 | + | border: 1px solid var(--color-border); | |
| 760 | + | border-radius: var(--radius-lg); | |
| 761 | + | margin-bottom: var(--space-4); | |
| 762 | + | overflow: hidden; | |
| 763 | + | } | |
| 764 | + | .commit-card-subject { | |
| 765 | + | font-size: var(--text-xl); | |
| 766 | + | font-weight: 600; | |
| 767 | + | padding: var(--space-5) var(--space-6); | |
| 768 | + | border-bottom: 1px solid var(--color-border); | |
| 769 | + | line-height: 1.3; | |
| 770 | + | } | |
| 771 | + | .commit-card-body { | |
| 772 | + | padding: var(--space-4) var(--space-6); | |
| 773 | + | font-family: inherit; | |
| 774 | + | font-size: var(--text-sm); | |
| 775 | + | color: var(--color-text-muted); | |
| 776 | + | border-bottom: 1px solid var(--color-border); | |
| 777 | + | overflow-x: auto; | |
| 778 | + | } | |
| 779 | + | .commit-card-meta { | |
| 780 | + | padding: var(--space-4) var(--space-6); | |
| 781 | + | display: flex; | |
| 782 | + | flex-direction: column; | |
| 783 | + | gap: var(--space-2); | |
| 784 | + | background: var(--color-bg-subtle); | |
| 785 | + | } | |
| 786 | + | .commit-card-meta-row { | |
| 787 | + | display: flex; | |
| 788 | + | align-items: baseline; | |
| 789 | + | gap: var(--space-3); | |
| 790 | + | font-size: var(--text-sm); | |
| 791 | + | flex-wrap: wrap; | |
| 792 | + | } | |
| 793 | + | .commit-meta-label { | |
| 794 | + | font-weight: 600; | |
| 795 | + | color: var(--color-text-muted); | |
| 796 | + | min-width: 5rem; | |
| 797 | + | font-size: var(--text-xs); | |
| 798 | + | text-transform: uppercase; | |
| 799 | + | letter-spacing: 0.04em; | |
| 800 | + | } | |
| 801 | + | .commit-sha-full { | |
| 802 | + | font-size: var(--text-xs); | |
| 803 | + | background: var(--color-bg-inset); | |
| 804 | + | padding: 2px var(--space-2); | |
| 805 | + | border-radius: var(--radius-sm); | |
| 806 | + | word-break: break-all; | |
| 807 | + | } | |
| 808 | + | .commit-stats-bar { | |
| 809 | + | font-size: var(--text-sm); | |
| 810 | + | color: var(--color-text-muted); | |
| 811 | + | margin-bottom: var(--space-4); | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | /* --- Commit layout (sidebar + diffs) --- */ | |
| 815 | + | .commit-layout { | |
| 816 | + | display: grid; | |
| 817 | + | grid-template-columns: 1fr; | |
| 818 | + | gap: var(--space-5); | |
| 819 | + | align-items: start; | |
| 820 | + | } | |
| 821 | + | .commit-diffs { min-width: 0; } | |
| 822 | + | @media (min-width: 900px) { | |
| 823 | + | .commit-layout { grid-template-columns: 220px 1fr; } | |
| 824 | + | .commit-layout:has(.file-nav-details:not([open])) { grid-template-columns: auto 1fr; } | |
| 825 | + | } | |
| 826 | + | ||
| 827 | + | /* --- File nav sidebar --- */ | |
| 828 | + | .commit-file-nav { | |
| 829 | + | position: sticky; | |
| 830 | + | top: calc(56px + var(--space-4)); | |
| 831 | + | max-height: calc(100vh - 140px); | |
| 832 | + | overflow-y: auto; | |
| 833 | + | } | |
| 834 | + | .file-nav-details { | |
| 835 | + | border: 1px solid var(--color-border); | |
| 836 | + | border-radius: var(--radius-lg); | |
| 837 | + | background: var(--color-bg-subtle); | |
| 838 | + | overflow: hidden; | |
| 839 | + | } | |
| 840 | + | .file-nav-toggle { | |
| 841 | + | display: flex; | |
| 842 | + | align-items: center; | |
| 843 | + | gap: var(--space-2); | |
| 844 | + | padding: var(--space-3) var(--space-4); | |
| 845 | + | font-size: var(--text-sm); | |
| 846 | + | font-weight: 500; | |
| 847 | + | cursor: pointer; | |
| 848 | + | list-style: none; | |
| 849 | + | user-select: none; | |
| 850 | + | border-bottom: 1px solid var(--color-border); | |
| 851 | + | } | |
| 852 | + | .file-nav-toggle::-webkit-details-marker { display: none; } | |
| 853 | + | .file-nav-details:not([open]) .file-nav-toggle { border-bottom: none; } | |
| 854 | + | .file-nav-toggle-icon { | |
| 855 | + | font-size: 1em; | |
| 856 | + | transition: transform 0.15s; | |
| 857 | + | } | |
| 858 | + | .file-nav-details:not([open]) .file-nav-toggle-icon { transform: rotate(-90deg); } | |
| 859 | + | .file-nav-list { | |
| 860 | + | padding: var(--space-1) 0; | |
| 861 | + | list-style: none; | |
| 862 | + | } | |
| 863 | + | .file-nav-item { | |
| 864 | + | display: flex; | |
| 865 | + | align-items: center; | |
| 866 | + | gap: var(--space-2); | |
| 867 | + | padding: var(--space-1) var(--space-3); | |
| 868 | + | font-size: var(--text-xs); | |
| 869 | + | text-decoration: none; | |
| 870 | + | color: var(--color-text); | |
| 871 | + | border-radius: 0; | |
| 872 | + | overflow: hidden; | |
| 873 | + | } | |
| 874 | + | .file-nav-item:hover { background: var(--color-bg-inset); } | |
| 875 | + | .file-nav-status { | |
| 876 | + | flex-shrink: 0; | |
| 877 | + | width: 16px; | |
| 878 | + | height: 16px; | |
| 879 | + | display: inline-flex; | |
| 880 | + | align-items: center; | |
| 881 | + | justify-content: center; | |
| 882 | + | border-radius: var(--radius-sm); | |
| 883 | + | font-size: 9px; | |
| 884 | + | font-weight: 700; | |
| 885 | + | font-family: var(--font-mono); | |
| 886 | + | } | |
| 887 | + | .file-status-added { background: var(--color-success-bg); color: var(--color-success); } | |
| 888 | + | .file-status-deleted { background: var(--color-danger-bg); color: var(--color-danger); } | |
| 889 | + | .file-status-modified { background: var(--color-accent-bg); color: var(--color-accent); } | |
| 890 | + | .file-status-renamed { background: var(--color-merged-bg); color: var(--color-merged); } | |
| 891 | + | .file-status-copied { background: var(--color-merged-bg); color: var(--color-merged); } | |
| 892 | + | .file-nav-name { | |
| 893 | + | flex: 1; | |
| 894 | + | overflow: hidden; | |
| 895 | + | text-overflow: ellipsis; | |
| 896 | + | white-space: nowrap; | |
| 897 | + | font-family: var(--font-mono); | |
| 898 | + | } | |
| 899 | + | .file-nav-stat { | |
| 900 | + | flex-shrink: 0; | |
| 901 | + | display: flex; | |
| 902 | + | gap: var(--space-1); | |
| 903 | + | font-size: var(--text-xs); | |
| 904 | + | font-family: var(--font-mono); | |
| 905 | + | } | |
| 906 | + | .nav-add { color: var(--color-success); } | |
| 907 | + | .nav-del { color: var(--color-danger); } | |
| 908 | + | .file-nav-dir > details > .file-nav-list { padding-left: var(--space-4); } | |
| 909 | + | .file-nav-dir-toggle { | |
| 910 | + | display: flex; | |
| 911 | + | align-items: center; | |
| 912 | + | gap: var(--space-1); | |
| 913 | + | padding: var(--space-1) var(--space-3); | |
| 914 | + | font-size: var(--text-xs); | |
| 915 | + | font-family: var(--font-mono); | |
| 916 | + | color: var(--color-text-muted); | |
| 917 | + | cursor: pointer; | |
| 918 | + | list-style: none; | |
| 919 | + | user-select: none; | |
| 920 | + | } | |
| 921 | + | .file-nav-dir-toggle::-webkit-details-marker { display: none; } | |
| 922 | + | .file-nav-dir > details:not([open]) .file-nav-toggle-icon { transform: rotate(-90deg); } | |
| 923 | + | ||
| 924 | + | /* --- Diff file cards --- */ | |
| 925 | + | .diff-file { | |
| 926 | + | border: 1px solid var(--color-border); | |
| 927 | + | border-radius: var(--radius-lg); | |
| 928 | + | margin-bottom: var(--space-4); | |
| 929 | + | overflow: hidden; | |
| 930 | + | } | |
| 931 | + | .diff-file-header { | |
| 932 | + | display: flex; | |
| 933 | + | align-items: center; | |
| 934 | + | justify-content: space-between; | |
| 935 | + | gap: var(--space-3); | |
| 936 | + | padding: var(--space-3) var(--space-4); | |
| 937 | + | background: var(--color-bg-subtle); | |
| 938 | + | cursor: pointer; | |
| 939 | + | list-style: none; | |
| 940 | + | user-select: none; | |
| 941 | + | flex-wrap: wrap; | |
| 942 | + | } | |
| 943 | + | .diff-file-header::-webkit-details-marker { display: none; } | |
| 944 | + | .diff-file-header-left { | |
| 945 | + | display: flex; | |
| 946 | + | align-items: center; | |
| 947 | + | gap: var(--space-2); | |
| 948 | + | min-width: 0; | |
| 949 | + | flex: 1; | |
| 950 | + | } | |
| 951 | + | .diff-file-header-right { | |
| 952 | + | display: flex; | |
| 953 | + | align-items: center; | |
| 954 | + | gap: var(--space-2); | |
| 955 | + | flex-shrink: 0; | |
| 956 | + | flex-wrap: wrap; | |
| 957 | + | } | |
| 958 | + | .diff-file-toggle-icon { | |
| 959 | + | font-size: 0.7em; | |
| 960 | + | flex-shrink: 0; | |
| 961 | + | transition: transform 0.15s; | |
| 962 | + | } | |
| 963 | + | .diff-file:not([open]) .diff-file-toggle-icon { transform: rotate(-90deg); } | |
| 964 | + | .diff-status-badge { | |
| 965 | + | flex-shrink: 0; | |
| 966 | + | width: 18px; | |
| 967 | + | height: 18px; | |
| 968 | + | display: inline-flex; | |
| 969 | + | align-items: center; | |
| 970 | + | justify-content: center; | |
| 971 | + | border-radius: var(--radius-sm); | |
| 972 | + | font-size: 10px; | |
| 973 | + | font-weight: 700; | |
| 974 | + | font-family: var(--font-mono); | |
| 975 | + | } | |
| 976 | + | .diff-status-added { background: var(--color-success-bg); color: var(--color-success); } | |
| 977 | + | .diff-status-deleted { background: var(--color-danger-bg); color: var(--color-danger); } | |
| 978 | + | .diff-status-modified { background: var(--color-accent-bg); color: var(--color-accent); } | |
| 979 | + | .diff-status-renamed { background: var(--color-merged-bg); color: var(--color-merged); } | |
| 980 | + | .diff-status-copied { background: var(--color-merged-bg); color: var(--color-merged); } | |
| 981 | + | .diff-file-path { | |
| 982 | + | font-size: var(--text-sm); | |
| 983 | + | font-weight: 500; | |
| 984 | + | overflow: hidden; | |
| 985 | + | text-overflow: ellipsis; | |
| 986 | + | white-space: nowrap; | |
| 987 | + | } | |
| 988 | + | .diff-rename-arrow { | |
| 989 | + | font-size: var(--text-xs); | |
| 990 | + | color: var(--color-text-muted); | |
| 991 | + | white-space: nowrap; | |
| 992 | + | overflow: hidden; | |
| 993 | + | text-overflow: ellipsis; | |
| 994 | + | } | |
| 995 | + | .diff-stat-add { color: var(--color-success); font-size: var(--text-sm); font-family: var(--font-mono); font-weight: 600; } | |
| 996 | + | .diff-stat-del { color: var(--color-danger); font-size: var(--text-sm); font-family: var(--font-mono); font-weight: 600; } | |
| 997 | + | .btn-xs { | |
| 998 | + | padding: 1px var(--space-2); | |
| 999 | + | font-size: var(--text-xs); | |
| 1000 | + | border: 1px solid var(--color-border); | |
| 1001 | + | border-radius: var(--radius-sm); | |
| 1002 | + | background: var(--color-bg); | |
| 1003 | + | color: var(--color-text); | |
| 1004 | + | text-decoration: none; | |
| 1005 | + | cursor: pointer; | |
| 1006 | + | white-space: nowrap; | |
| 1007 | + | font-family: var(--font-mono); | |
| 1008 | + | display: inline-flex; | |
| 1009 | + | align-items: center; | |
| 1010 | + | transition: background 0.1s; | |
| 1011 | + | } | |
| 1012 | + | .btn-xs:hover { background: var(--color-bg-inset); } | |
| 1013 | + | ||
| 1014 | + | /* --- Diff file body --- */ | |
| 1015 | + | .diff-file-body { } | |
| 1016 | + | .diff-binary-notice { | |
| 1017 | + | padding: var(--space-4) var(--space-6); | |
| 1018 | + | font-size: var(--text-sm); | |
| 1019 | + | color: var(--color-text-muted); | |
| 1020 | + | font-style: italic; | |
| 1021 | + | } | |
| 1022 | + | .diff-hunk { border-top: 1px solid var(--color-border-muted); overflow-x: auto; } | |
| 1023 | + | .diff-hunk:first-child { border-top: none; } | |
| 1024 | + | .diff-hunk-header { | |
| 1025 | + | padding: var(--space-1) var(--space-4); | |
| 1026 | + | font-family: var(--font-mono); | |
| 1027 | + | font-size: var(--text-xs); | |
| 1028 | + | font-weight: normal; | |
| 1029 | + | text-align: left; | |
| 1030 | + | background: color-mix(in srgb, var(--color-merged-bg) 50%, var(--color-bg)); | |
| 1031 | + | color: var(--color-merged); | |
| 1032 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 1033 | + | white-space: pre; | |
| 1034 | + | } | |
| 1035 | + | ||
| 1036 | + | /* --- Diff table --- */ | |
| 1037 | + | .diff-table { | |
| 1038 | + | min-width: 100%; | |
| 1039 | + | border-collapse: collapse; | |
| 1040 | + | font-family: var(--font-mono); | |
| 1041 | + | font-size: var(--text-xs); | |
| 1042 | + | line-height: 1.5; | |
| 1043 | + | } | |
| 1044 | + | .diff-ln { | |
| 1045 | + | width: 44px; | |
| 1046 | + | min-width: 44px; | |
| 1047 | + | text-align: right; | |
| 1048 | + | padding: 0 var(--space-2); | |
| 1049 | + | color: var(--color-text-muted); | |
| 1050 | + | border-right: 1px solid var(--color-border-muted); | |
| 1051 | + | user-select: none; | |
| 1052 | + | vertical-align: top; | |
| 1053 | + | white-space: nowrap; | |
| 1054 | + | } | |
| 1055 | + | .diff-sign { | |
| 1056 | + | width: 18px; | |
| 1057 | + | min-width: 18px; | |
| 1058 | + | text-align: center; | |
| 1059 | + | padding: 0 var(--space-1); | |
| 1060 | + | user-select: none; | |
| 1061 | + | vertical-align: top; | |
| 1062 | + | } | |
| 1063 | + | .diff-code { | |
| 1064 | + | padding: 0 var(--space-3); | |
| 1065 | + | white-space: pre; | |
| 1066 | + | overflow: visible; | |
| 1067 | + | vertical-align: top; | |
| 1068 | + | word-break: normal; | |
| 1069 | + | } | |
| 1070 | + | /* Row type backgrounds */ | |
| 1071 | + | .diff-row-add { background: color-mix(in srgb, var(--color-success-bg) 70%, transparent); } | |
| 1072 | + | .diff-row-del { background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent); } | |
| 1073 | + | .diff-row-add .diff-ln, | |
| 1074 | + | .diff-row-add .diff-sign { background: color-mix(in srgb, var(--color-success-bg) 90%, transparent); } | |
| 1075 | + | .diff-row-del .diff-ln, | |
| 1076 | + | .diff-row-del .diff-sign { background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent); } | |
| 1077 | + | .diff-row-add .diff-sign { color: var(--color-success); font-weight: 700; } | |
| 1078 | + | .diff-row-del .diff-sign { color: var(--color-danger); font-weight: 700; } | |
| 1079 | + | ||
| 1080 | + | /* Shiki dark-mode support in diff code cells */ | |
| 1081 | + | @media (prefers-color-scheme: dark) { | |
| 1082 | + | .diff-code span[style] { color: var(--shiki-dark) !important; } | |
| 1083 | + | } | |
| 1084 | + | ||
| 1085 | + | /* Shiki dark-mode support in file blob view */ | |
| 1086 | + | @media (prefers-color-scheme: dark) { | |
| 1087 | + | .shiki-wrapper .shiki { background-color: var(--shiki-dark-bg) !important; color: var(--shiki-dark) !important; } | |
| 1088 | + | .shiki-wrapper .shiki span { color: var(--shiki-dark) !important; } | |
| 1089 | + | } | |
| 1090 | + | ||
| 1091 | + | /* --- Issue / Patch list --- */ | |
| 1092 | + | .list-header { | |
| 1093 | + | display: flex; | |
| 1094 | + | align-items: center; | |
| 1095 | + | justify-content: space-between; | |
| 1096 | + | padding: var(--space-3) var(--space-4); | |
| 1097 | + | background: var(--color-bg-subtle); | |
| 1098 | + | border: 1px solid var(--color-border); | |
| 1099 | + | border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 1100 | + | gap: var(--space-4); | |
| 1101 | + | } | |
| 1102 | + | .list-header-tabs { display: flex; gap: var(--space-2); } | |
| 1103 | + | .list-tab { | |
| 1104 | + | padding: var(--space-1) var(--space-3); | |
| 1105 | + | border-radius: var(--radius-md); | |
| 1106 | + | font-size: var(--text-sm); | |
| 1107 | + | text-decoration: none; | |
| 1108 | + | color: var(--color-text-muted); | |
| 1109 | + | } | |
| 1110 | + | .list-tab:hover { background: var(--color-bg-inset); color: var(--color-text); } | |
| 1111 | + | .list-tab.active { | |
| 1112 | + | background: var(--color-bg-inset); | |
| 1113 | + | color: var(--color-text); | |
| 1114 | + | font-weight: 600; | |
| 1115 | + | } | |
| 1116 | + | .issue-list { border: 1px solid var(--color-border); border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg); } | |
| 1117 | + | .issue-item { | |
| 1118 | + | padding: var(--space-4) var(--space-4); | |
| 1119 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 1120 | + | } | |
| 1121 | + | .issue-item:last-child { border-bottom: none; } | |
| 1122 | + | .issue-main { | |
| 1123 | + | display: flex; | |
| 1124 | + | align-items: center; | |
| 1125 | + | gap: var(--space-2); | |
| 1126 | + | margin-bottom: var(--space-1); | |
| 1127 | + | flex-wrap: wrap; | |
| 1128 | + | } | |
| 1129 | + | .issue-title { | |
| 1130 | + | font-weight: 500; | |
| 1131 | + | text-decoration: none; | |
| 1132 | + | color: var(--color-text); | |
| 1133 | + | flex: 1; | |
| 1134 | + | } | |
| 1135 | + | .issue-title:hover { color: var(--color-link); } | |
| 1136 | + | .issue-meta { | |
| 1137 | + | font-size: var(--text-xs); | |
| 1138 | + | color: var(--color-text-muted); | |
| 1139 | + | display: flex; | |
| 1140 | + | gap: var(--space-3); | |
| 1141 | + | } | |
| 1142 | + | .issue-status-dot, .patch-status-dot { | |
| 1143 | + | width: 8px; | |
| 1144 | + | height: 8px; | |
| 1145 | + | border-radius: 50%; | |
| 1146 | + | flex-shrink: 0; | |
| 1147 | + | } | |
| 1148 | + | .issue-status-dot.open, .patch-status-dot.open { background: var(--color-success); } | |
| 1149 | + | .issue-status-dot.closed, .patch-status-dot.closed { background: var(--color-danger); } | |
| 1150 | + | .patch-status-dot.merged { background: var(--color-merged); } | |
| 1151 | + | ||
| 1152 | + | /* --- Issue detail / Timeline --- */ | |
| 1153 | + | .issue-detail-header { | |
| 1154 | + | display: flex; | |
| 1155 | + | align-items: center; | |
| 1156 | + | gap: var(--space-3); | |
| 1157 | + | margin-bottom: var(--space-6); | |
| 1158 | + | flex-wrap: wrap; | |
| 1159 | + | } | |
| 1160 | + | .issue-number { | |
| 1161 | + | font-size: var(--text-2xl); | |
| 1162 | + | font-weight: 600; | |
| 1163 | + | color: var(--color-text-muted); | |
| 1164 | + | white-space: nowrap; | |
| 1165 | + | } | |
| 1166 | + | .issue-detail-title { | |
| 1167 | + | font-size: var(--text-2xl); | |
| 1168 | + | font-weight: 600; | |
| 1169 | + | flex: 1; | |
| 1170 | + | } | |
| 1171 | + | .issue-detail-meta-actions { margin-left: auto; display: flex; gap: var(--space-2); } | |
| 1172 | + | .timeline-item { | |
| 1173 | + | border: 1px solid var(--color-border); | |
| 1174 | + | border-radius: var(--radius-lg); | |
| 1175 | + | margin-bottom: var(--space-4); | |
| 1176 | + | overflow: hidden; | |
| 1177 | + | } | |
| 1178 | + | .timeline-author { | |
| 1179 | + | background: var(--color-bg-subtle); | |
| 1180 | + | padding: var(--space-3) var(--space-4); | |
| 1181 | + | font-size: var(--text-sm); | |
| 1182 | + | display: flex; | |
| 1183 | + | align-items: center; | |
| 1184 | + | justify-content: space-between; | |
| 1185 | + | flex-wrap: wrap; | |
| 1186 | + | gap: var(--space-3); | |
| 1187 | + | border-bottom: 1px solid var(--color-border); | |
| 1188 | + | } | |
| 1189 | + | .timeline-author time { color: var(--color-text-muted); font-size: var(--text-xs); } | |
| 1190 | + | .timeline-author-right { margin-left: auto; display: flex; align-items: center; gap: var(--space-2); } | |
| 1191 | + | .edited-indicator { color: var(--color-text-muted); font-size: var(--text-xs); cursor: default; } | |
| 1192 | + | ||
| 1193 | + | /* --- Avatars --- */ | |
| 1194 | + | .avatar { | |
| 1195 | + | display: inline-flex; | |
| 1196 | + | border-radius: 4px; | |
| 1197 | + | overflow: hidden; | |
| 1198 | + | flex-shrink: 0; | |
| 1199 | + | vertical-align: middle; | |
| 1200 | + | } | |
| 1201 | + | .avatar-img { | |
| 1202 | + | width: 100%; | |
| 1203 | + | height: 100%; | |
| 1204 | + | object-fit: cover; | |
| 1205 | + | display: block; | |
| 1206 | + | color: transparent; | |
| 1207 | + | } | |
| 1208 | + | .avatar-placeholder { | |
| 1209 | + | display: inline-flex; | |
| 1210 | + | align-items: center; | |
| 1211 | + | justify-content: center; | |
| 1212 | + | border-radius: 4px; | |
| 1213 | + | background: var(--color-bg-inset); | |
| 1214 | + | color: var(--color-text-muted); | |
| 1215 | + | font-weight: 600; | |
| 1216 | + | flex-shrink: 0; | |
| 1217 | + | vertical-align: middle; | |
| 1218 | + | } | |
| 1219 | + | .avatar-settings { | |
| 1220 | + | display: flex; | |
| 1221 | + | align-items: flex-start; | |
| 1222 | + | gap: var(--space-5); | |
| 1223 | + | } | |
| 1224 | + | .avatar-actions { | |
| 1225 | + | display: flex; | |
| 1226 | + | flex-direction: column; | |
| 1227 | + | gap: var(--space-3); | |
| 1228 | + | } | |
| 1229 | + | .nav-user { | |
| 1230 | + | display: inline-flex; | |
| 1231 | + | align-items: center; | |
| 1232 | + | gap: var(--space-2); | |
| 1233 | + | } | |
| 1234 | + | .timeline-body { padding: var(--space-4); } | |
| 1235 | + | .timeline-item-new { padding: var(--space-4); } | |
| 1236 | + | .timeline-item-new .section-title { font-size: var(--text-base); border: none; padding-bottom: 0; } | |
| 1237 | + | /* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */ | |
| 1238 | + | .timeline-author-right .inline-edit-details { border: none; } | |
| 1239 | + | .timeline-author-right .inline-edit-details > summary { padding: 0; list-style: none; } | |
| 1240 | + | .timeline-author-right .inline-edit-details > summary::-webkit-details-marker { display: none; } | |
| 1241 | + | .inline-edit-details .when-open { display: none; } | |
| 1242 | + | .inline-edit-details[open] .when-closed { display: none; } | |
| 1243 | + | .inline-edit-details[open] .when-open { display: inline; } | |
| 1244 | + | .inline-edit-form-area { display: none; border-top: 1px solid var(--color-border-muted); } | |
| 1245 | + | .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area { display: block; } | |
| 1246 | + | .inline-edit-form { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); } | |
| 1247 | + | ||
| 1248 | + | /* --- Reactions --- */ | |
| 1249 | + | .reaction-bar { | |
| 1250 | + | display: flex; | |
| 1251 | + | flex-wrap: wrap; | |
| 1252 | + | gap: var(--space-2); | |
| 1253 | + | padding: var(--space-2) var(--space-4); | |
| 1254 | + | border-top: 1px solid var(--color-border-muted); | |
| 1255 | + | } | |
| 1256 | + | .reaction-form { display: inline; } | |
| 1257 | + | .reaction-btn { | |
| 1258 | + | display: inline-flex; | |
| 1259 | + | align-items: center; | |
| 1260 | + | gap: var(--space-1); | |
| 1261 | + | padding: var(--space-1) var(--space-3); | |
| 1262 | + | border: 1px solid var(--color-border); | |
| 1263 | + | border-radius: var(--radius-full); | |
| 1264 | + | background: var(--color-bg-subtle); | |
| 1265 | + | color: var(--color-text); | |
| 1266 | + | font-size: var(--text-sm); | |
| 1267 | + | cursor: pointer; | |
| 1268 | + | transition: background 0.1s; | |
| 1269 | + | } | |
| 1270 | + | .reaction-btn:hover { background: var(--color-bg-inset); } | |
| 1271 | + | .reaction-btn.reacted { | |
| 1272 | + | background: var(--color-accent-bg); | |
| 1273 | + | border-color: var(--color-accent); | |
| 1274 | + | } | |
| 1275 | + | .reaction-picker { | |
| 1276 | + | position: relative; | |
| 1277 | + | } | |
| 1278 | + | .reaction-add-btn { | |
| 1279 | + | display: inline-flex; | |
| 1280 | + | align-items: center; | |
| 1281 | + | justify-content: center; | |
| 1282 | + | width: 28px; | |
| 1283 | + | height: 28px; | |
| 1284 | + | border: 1px dashed var(--color-border); | |
| 1285 | + | border-radius: var(--radius-full); | |
| 1286 | + | background: transparent; | |
| 1287 | + | cursor: pointer; | |
| 1288 | + | font-size: var(--text-base); | |
| 1289 | + | color: var(--color-text-muted); | |
| 1290 | + | list-style: none; | |
| 1291 | + | } | |
| 1292 | + | .reaction-add-btn::-webkit-details-marker { display: none; } | |
| 1293 | + | .reaction-picker-dropdown { | |
| 1294 | + | position: absolute; | |
| 1295 | + | bottom: calc(100% + var(--space-2)); | |
| 1296 | + | left: 0; | |
| 1297 | + | background: var(--color-bg); | |
| 1298 | + | border: 1px solid var(--color-border); | |
| 1299 | + | border-radius: var(--radius-lg); | |
| 1300 | + | padding: var(--space-2); | |
| 1301 | + | display: flex; | |
| 1302 | + | gap: var(--space-1); | |
| 1303 | + | z-index: 10; | |
| 1304 | + | } | |
| 1305 | + | .reaction-picker-btn { | |
| 1306 | + | background: none; | |
| 1307 | + | border: none; | |
| 1308 | + | font-size: 1.25rem; | |
| 1309 | + | cursor: pointer; | |
| 1310 | + | padding: var(--space-1); | |
| 1311 | + | border-radius: var(--radius-sm); | |
| 1312 | + | } | |
| 1313 | + | .reaction-picker-btn:hover { background: var(--color-bg-inset); } | |
| 1314 | + | ||
| 1315 | + | /* --- Patch subview tabs --- */ | |
| 1316 | + | .patch-tab-nav { | |
| 1317 | + | display: flex; | |
| 1318 | + | border-bottom: 1px solid var(--color-border); | |
| 1319 | + | margin-bottom: var(--space-6); | |
| 1320 | + | } | |
| 1321 | + | .tab-count { | |
| 1322 | + | display: inline-flex; | |
| 1323 | + | align-items: center; | |
| 1324 | + | justify-content: center; | |
| 1325 | + | min-width: 1.4em; | |
| 1326 | + | padding: 0 var(--space-1); | |
| 1327 | + | margin-left: var(--space-1); | |
| 1328 | + | font-size: var(--text-xs); | |
| 1329 | + | font-weight: 500; | |
| 1330 | + | background: var(--color-bg-inset); | |
| 1331 | + | border-radius: 999px; | |
| 1332 | + | color: var(--color-text-muted); | |
| 1333 | + | } | |
| 1334 | + | ||
| 1335 | + | /* --- Patch apply status --- */ | |
| 1336 | + | .apply-status { | |
| 1337 | + | margin: var(--space-4) 0; | |
| 1338 | + | } | |
| 1339 | + | .apply-result { | |
| 1340 | + | display: flex; | |
| 1341 | + | align-items: flex-start; | |
| 1342 | + | gap: var(--space-2); | |
| 1343 | + | padding: var(--space-3) var(--space-4); | |
| 1344 | + | border-radius: var(--radius-md); | |
| 1345 | + | font-size: var(--text-sm); | |
| 1346 | + | flex-direction: column; | |
| 1347 | + | } | |
| 1348 | + | .apply-result > div { display: flex; gap: var(--space-2); align-items: center; } | |
| 1349 | + | .apply-clean { | |
| 1350 | + | background: var(--color-success-bg); | |
| 1351 | + | color: var(--color-success); | |
| 1352 | + | border: 1px solid var(--color-success); | |
| 1353 | + | } | |
| 1354 | + | .apply-conflict { | |
| 1355 | + | background: var(--color-danger-bg); | |
| 1356 | + | color: var(--color-danger); | |
| 1357 | + | border: 1px solid var(--color-danger); | |
| 1358 | + | } | |
| 1359 | + | .apply-checking, .apply-unknown { | |
| 1360 | + | color: var(--color-text-muted); | |
| 1361 | + | font-style: italic; | |
| 1362 | + | font-size: var(--text-sm); | |
| 1363 | + | } | |
| 1364 | + | .apply-output { | |
| 1365 | + | font-family: var(--font-mono); | |
| 1366 | + | font-size: var(--text-xs); | |
| 1367 | + | white-space: pre-wrap; | |
| 1368 | + | margin-top: var(--space-2); | |
| 1369 | + | padding: var(--space-2); | |
| 1370 | + | background: rgba(0,0,0,0.05); | |
| 1371 | + | border-radius: var(--radius-sm); | |
| 1372 | + | width: 100%; | |
| 1373 | + | } | |
| 1374 | + | .patch-actions { | |
| 1375 | + | display: flex; | |
| 1376 | + | gap: var(--space-3); | |
| 1377 | + | margin: var(--space-4) 0; | |
| 1378 | + | flex-wrap: wrap; | |
| 1379 | + | } | |
| 1380 | + | .patch-card-title-row { | |
| 1381 | + | display: flex; | |
| 1382 | + | align-items: flex-start; | |
| 1383 | + | gap: var(--space-3); | |
| 1384 | + | justify-content: space-between; | |
| 1385 | + | margin-bottom: var(--space-3); | |
| 1386 | + | } | |
| 1387 | + | .patch-card-title-row .commit-card-subject { margin-bottom: 0; } | |
| 1388 | + | .patch-card-description { | |
| 1389 | + | margin-top: var(--space-4); | |
| 1390 | + | padding-top: var(--space-4); | |
| 1391 | + | border-top: 1px solid var(--color-border-muted); | |
| 1392 | + | } | |
| 1393 | + | ||
| 1394 | + | /* --- Repo home layout --- */ | |
| 1395 | + | .repo-home-layout { | |
| 1396 | + | display: grid; | |
| 1397 | + | gap: var(--space-8); | |
| 1398 | + | } | |
| 1399 | + | @media (min-width: 768px) { | |
| 1400 | + | .repo-home-layout { | |
| 1401 | + | grid-template-columns: 1fr 1fr; | |
| 1402 | + | } | |
| 1403 | + | } | |
| 1404 | + | .readme-section { | |
| 1405 | + | border: 1px solid var(--color-border); | |
| 1406 | + | border-radius: var(--radius-lg); | |
| 1407 | + | overflow: hidden; | |
| 1408 | + | } | |
| 1409 | + | .readme-header { | |
| 1410 | + | background: var(--color-bg-subtle); | |
| 1411 | + | padding: var(--space-3) var(--space-4); | |
| 1412 | + | font-size: var(--text-sm); | |
| 1413 | + | font-weight: 500; | |
| 1414 | + | border-bottom: 1px solid var(--color-border); | |
| 1415 | + | } | |
| 1416 | + | .readme-section .markdown-body { | |
| 1417 | + | padding: var(--space-4); | |
| 1418 | + | } | |
| 1419 | + | ||
| 1420 | + | /* --- Code setup block --- */ | |
| 1421 | + | .code-setup { | |
| 1422 | + | background: var(--color-bg-subtle); | |
| 1423 | + | border: 1px solid var(--color-border); | |
| 1424 | + | border-radius: var(--radius-lg); | |
| 1425 | + | padding: var(--space-4); | |
| 1426 | + | font-size: var(--text-sm); | |
| 1427 | + | margin-top: var(--space-4); | |
| 1428 | + | overflow: auto; | |
| 1429 | + | text-align: left; | |
| 1430 | + | } | |
| 1431 | + | .code-plain { | |
| 1432 | + | background: var(--color-bg-subtle); | |
| 1433 | + | padding: var(--space-4); | |
| 1434 | + | overflow: auto; | |
| 1435 | + | font-size: var(--text-sm); | |
| 1436 | + | line-height: 1.6; | |
| 1437 | + | } | |
| 1438 | + | ||
| 1439 | + | /* --- Markdown body --- */ | |
| 1440 | + | .markdown-body { | |
| 1441 | + | font-size: var(--text-sm); | |
| 1442 | + | line-height: 1.6; | |
| 1443 | + | color: var(--color-text); | |
| 1444 | + | } | |
| 1445 | + | .markdown-body h1, .markdown-body h2, .markdown-body h3, | |
| 1446 | + | .markdown-body h4, .markdown-body h5, .markdown-body h6 { | |
| 1447 | + | margin-top: var(--space-6); | |
| 1448 | + | margin-bottom: var(--space-3); | |
| 1449 | + | } | |
| 1450 | + | .markdown-body p { margin-bottom: var(--space-4); } | |
| 1451 | + | .markdown-body a { color: var(--color-link); } | |
| 1452 | + | .markdown-body code { | |
| 1453 | + | background: var(--color-bg-inset); | |
| 1454 | + | padding: 0.1em 0.3em; | |
| 1455 | + | border-radius: var(--radius-sm); | |
| 1456 | + | font-size: 0.9em; | |
| 1457 | + | } | |
| 1458 | + | .markdown-body pre { | |
| 1459 | + | background: var(--color-bg-subtle); | |
| 1460 | + | border: 1px solid var(--color-border); | |
| 1461 | + | border-radius: var(--radius-md); | |
| 1462 | + | padding: var(--space-4); | |
| 1463 | + | overflow: auto; | |
| 1464 | + | margin-bottom: var(--space-4); | |
| 1465 | + | } | |
| 1466 | + | .markdown-body pre code { background: none; padding: 0; } | |
| 1467 | + | .markdown-body ul, .markdown-body ol { | |
| 1468 | + | padding-left: var(--space-6); | |
| 1469 | + | margin-bottom: var(--space-4); | |
| 1470 | + | } | |
| 1471 | + | .markdown-body ul { list-style: disc; } | |
| 1472 | + | .markdown-body ol { list-style: decimal; } | |
| 1473 | + | .markdown-body li { margin-bottom: var(--space-1); } | |
| 1474 | + | .markdown-body blockquote { | |
| 1475 | + | border-left: 3px solid var(--color-border); | |
| 1476 | + | padding-left: var(--space-4); | |
| 1477 | + | color: var(--color-text-muted); | |
| 1478 | + | margin: var(--space-4) 0; | |
| 1479 | + | } | |
| 1480 | + | .markdown-body table { | |
| 1481 | + | width: 100%; | |
| 1482 | + | border-collapse: collapse; | |
| 1483 | + | margin-bottom: var(--space-4); | |
| 1484 | + | } | |
| 1485 | + | .markdown-body th, .markdown-body td { | |
| 1486 | + | padding: var(--space-2) var(--space-3); | |
| 1487 | + | border: 1px solid var(--color-border); | |
| 1488 | + | } | |
| 1489 | + | .markdown-body th { background: var(--color-bg-subtle); font-weight: 600; } | |
| 1490 | + | .markdown-body hr { | |
| 1491 | + | border: none; | |
| 1492 | + | border-top: 1px solid var(--color-border); | |
| 1493 | + | margin: var(--space-6) 0; | |
| 1494 | + | } | |
| 1495 | + | .markdown-body img { max-width: 100%; border-radius: var(--radius-md); } | |
| 1496 | + | .markdown-body details { margin-bottom: var(--space-4); } | |
| 1497 | + | .markdown-body summary { cursor: pointer; font-weight: 500; } | |
| 1498 | + | ||
| 1499 | + | /* --- Search form --- */ | |
| 1500 | + | .search-form { margin-bottom: var(--space-5); } | |
| 1501 | + | .search-input-wrap { | |
| 1502 | + | display: flex; | |
| 1503 | + | gap: var(--space-2); | |
| 1504 | + | max-width: 480px; | |
| 1505 | + | } | |
| 1506 | + | .search-input { | |
| 1507 | + | flex: 1; | |
| 1508 | + | padding: var(--space-2) var(--space-3); | |
| 1509 | + | border: 1px solid var(--color-border); | |
| 1510 | + | border-radius: var(--radius-md); | |
| 1511 | + | background: var(--color-bg); | |
| 1512 | + | color: var(--color-text); | |
| 1513 | + | font-size: var(--text-sm); | |
| 1514 | + | } | |
| 1515 | + | .search-input:focus { | |
| 1516 | + | outline: 2px solid var(--color-accent); | |
| 1517 | + | outline-offset: -1px; | |
| 1518 | + | border-color: var(--color-accent); | |
| 1519 | + | } | |
| 1520 | + | ||
| 1521 | + | /* --- Issue close bar --- */ | |
| 1522 | + | .issue-close-bar { | |
| 1523 | + | display: flex; | |
| 1524 | + | justify-content: flex-end; | |
| 1525 | + | padding: var(--space-3) 0; | |
| 1526 | + | border-top: 1px solid var(--color-border-muted); | |
| 1527 | + | margin-top: var(--space-2); | |
| 1528 | + | } | |
| 1529 | + | ||
| 1530 | + | /* --- Auth divider --- */ | |
| 1531 | + | .auth-divider { | |
| 1532 | + | display: flex; | |
| 1533 | + | align-items: center; | |
| 1534 | + | gap: var(--space-3); | |
| 1535 | + | margin: var(--space-4) 0; | |
| 1536 | + | color: var(--color-text-muted); | |
| 1537 | + | font-size: var(--text-sm); | |
| 1538 | + | } | |
| 1539 | + | .auth-divider::before, | |
| 1540 | + | .auth-divider::after { | |
| 1541 | + | content: ''; | |
| 1542 | + | flex: 1; | |
| 1543 | + | height: 1px; | |
| 1544 | + | background: var(--color-border); | |
| 1545 | + | } | |
| 1546 | + | ||
| 1547 | + | /* --- Passkey UI in settings --- */ | |
| 1548 | + | .passkey-actions { | |
| 1549 | + | display: flex; | |
| 1550 | + | flex-direction: column; | |
| 1551 | + | gap: var(--space-3); | |
| 1552 | + | align-items: flex-start; | |
| 1553 | + | margin-top: var(--space-3); | |
| 1554 | + | } | |
| 1555 | + | .passkey-status { font-size: var(--text-sm); } | |
| 1556 | + | ||
| 1557 | + | /* --- Danger zone --- */ | |
| 1558 | + | .danger-zone { margin-top: var(--space-8); } | |
| 1559 | + | .danger-title { color: var(--color-danger); border-bottom-color: var(--color-danger); } | |
| 1560 | + | .danger-card { | |
| 1561 | + | border-color: var(--color-danger); | |
| 1562 | + | background: var(--color-danger-bg); | |
| 1563 | + | } | |
| 1564 | + | .danger-item { | |
| 1565 | + | display: flex; | |
| 1566 | + | align-items: center; | |
| 1567 | + | justify-content: space-between; | |
| 1568 | + | gap: var(--space-4); | |
| 1569 | + | flex-wrap: wrap; | |
| 1570 | + | } | |
| 1571 | + | .danger-item p { margin: var(--space-1) 0 0; } | |
| 1572 | + | .btn-danger { | |
| 1573 | + | background: var(--color-danger); | |
| 1574 | + | border-color: transparent; | |
| 1575 | + | color: #fff; | |
| 1576 | + | } | |
| 1577 | + | .btn-danger:hover { filter: brightness(0.9); } | |
| 1578 | + | ||
| 1579 | + | /* --- Release list items --- */ | |
| 1580 | + | .release-item-header { | |
| 1581 | + | display: flex; | |
| 1582 | + | gap: var(--space-3); | |
| 1583 | + | align-items: flex-start; | |
| 1584 | + | } | |
| 1585 | + | .release-item-main { flex: 1; min-width: 0; } | |
| 1586 | + | .release-item-title { | |
| 1587 | + | font-size: var(--text-lg); | |
| 1588 | + | font-weight: 700; | |
| 1589 | + | text-decoration: none; | |
| 1590 | + | color: var(--color-text); | |
| 1591 | + | display: block; | |
| 1592 | + | margin-bottom: var(--space-1); | |
| 1593 | + | } | |
| 1594 | + | .release-item-title:hover { color: var(--color-link); } | |
| 1595 | + | .release-item-meta { | |
| 1596 | + | display: flex; | |
| 1597 | + | flex-wrap: wrap; | |
| 1598 | + | gap: var(--space-3); | |
| 1599 | + | align-items: center; | |
| 1600 | + | font-size: var(--text-xs); | |
| 1601 | + | color: var(--color-text-muted); | |
| 1602 | + | } | |
| 1603 | + | .release-item-date { | |
| 1604 | + | display: flex; | |
| 1605 | + | flex-direction: column; | |
| 1606 | + | align-items: flex-end; | |
| 1607 | + | gap: var(--space-2); | |
| 1608 | + | font-size: var(--text-xs); | |
| 1609 | + | color: var(--color-text-muted); | |
| 1610 | + | white-space: nowrap; | |
| 1611 | + | flex-shrink: 0; | |
| 1612 | + | } | |
| 1613 | + | ||
| 1614 | + | /* --- Release notes preview in list --- */ | |
| 1615 | + | .release-notes-section { margin-top: var(--space-3); } | |
| 1616 | + | .release-notes-label { | |
| 1617 | + | font-size: var(--text-xs); | |
| 1618 | + | font-weight: 600; | |
| 1619 | + | color: var(--color-text-muted); | |
| 1620 | + | text-transform: uppercase; | |
| 1621 | + | letter-spacing: 0.05em; | |
| 1622 | + | margin-bottom: var(--space-2); | |
| 1623 | + | } | |
| 1624 | + | .notes-expand > summary { list-style: none; cursor: pointer; outline: none; } | |
| 1625 | + | .notes-expand > summary::-webkit-details-marker { display: none; } | |
| 1626 | + | .notes-preview { | |
| 1627 | + | max-height: calc(5 * 1.6em); | |
| 1628 | + | overflow: hidden; | |
| 1629 | + | position: relative; | |
| 1630 | + | white-space: pre-wrap; | |
| 1631 | + | font-size: var(--text-sm); | |
| 1632 | + | color: var(--color-text); | |
| 1633 | + | line-height: 1.6; | |
| 1634 | + | word-break: break-word; | |
| 1635 | + | margin-bottom: var(--space-1); | |
| 1636 | + | } | |
| 1637 | + | .notes-preview::after { | |
| 1638 | + | content: ""; | |
| 1639 | + | position: absolute; | |
| 1640 | + | bottom: 0; | |
| 1641 | + | left: 0; | |
| 1642 | + | right: 0; | |
| 1643 | + | height: 2.5em; | |
| 1644 | + | background: linear-gradient(transparent, var(--color-bg)); | |
| 1645 | + | pointer-events: none; | |
| 1646 | + | } | |
| 1647 | + | .notes-expand[open] .notes-preview { display: none; } | |
| 1648 | + | .notes-expand[open] { display: flex; flex-direction: column; } | |
| 1649 | + | .notes-expand[open] > summary { order: 1; } | |
| 1650 | + | .notes-expand[open] > .notes-full { order: 0; } | |
| 1651 | + | .notes-toggle-label { | |
| 1652 | + | display: inline-block; | |
| 1653 | + | font-size: var(--text-xs); | |
| 1654 | + | color: var(--color-link); | |
| 1655 | + | margin-top: var(--space-1); | |
| 1656 | + | } | |
| 1657 | + | .notes-toggle-label:hover { text-decoration: underline; } | |
| 1658 | + | .notes-toggle-label::before { content: "Show more ↓"; } | |
| 1659 | + | .notes-expand[open] .notes-toggle-label::before { content: "Show less ↑"; } | |
| 1660 | + | .notes-full { padding-top: var(--space-3); padding-bottom: var(--space-1); } | |
| 1661 | + | ||
| 1662 | + | /* --- Release detail --- */ | |
| 1663 | + | .release-detail { padding: var(--space-6) 0; } | |
| 1664 | + | .release-header { margin-bottom: var(--space-6); } | |
| 1665 | + | .release-notes { margin-bottom: var(--space-6); } | |
| 1666 | + | .release-assets { margin-bottom: var(--space-6); } | |
| 1667 | + | .release-assets-heading { | |
| 1668 | + | font-size: var(--text-base); | |
| 1669 | + | font-weight: 600; | |
| 1670 | + | margin-bottom: var(--space-3); | |
| 1671 | + | } | |
| 1672 | + | .asset-list { | |
| 1673 | + | border: 1px solid var(--color-border); | |
| 1674 | + | border-radius: var(--radius-lg); | |
| 1675 | + | overflow: hidden; | |
| 1676 | + | } | |
| 1677 | + | .asset-item { | |
| 1678 | + | display: flex; | |
| 1679 | + | align-items: center; | |
| 1680 | + | gap: var(--space-4); | |
| 1681 | + | padding: var(--space-3) var(--space-4); | |
| 1682 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 1683 | + | font-size: var(--text-sm); | |
| 1684 | + | } | |
| 1685 | + | .asset-item:last-child { border-bottom: none; } | |
| 1686 | + | .asset-name { | |
| 1687 | + | flex: 1; | |
| 1688 | + | color: var(--color-link); | |
| 1689 | + | text-decoration: none; | |
| 1690 | + | font-family: var(--font-mono); | |
| 1691 | + | font-size: var(--text-xs); | |
| 1692 | + | } | |
| 1693 | + | .asset-name:hover { text-decoration: underline; } | |
| 1694 | + | .asset-name-pending { | |
| 1695 | + | color: var(--color-text-muted); | |
| 1696 | + | cursor: default; | |
| 1697 | + | } | |
| 1698 | + | .asset-item-pending { opacity: 0.6; } | |
| 1699 | + | .asset-size, .asset-meta { | |
| 1700 | + | color: var(--color-text-muted); | |
| 1701 | + | font-size: var(--text-xs); | |
| 1702 | + | white-space: nowrap; | |
| 1703 | + | } | |
| 1704 | + | .release-back { margin-top: var(--space-4); display: flex; gap: var(--space-3); align-items: center; justify-content: space-between; } | |
| 1705 | + | } | |
| 1706 | + | ||
| 1707 | + | /* ============================================================ | |
| 1708 | + | @layer utilities | |
| 1709 | + | ============================================================ */ | |
| 1710 | + | @layer utilities { | |
| 1711 | + | .text-muted { color: var(--color-text-muted); } | |
| 1712 | + | .text-sm { font-size: var(--text-sm); } | |
| 1713 | + | .text-xs { font-size: var(--text-xs); } | |
| 1714 | + | .font-mono { font-family: var(--font-mono); } | |
| 1715 | + | .mt-1 { margin-top: var(--space-1); } | |
| 1716 | + | .mt-2 { margin-top: var(--space-2); } | |
| 1717 | + | .mt-4 { margin-top: var(--space-4); } | |
| 1718 | + | .mt-6 { margin-top: var(--space-6); } | |
| 1719 | + | .mb-4 { margin-bottom: var(--space-4); } | |
| 1720 | + | .flex { display: flex; } | |
| 1721 | + | .gap-2 { gap: var(--space-2); } | |
| 1722 | + | .gap-4 { gap: var(--space-4); } | |
| 1723 | + | .items-center { align-items: center; } | |
| 1724 | + | .justify-between { justify-content: space-between; } | |
| 1725 | + | .hidden { display: none; } | |
| 1726 | + | } | |
| 1727 | + | ||
| 1728 | + | /* ============================================================ | |
| 1729 | + | Pagination | |
| 1730 | + | ============================================================ */ | |
| 1731 | + | .commit-cursor-nav { | |
| 1732 | + | display: flex; | |
| 1733 | + | justify-content: space-between; | |
| 1734 | + | border-top: 1px solid var(--color-border); | |
| 1735 | + | margin-top: var(--space-2); | |
| 1736 | + | padding: var(--space-4) 0 var(--space-2); | |
| 1737 | + | } | |
| 1738 | + | ||
| 1739 | + | .commit-cursor-prev { flex: 1; } | |
| 1740 | + | .commit-cursor-next { flex: 1; text-align: right; } | |
| 1741 | + | ||
| 1742 | + | .pagination { | |
| 1743 | + | display: grid; | |
| 1744 | + | grid-template-columns: 1fr auto 1fr; | |
| 1745 | + | align-items: center; | |
| 1746 | + | border-top: 1px solid var(--color-border); | |
| 1747 | + | margin-top: var(--space-2); | |
| 1748 | + | padding: var(--space-4) 0 var(--space-2); | |
| 1749 | + | } | |
| 1750 | + | ||
| 1751 | + | .pagination-prev { justify-self: start; } | |
| 1752 | + | .pagination-next { justify-self: end; } | |
| 1753 | + | ||
| 1754 | + | .pagination-info { | |
| 1755 | + | justify-self: center; | |
| 1756 | + | font-size: var(--text-sm); | |
| 1757 | + | color: var(--color-text-muted); | |
| 1758 | + | } | |
| 1759 | + | ||
| 1760 | + | .pagination-btn { | |
| 1761 | + | display: inline-flex; | |
| 1762 | + | align-items: center; | |
| 1763 | + | gap: var(--space-1); | |
| 1764 | + | padding: var(--space-1) var(--space-3); | |
| 1765 | + | border: 1px solid var(--color-border); | |
| 1766 | + | border-radius: var(--radius-md); | |
| 1767 | + | font-size: var(--text-sm); | |
| 1768 | + | font-weight: 500; | |
| 1769 | + | text-decoration: none; | |
| 1770 | + | color: var(--color-text); | |
| 1771 | + | background: var(--color-bg-subtle); | |
| 1772 | + | transition: background 0.1s, border-color 0.1s; | |
| 1773 | + | white-space: nowrap; | |
| 1774 | + | } | |
| 1775 | + | ||
| 1776 | + | .pagination-btn:hover { | |
| 1777 | + | background: var(--color-bg-inset); | |
| 1778 | + | border-color: var(--color-border); | |
| 1779 | + | color: var(--color-text); | |
| 1780 | + | text-decoration: none; | |
| 1781 | + | } | |
| 1782 | + | ||
| 1783 | + | /* Settings */ | |
| 1784 | + | .theme-options { display: flex; gap: var(--space-4); margin-bottom: var(--space-4); flex-wrap: wrap; } | |
| 1785 | + | .theme-option { display: flex; align-items: center; gap: var(--space-2); cursor: pointer; font-size: var(--text-sm); } | |
| 1786 | + | .settings-form { display: flex; flex-direction: column; gap: var(--space-4); } | |
| 1787 | + | .passkey-list { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); } | |
| 1788 | + | .passkey-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border-muted); font-size: var(--text-sm); } | |
| 1789 | + | .passkey-item:last-child { border-bottom: none; } | |
| 1790 | + | .passkey-date { flex: 1; color: var(--color-text-muted); } | |
| 1791 | + | .ssh-key-info { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; } | |
| 1792 | + | .ssh-key-name { font-weight: 500; } | |
| 1793 | + | .ssh-key-fingerprint { font-family: var(--font-mono); font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1794 | + | .form-textarea { resize: vertical; min-height: 4.5rem; font-family: var(--font-mono); font-size: var(--text-sm); } | |
| 1795 | + | /* Clone popup */ | |
| 1796 | + | .clone-popup-details { position: relative; display: inline-block; } | |
| 1797 | + | .clone-popup-details > summary { list-style: none; } | |
| 1798 | + | .clone-popup-details > summary::-webkit-details-marker { display: none; } | |
| 1799 | + | .clone-popup { position: absolute; right: 1rem; top: calc(100% + var(--space-2)); background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-3); min-width: 22rem; z-index: 100; box-shadow: 0 4px 20px rgba(0,0,0,0.15); display: flex; flex-direction: column; gap: var(--space-2); } | |
| 1800 | + | .clone-url-row { display: flex; align-items: center; gap: var(--space-2); } | |
| 1801 | + | .clone-url-label { font-size: var(--text-xs); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; min-width: 2.75rem; } | |
| 1802 | + | .clone-url-input { flex: 1; font-family: var(--font-mono); font-size: var(--text-sm); background: var(--color-bg-subtle); border: 1px solid var(--color-border-muted); border-radius: var(--radius-md); padding: var(--space-1) var(--space-2); color: var(--color-text); cursor: text; min-width: 0; } | |
| 1803 | + | .user-list { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-6); } | |
| 1804 | + | .user-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border-muted); font-size: var(--text-sm); } | |
| 1805 | + | .user-item:last-child { border-bottom: none; } | |
| 1806 | + | .user-name { font-weight: 500; flex: 1; } | |
| 1807 | + | .user-date { color: var(--color-text-muted); font-size: var(--text-xs); } | |
Apublic/assets/theme.js
| @@ -0,0 +1 @@ | |||
|---|---|---|---|
| 1 | + | (function(){try{var m=document.cookie.match(/(?:^|;)\s*theme=([^;]+)/);var t=m&&m[1];if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}})() | |
Asrc/config.ts
| @@ -0,0 +1,20 @@ | |||
|---|---|---|---|
| 1 | + | import path from "node:path"; | |
| 2 | + | ||
| 3 | + | //env configs | |
| 4 | + | export const OWNER_DISPLAY_NAME = process.env.OWNER_DISPLAY_NAME ?? "Admin"; | |
| 5 | + | export const INLINE_MAX_BYTES = | |
| 6 | + | parseInt(process.env.INLINE_MAX_BYTES ?? "", 10) || 524288; | |
| 7 | + | export const MAX_UPLOAD_BYTES = | |
| 8 | + | parseInt(process.env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024; | |
| 9 | + | export const MAX_USER_UPLOAD_BYTES = | |
| 10 | + | parseInt(process.env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024; | |
| 11 | + | export const TRUSTED_PROXY = !!process.env.TRUSTED_PROXY; | |
| 12 | + | export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED; | |
| 13 | + | export const SSH_DISABLED = !!process.env.SSH_DISABLED; | |
| 14 | + | export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000; | |
| 15 | + | export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222; | |
| 16 | + | export const REGISTRATION_DISABLED = !!process.env.REGISTRATION_DISABLED; | |
| 17 | + | export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`; | |
| 18 | + | export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data"); | |
| 19 | + | export const HIGHLIGHT_WORKERS = | |
| 20 | + | parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4; | |
Asrc/constants.ts
| @@ -0,0 +1,47 @@ | |||
|---|---|---|---|
| 1 | + | import path from "node:path"; | |
| 2 | + | import { DATA_DIR, OWNER_DISPLAY_NAME } from "./config.ts"; | |
| 3 | + | ||
| 4 | + | // Auth / identity | |
| 5 | + | export const WEBAUTHN_RP_NAME = `${OWNER_DISPLAY_NAME}'s Hearthforge`; | |
| 6 | + | export const ADMIN_USERNAME = "admin"; | |
| 7 | + | export const VALID_USERNAME_RE = /^[a-zA-Z0-9_-]+$/; | |
| 8 | + | export const VALID_REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/; | |
| 9 | + | export const ALLOWED_REACTIONS = new Set([ | |
| 10 | + | "👍", | |
| 11 | + | "👎", | |
| 12 | + | "❤️", | |
| 13 | + | "🎉", | |
| 14 | + | "😕", | |
| 15 | + | "👀", | |
| 16 | + | "🚀", | |
| 17 | + | ]); | |
| 18 | + | export const VALID_KEY_TYPES = new Set([ | |
| 19 | + | "ssh-rsa", | |
| 20 | + | "ssh-ed25519", | |
| 21 | + | "ecdsa-sha2-nistp256", | |
| 22 | + | "ecdsa-sha2-nistp384", | |
| 23 | + | "ecdsa-sha2-nistp521", | |
| 24 | + | "sk-ssh-ed25519@openssh.com", | |
| 25 | + | "sk-ecdsa-sha2-nistp256@openssh.com", | |
| 26 | + | ]); | |
| 27 | + | export const CHALLENGE_TTL_MS = 5 * 60 * 1000; | |
| 28 | + | ||
| 29 | + | // Pagination | |
| 30 | + | export const REPOS_PER_PAGE = 20; | |
| 31 | + | export const COMMITS_PER_PAGE = 30; | |
| 32 | + | export const ISSUES_PER_PAGE = 20; | |
| 33 | + | export const PATCHES_PER_PAGE = 20; | |
| 34 | + | export const RELEASES_PER_PAGE = 20; | |
| 35 | + | ||
| 36 | + | // Cache sizes | |
| 37 | + | export const MAX_MD_CACHE = 50; | |
| 38 | + | export const MAX_FILE_CACHE = 500; | |
| 39 | + | export const MAX_DIFF_CACHE = 500; | |
| 40 | + | export const MAX_PATCH_CACHE = 100; | |
| 41 | + | ||
| 42 | + | // Paths | |
| 43 | + | export const DB_PATH = path.join(DATA_DIR, "hearthforge.db"); | |
| 44 | + | export const REPOS_DIR = path.join(DATA_DIR, "repos"); | |
| 45 | + | export const AVATARS_DIR = path.join(DATA_DIR, "avatars"); | |
| 46 | + | export const RELEASES_DIR = path.join(DATA_DIR, "releases"); | |
| 47 | + | export const SSH_HOST_KEY_PATH = path.join(DATA_DIR, "ssh_host_key"); | |
Asrc/db/index.ts
| @@ -0,0 +1,180 @@ | |||
|---|---|---|---|
| 1 | + | import { Database as BunDatabase } from "bun:sqlite"; | |
| 2 | + | import { type Generated, Kysely, type Selectable } from "kysely"; | |
| 3 | + | import { BunSqliteDialect } from "kysely-bun-sqlite"; | |
| 4 | + | ||
| 5 | + | import { DB_PATH } from "../constants.ts"; | |
| 6 | + | ||
| 7 | + | interface UserTable { | |
| 8 | + | id: Generated<number>; | |
| 9 | + | username: string; | |
| 10 | + | password_hash: string | null; | |
| 11 | + | created_at: string; | |
| 12 | + | avatar_version: Generated<number>; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | interface PasskeyTable { | |
| 16 | + | id: Generated<number>; | |
| 17 | + | user_id: number; | |
| 18 | + | credential_id: string; | |
| 19 | + | public_key: string; | |
| 20 | + | counter: number; | |
| 21 | + | created_at: string; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | interface SessionTable { | |
| 25 | + | id: string; | |
| 26 | + | user_id: number; | |
| 27 | + | expires_at: string; | |
| 28 | + | created_at: string; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | interface RepositoryTable { | |
| 32 | + | id: Generated<number>; | |
| 33 | + | name: string; | |
| 34 | + | description: string | null; | |
| 35 | + | is_private: number; | |
| 36 | + | default_branch: string; | |
| 37 | + | created_at: string; | |
| 38 | + | issue_seq: Generated<number>; | |
| 39 | + | patch_seq: Generated<number>; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | interface IssueTable { | |
| 43 | + | id: Generated<number>; | |
| 44 | + | repo_id: number; | |
| 45 | + | author_id: number | null; | |
| 46 | + | number: number; | |
| 47 | + | title: string; | |
| 48 | + | body: string; | |
| 49 | + | status: string; | |
| 50 | + | created_at: string; | |
| 51 | + | updated_at: string; | |
| 52 | + | edited_at: string | null; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | interface IssueCommentTable { | |
| 56 | + | id: Generated<number>; | |
| 57 | + | issue_id: number; | |
| 58 | + | author_id: number | null; | |
| 59 | + | body: string; | |
| 60 | + | created_at: string; | |
| 61 | + | edited_at: string | null; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | interface IssueReactionTable { | |
| 65 | + | id: Generated<number>; | |
| 66 | + | issue_id: number; | |
| 67 | + | comment_id: number | null; | |
| 68 | + | user_id: number; | |
| 69 | + | emoji: string; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | interface PatchTable { | |
| 73 | + | id: Generated<number>; | |
| 74 | + | repo_id: number; | |
| 75 | + | author_id: number | null; | |
| 76 | + | number: number; | |
| 77 | + | title: string; | |
| 78 | + | description: string; | |
| 79 | + | patch_content: string; | |
| 80 | + | status: string; | |
| 81 | + | created_at: string; | |
| 82 | + | updated_at: string; | |
| 83 | + | edited_at: string | null; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | interface PatchCommentTable { | |
| 87 | + | id: Generated<number>; | |
| 88 | + | patch_id: number; | |
| 89 | + | author_id: number | null; | |
| 90 | + | body: string; | |
| 91 | + | created_at: string; | |
| 92 | + | edited_at: string | null; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | interface PatchReactionTable { | |
| 96 | + | id: Generated<number>; | |
| 97 | + | patch_id: number; | |
| 98 | + | comment_id: number | null; | |
| 99 | + | user_id: number; | |
| 100 | + | emoji: string; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | interface SshKeyTable { | |
| 104 | + | id: Generated<number>; | |
| 105 | + | user_id: number; | |
| 106 | + | name: string; | |
| 107 | + | public_key: string; | |
| 108 | + | fingerprint: string; | |
| 109 | + | created_at: string; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | interface ReleaseTable { | |
| 113 | + | id: Generated<number>; | |
| 114 | + | repo_id: number; | |
| 115 | + | tag_name: string; | |
| 116 | + | name: string | null; | |
| 117 | + | notes: string | null; | |
| 118 | + | commit_hash: string; | |
| 119 | + | include_source_code: number; | |
| 120 | + | created_at: string; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | interface ReleaseAssetTable { | |
| 124 | + | id: Generated<number>; | |
| 125 | + | release_id: number; | |
| 126 | + | filename: string; | |
| 127 | + | size: number; | |
| 128 | + | content_type: string; | |
| 129 | + | created_at: string; | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | export interface Database { | |
| 133 | + | users: UserTable; | |
| 134 | + | passkeys: PasskeyTable; | |
| 135 | + | sessions: SessionTable; | |
| 136 | + | repositories: RepositoryTable; | |
| 137 | + | issues: IssueTable; | |
| 138 | + | issue_comments: IssueCommentTable; | |
| 139 | + | issue_reactions: IssueReactionTable; | |
| 140 | + | patches: PatchTable; | |
| 141 | + | patch_comments: PatchCommentTable; | |
| 142 | + | patch_reactions: PatchReactionTable; | |
| 143 | + | ssh_keys: SshKeyTable; | |
| 144 | + | releases: ReleaseTable; | |
| 145 | + | release_assets: ReleaseAssetTable; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Selectable row types (id is plain number, as returned by queries) | |
| 149 | + | export type UserRow = Selectable<UserTable>; | |
| 150 | + | export type PasskeyRow = Selectable<PasskeyTable>; | |
| 151 | + | export type SessionRow = Selectable<SessionTable>; | |
| 152 | + | export type RepositoryRow = Selectable<RepositoryTable>; | |
| 153 | + | export type IssueRow = Selectable<IssueTable>; | |
| 154 | + | export type IssueCommentRow = Selectable<IssueCommentTable>; | |
| 155 | + | export type IssueReactionRow = Selectable<IssueReactionTable>; | |
| 156 | + | export type PatchRow = Selectable<PatchTable>; | |
| 157 | + | export type PatchCommentRow = Selectable<PatchCommentTable>; | |
| 158 | + | export type PatchReactionRow = Selectable<PatchReactionTable>; | |
| 159 | + | export type SshKeyRow = Selectable<SshKeyTable>; | |
| 160 | + | export type ReleaseRow = Selectable<ReleaseTable>; | |
| 161 | + | export type ReleaseAssetRow = Selectable<ReleaseAssetTable>; | |
| 162 | + | ||
| 163 | + | const sqlite = new BunDatabase(DB_PATH); | |
| 164 | + | sqlite.run("PRAGMA journal_mode=WAL"); | |
| 165 | + | sqlite.run("PRAGMA foreign_keys=ON"); | |
| 166 | + | ||
| 167 | + | export const db = new Kysely<Database>({ | |
| 168 | + | dialect: new BunSqliteDialect({ database: sqlite }), | |
| 169 | + | }); | |
| 170 | + | ||
| 171 | + | export async function getRepo(name: string, isAdmin: boolean) { | |
| 172 | + | const repo = await db | |
| 173 | + | .selectFrom("repositories") | |
| 174 | + | .selectAll() | |
| 175 | + | .where("name", "=", name) | |
| 176 | + | .executeTakeFirst(); | |
| 177 | + | if (!repo) return null; | |
| 178 | + | if (repo.is_private && !isAdmin) return null; | |
| 179 | + | return repo; | |
| 180 | + | } | |
Asrc/db/init.ts
| @@ -0,0 +1,51 @@ | |||
|---|---|---|---|
| 1 | + | import { Database } from "bun:sqlite"; | |
| 2 | + | import { mkdirSync, readFileSync } from "node:fs"; | |
| 3 | + | import path from "node:path"; | |
| 4 | + | import * as argon2 from "argon2"; | |
| 5 | + | import { DATA_DIR } from "../config.ts"; | |
| 6 | + | import { | |
| 7 | + | ADMIN_USERNAME, | |
| 8 | + | AVATARS_DIR, | |
| 9 | + | DB_PATH, | |
| 10 | + | REPOS_DIR, | |
| 11 | + | } from "../constants.ts"; | |
| 12 | + | ||
| 13 | + | // Ensure data directories exist | |
| 14 | + | mkdirSync(DATA_DIR, { recursive: true }); | |
| 15 | + | mkdirSync(REPOS_DIR, { recursive: true }); | |
| 16 | + | mkdirSync(AVATARS_DIR, { recursive: true }); | |
| 17 | + | ||
| 18 | + | const db = new Database(DB_PATH); | |
| 19 | + | db.run("PRAGMA journal_mode=WAL"); | |
| 20 | + | db.run("PRAGMA foreign_keys=ON"); | |
| 21 | + | ||
| 22 | + | // Run schema | |
| 23 | + | const schema = readFileSync(path.join(import.meta.dir, "schema.sql"), "utf-8"); | |
| 24 | + | db.run(schema); | |
| 25 | + | ||
| 26 | + | // Seed admin account if not present | |
| 27 | + | const existing = db | |
| 28 | + | .query("SELECT id FROM users WHERE username = ?") | |
| 29 | + | .get(ADMIN_USERNAME); | |
| 30 | + | if (!existing) { | |
| 31 | + | const password = process.env.ADMIN_PASSWORD ?? "changeme"; | |
| 32 | + | const hash = await argon2.hash(password); | |
| 33 | + | const now = new Date().toISOString(); | |
| 34 | + | db.run( | |
| 35 | + | "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)", | |
| 36 | + | [ADMIN_USERNAME, hash, now], | |
| 37 | + | ); | |
| 38 | + | console.log( | |
| 39 | + | `Created admin account (username: ${ADMIN_USERNAME}, password: ${password})`, | |
| 40 | + | ); | |
| 41 | + | if (password === "changeme") { | |
| 42 | + | console.warn( | |
| 43 | + | "WARNING: Using default admin password. Set ADMIN_PASSWORD env var before running db:init.", | |
| 44 | + | ); | |
| 45 | + | } | |
| 46 | + | } else { | |
| 47 | + | console.log("Admin account already exists."); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | console.log("Database initialized at", DB_PATH); | |
| 51 | + | db.close(); | |
Asrc/db/schema.sql
| @@ -0,0 +1,129 @@ | |||
|---|---|---|---|
| 1 | + | CREATE TABLE IF NOT EXISTS users ( | |
| 2 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 3 | + | username TEXT UNIQUE NOT NULL, | |
| 4 | + | password_hash TEXT, | |
| 5 | + | created_at TEXT NOT NULL, | |
| 6 | + | avatar_version INTEGER NOT NULL DEFAULT 1 | |
| 7 | + | ); | |
| 8 | + | ||
| 9 | + | CREATE TABLE IF NOT EXISTS passkeys ( | |
| 10 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 11 | + | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 12 | + | credential_id TEXT UNIQUE NOT NULL, | |
| 13 | + | public_key TEXT NOT NULL, | |
| 14 | + | counter INTEGER NOT NULL DEFAULT 0, | |
| 15 | + | created_at TEXT NOT NULL | |
| 16 | + | ); | |
| 17 | + | ||
| 18 | + | CREATE TABLE IF NOT EXISTS sessions ( | |
| 19 | + | id TEXT PRIMARY KEY, | |
| 20 | + | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 21 | + | expires_at TEXT NOT NULL, | |
| 22 | + | created_at TEXT NOT NULL | |
| 23 | + | ); | |
| 24 | + | ||
| 25 | + | CREATE TABLE IF NOT EXISTS repositories ( | |
| 26 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 27 | + | name TEXT UNIQUE NOT NULL, | |
| 28 | + | description TEXT, | |
| 29 | + | is_private INTEGER NOT NULL DEFAULT 0, | |
| 30 | + | default_branch TEXT NOT NULL DEFAULT 'main', | |
| 31 | + | created_at TEXT NOT NULL, | |
| 32 | + | issue_seq INTEGER NOT NULL DEFAULT 0, | |
| 33 | + | patch_seq INTEGER NOT NULL DEFAULT 0 | |
| 34 | + | ); | |
| 35 | + | ||
| 36 | + | CREATE TABLE IF NOT EXISTS issues ( | |
| 37 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 38 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 39 | + | author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | |
| 40 | + | number INTEGER NOT NULL, | |
| 41 | + | title TEXT NOT NULL, | |
| 42 | + | body TEXT NOT NULL DEFAULT '', | |
| 43 | + | status TEXT NOT NULL DEFAULT 'open', | |
| 44 | + | created_at TEXT NOT NULL, | |
| 45 | + | updated_at TEXT NOT NULL, | |
| 46 | + | edited_at TEXT, | |
| 47 | + | UNIQUE(repo_id, number) | |
| 48 | + | ); | |
| 49 | + | ||
| 50 | + | CREATE TABLE IF NOT EXISTS issue_comments ( | |
| 51 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 52 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 53 | + | author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | |
| 54 | + | body TEXT NOT NULL, | |
| 55 | + | created_at TEXT NOT NULL, | |
| 56 | + | edited_at TEXT | |
| 57 | + | ); | |
| 58 | + | ||
| 59 | + | CREATE TABLE IF NOT EXISTS issue_reactions ( | |
| 60 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 61 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 62 | + | comment_id INTEGER REFERENCES issue_comments(id) ON DELETE CASCADE, | |
| 63 | + | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 64 | + | emoji TEXT NOT NULL, | |
| 65 | + | UNIQUE(issue_id, comment_id, user_id) | |
| 66 | + | ); | |
| 67 | + | ||
| 68 | + | CREATE TABLE IF NOT EXISTS patches ( | |
| 69 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 70 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 71 | + | author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | |
| 72 | + | number INTEGER NOT NULL, | |
| 73 | + | title TEXT NOT NULL, | |
| 74 | + | description TEXT NOT NULL DEFAULT '', | |
| 75 | + | patch_content TEXT NOT NULL, | |
| 76 | + | status TEXT NOT NULL DEFAULT 'open', | |
| 77 | + | created_at TEXT NOT NULL, | |
| 78 | + | updated_at TEXT NOT NULL, | |
| 79 | + | edited_at TEXT, | |
| 80 | + | UNIQUE(repo_id, number) | |
| 81 | + | ); | |
| 82 | + | ||
| 83 | + | CREATE TABLE IF NOT EXISTS patch_comments ( | |
| 84 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 85 | + | patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, | |
| 86 | + | author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, | |
| 87 | + | body TEXT NOT NULL, | |
| 88 | + | created_at TEXT NOT NULL, | |
| 89 | + | edited_at TEXT | |
| 90 | + | ); | |
| 91 | + | ||
| 92 | + | CREATE TABLE IF NOT EXISTS patch_reactions ( | |
| 93 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 94 | + | patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, | |
| 95 | + | comment_id INTEGER REFERENCES patch_comments(id) ON DELETE CASCADE, | |
| 96 | + | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 97 | + | emoji TEXT NOT NULL, | |
| 98 | + | UNIQUE(patch_id, comment_id, user_id) | |
| 99 | + | ); | |
| 100 | + | ||
| 101 | + | CREATE TABLE IF NOT EXISTS ssh_keys ( | |
| 102 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 103 | + | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 104 | + | name TEXT NOT NULL, | |
| 105 | + | public_key TEXT NOT NULL, | |
| 106 | + | fingerprint TEXT NOT NULL UNIQUE, | |
| 107 | + | created_at TEXT NOT NULL | |
| 108 | + | ); | |
| 109 | + | ||
| 110 | + | CREATE TABLE IF NOT EXISTS releases ( | |
| 111 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 112 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 113 | + | tag_name TEXT NOT NULL, | |
| 114 | + | name TEXT, | |
| 115 | + | notes TEXT, | |
| 116 | + | commit_hash TEXT NOT NULL, | |
| 117 | + | include_source_code INTEGER NOT NULL DEFAULT 0, | |
| 118 | + | created_at TEXT NOT NULL, | |
| 119 | + | UNIQUE(repo_id, tag_name) | |
| 120 | + | ); | |
| 121 | + | ||
| 122 | + | CREATE TABLE IF NOT EXISTS release_assets ( | |
| 123 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 124 | + | release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, | |
| 125 | + | filename TEXT NOT NULL, | |
| 126 | + | size INTEGER NOT NULL, | |
| 127 | + | content_type TEXT NOT NULL, | |
| 128 | + | created_at TEXT NOT NULL | |
| 129 | + | ); | |
Asrc/db/seed.ts
| @@ -0,0 +1,256 @@ | |||
|---|---|---|---|
| 1 | + | /** | |
| 2 | + | * Seed script — populates the live database with enough data to test pagination. | |
| 3 | + | * Run with: bun run db:seed | |
| 4 | + | * | |
| 5 | + | * Safe to run multiple times — skips existing items. | |
| 6 | + | */ | |
| 7 | + | import { Database } from "bun:sqlite"; | |
| 8 | + | import { mkdirSync } from "node:fs"; | |
| 9 | + | import path from "node:path"; | |
| 10 | + | import * as argon2 from "argon2"; | |
| 11 | + | import { ADMIN_USERNAME, DB_PATH, REPOS_DIR } from "../constants.ts"; | |
| 12 | + | ||
| 13 | + | const db = new Database(DB_PATH); | |
| 14 | + | db.run("PRAGMA journal_mode=WAL"); | |
| 15 | + | db.run("PRAGMA foreign_keys=ON"); | |
| 16 | + | ||
| 17 | + | // ── Resolve admin user ──────────────────────────────────────────────────────── | |
| 18 | + | const adminRow = db | |
| 19 | + | .query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?") | |
| 20 | + | .get(ADMIN_USERNAME); | |
| 21 | + | if (!adminRow) { | |
| 22 | + | console.error("Admin user not found. Run `bun run db:init` first."); | |
| 23 | + | process.exit(1); | |
| 24 | + | } | |
| 25 | + | const adminId = adminRow.id; | |
| 26 | + | ||
| 27 | + | // Ensure a second user "alice" exists for variety | |
| 28 | + | let aliceId: number; | |
| 29 | + | const aliceRow = db | |
| 30 | + | .query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?") | |
| 31 | + | .get("alice"); | |
| 32 | + | if (aliceRow) { | |
| 33 | + | aliceId = aliceRow.id; | |
| 34 | + | } else { | |
| 35 | + | const hash = await argon2.hash("password123"); | |
| 36 | + | const now = new Date().toISOString(); | |
| 37 | + | db.run( | |
| 38 | + | "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)", | |
| 39 | + | ["alice", hash, now], | |
| 40 | + | ); | |
| 41 | + | aliceId = db | |
| 42 | + | .query<{ id: number }, [string]>( | |
| 43 | + | "SELECT id FROM users WHERE username = ?", | |
| 44 | + | ) | |
| 45 | + | .get("alice")!.id; | |
| 46 | + | console.log("Created user alice (password: password123)"); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // ── Helper ──────────────────────────────────────────────────────────────────── | |
| 50 | + | function getOrCreateRepo(name: string, description: string): number { | |
| 51 | + | const existing = db | |
| 52 | + | .query<{ id: number }, [string]>( | |
| 53 | + | "SELECT id FROM repositories WHERE name = ?", | |
| 54 | + | ) | |
| 55 | + | .get(name); | |
| 56 | + | if (existing) return existing.id; | |
| 57 | + | ||
| 58 | + | const now = new Date().toISOString(); | |
| 59 | + | db.run( | |
| 60 | + | "INSERT INTO repositories (name, description, is_private, default_branch, created_at) VALUES (?, ?, 0, 'main', ?)", | |
| 61 | + | [name, description, now], | |
| 62 | + | ); | |
| 63 | + | const repoPath = path.join(REPOS_DIR, `${name}.git`); | |
| 64 | + | mkdirSync(repoPath, { recursive: true }); | |
| 65 | + | // init bare repo (sync-ish via Bun.spawnSync) | |
| 66 | + | Bun.spawnSync(["git", "init", "--bare", "--initial-branch=main", repoPath]); | |
| 67 | + | console.log(`Created repo: ${name}`); | |
| 68 | + | return db | |
| 69 | + | .query<{ id: number }, [string]>( | |
| 70 | + | "SELECT id FROM repositories WHERE name = ?", | |
| 71 | + | ) | |
| 72 | + | .get(name)!.id; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // ── 25 extra repositories (for repo list pagination) ───────────────────────── | |
| 76 | + | const topics = [ | |
| 77 | + | "A web framework", | |
| 78 | + | "CLI toolkit", | |
| 79 | + | "Database driver", | |
| 80 | + | "Auth library", | |
| 81 | + | "Test runner", | |
| 82 | + | "Build system", | |
| 83 | + | "Linter plugin", | |
| 84 | + | "ORM layer", | |
| 85 | + | "Cache client", | |
| 86 | + | "Queue worker", | |
| 87 | + | "API gateway", | |
| 88 | + | "Graph engine", | |
| 89 | + | "ML utilities", | |
| 90 | + | "Crypto helpers", | |
| 91 | + | "File watcher", | |
| 92 | + | "Schema validator", | |
| 93 | + | "Logger library", | |
| 94 | + | "Rate limiter", | |
| 95 | + | "Metrics exporter", | |
| 96 | + | "Job scheduler", | |
| 97 | + | "Config manager", | |
| 98 | + | "Template engine", | |
| 99 | + | "Markdown parser", | |
| 100 | + | "Image resizer", | |
| 101 | + | "Email sender", | |
| 102 | + | ]; | |
| 103 | + | ||
| 104 | + | for (let i = 1; i <= 25; i++) { | |
| 105 | + | const n = String(i).padStart(2, "0"); | |
| 106 | + | getOrCreateRepo(`seed-repo-${n}`, topics[i - 1]!); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // ── "demo" repo — seed issues and patches ──────────────────────────────────── | |
| 110 | + | const demoRepoId = getOrCreateRepo( | |
| 111 | + | "demo", | |
| 112 | + | "Demo repository for pagination testing", | |
| 113 | + | ); | |
| 114 | + | ||
| 115 | + | // Seed 35 issues (mix of open and closed) | |
| 116 | + | const issueCount = | |
| 117 | + | db | |
| 118 | + | .query<{ n: number }, [number]>( | |
| 119 | + | "SELECT COUNT(*) as n FROM issues WHERE repo_id = ?", | |
| 120 | + | ) | |
| 121 | + | .get(demoRepoId)?.n ?? 0; | |
| 122 | + | if (issueCount < 35) { | |
| 123 | + | const start = issueCount + 1; | |
| 124 | + | const issueTitles = [ | |
| 125 | + | "Fix null pointer dereference in parser", | |
| 126 | + | "Add dark mode support", | |
| 127 | + | "Improve error messages", | |
| 128 | + | "Upgrade dependencies to latest", | |
| 129 | + | "Memory leak in connection pool", | |
| 130 | + | "Race condition in event loop", | |
| 131 | + | "Add pagination to commit log", | |
| 132 | + | "Slow query on large datasets", | |
| 133 | + | "Missing CORS headers", | |
| 134 | + | "Typo in README", | |
| 135 | + | "Refactor authentication middleware", | |
| 136 | + | "Support IPv6 addresses", | |
| 137 | + | "Add unit tests for utils", | |
| 138 | + | "Broken link in documentation", | |
| 139 | + | "Config file not loaded on Windows", | |
| 140 | + | "Infinite loop when input is empty", | |
| 141 | + | "Add rate limiting", | |
| 142 | + | "Log rotation broken", | |
| 143 | + | "Session cookie not cleared on logout", | |
| 144 | + | "Crash on malformed JSON input", | |
| 145 | + | "Add export to CSV feature", | |
| 146 | + | "Support custom themes", | |
| 147 | + | "API returns 500 on edge case", | |
| 148 | + | "Update license to MIT", | |
| 149 | + | "Improve startup time", | |
| 150 | + | "Handle timeout errors gracefully", | |
| 151 | + | "Add health check endpoint", | |
| 152 | + | "Support environment variables in config", | |
| 153 | + | "Fix XSS in search", | |
| 154 | + | "Stale cache after update", | |
| 155 | + | "Add OpenAPI spec", | |
| 156 | + | "Sort order wrong in list view", | |
| 157 | + | "Binary file detection false positive", | |
| 158 | + | "Column alignment off in table view", | |
| 159 | + | "Wrong timezone in timestamps", | |
| 160 | + | ]; | |
| 161 | + | ||
| 162 | + | for (let i = start; i <= 35; i++) { | |
| 163 | + | const titleIndex = (i - 1) % issueTitles.length; | |
| 164 | + | const status = i <= 25 ? "open" : "closed"; | |
| 165 | + | const authorId = i % 3 === 0 ? aliceId : adminId; | |
| 166 | + | const now = new Date(Date.now() - (35 - i) * 3600_000).toISOString(); | |
| 167 | + | db.run( | |
| 168 | + | "INSERT OR IGNORE INTO issues (repo_id, author_id, number, title, body, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 169 | + | [ | |
| 170 | + | demoRepoId, | |
| 171 | + | authorId, | |
| 172 | + | i, | |
| 173 | + | issueTitles[titleIndex]!, | |
| 174 | + | `Details for issue #${i}.`, | |
| 175 | + | status, | |
| 176 | + | now, | |
| 177 | + | now, | |
| 178 | + | ], | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | console.log(`Seeded issues up to #35 in demo repo`); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // Seed 25 patches | |
| 185 | + | const patchCount = | |
| 186 | + | db | |
| 187 | + | .query<{ n: number }, [number]>( | |
| 188 | + | "SELECT COUNT(*) as n FROM patches WHERE repo_id = ?", | |
| 189 | + | ) | |
| 190 | + | .get(demoRepoId)?.n ?? 0; | |
| 191 | + | if (patchCount < 25) { | |
| 192 | + | const start = patchCount + 1; | |
| 193 | + | const SAMPLE_PATCH = [ | |
| 194 | + | "diff --git a/placeholder.txt b/placeholder.txt", | |
| 195 | + | "new file mode 100644", | |
| 196 | + | "index 0000000..e69de29", | |
| 197 | + | "--- /dev/null", | |
| 198 | + | "+++ b/placeholder.txt", | |
| 199 | + | "@@ -0,0 +1 @@", | |
| 200 | + | "+placeholder", | |
| 201 | + | "", | |
| 202 | + | ].join("\n"); | |
| 203 | + | ||
| 204 | + | const patchTitles = [ | |
| 205 | + | "Fix null deref", | |
| 206 | + | "Add dark mode", | |
| 207 | + | "Improve errors", | |
| 208 | + | "Upgrade deps", | |
| 209 | + | "Fix memory leak", | |
| 210 | + | "Fix race condition", | |
| 211 | + | "Add pagination", | |
| 212 | + | "Optimise query", | |
| 213 | + | "Add CORS headers", | |
| 214 | + | "Fix typo", | |
| 215 | + | "Refactor auth", | |
| 216 | + | "Support IPv6", | |
| 217 | + | "Add unit tests", | |
| 218 | + | "Fix broken link", | |
| 219 | + | "Fix Windows config", | |
| 220 | + | "Fix infinite loop", | |
| 221 | + | "Add rate limiting", | |
| 222 | + | "Fix log rotation", | |
| 223 | + | "Fix logout cookie", | |
| 224 | + | "Handle bad JSON", | |
| 225 | + | "Add CSV export", | |
| 226 | + | "Custom themes", | |
| 227 | + | "Fix 500 error", | |
| 228 | + | "Update license", | |
| 229 | + | "Improve startup", | |
| 230 | + | ]; | |
| 231 | + | ||
| 232 | + | for (let i = start; i <= 25; i++) { | |
| 233 | + | const titleIndex = (i - 1) % patchTitles.length; | |
| 234 | + | const status = i <= 20 ? "open" : "closed"; | |
| 235 | + | const authorId = i % 3 === 0 ? aliceId : adminId; | |
| 236 | + | const now = new Date(Date.now() - (25 - i) * 3600_000).toISOString(); | |
| 237 | + | db.run( | |
| 238 | + | "INSERT OR IGNORE INTO patches (repo_id, author_id, number, title, description, patch_content, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 239 | + | [ | |
| 240 | + | demoRepoId, | |
| 241 | + | authorId, | |
| 242 | + | i, | |
| 243 | + | patchTitles[titleIndex]!, | |
| 244 | + | `Description for patch #${i}.`, | |
| 245 | + | SAMPLE_PATCH, | |
| 246 | + | status, | |
| 247 | + | now, | |
| 248 | + | now, | |
| 249 | + | ], | |
| 250 | + | ); | |
| 251 | + | } | |
| 252 | + | console.log(`Seeded patches up to #25 in demo repo`); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | db.close(); | |
| 256 | + | console.log("Seed complete."); | |
Asrc/index.tsx
| @@ -0,0 +1,44 @@ | |||
|---|---|---|---|
| 1 | + | import path from "node:path"; | |
| 2 | + | import { staticPlugin } from "@elysiajs/static"; | |
| 3 | + | import { Elysia } from "elysia"; | |
| 4 | + | import { MAX_UPLOAD_BYTES, PORT, SSH_DISABLED } from "./config.ts"; | |
| 5 | + | import { authRoutes } from "./routes/auth.tsx"; | |
| 6 | + | import { avatarRoutes } from "./routes/avatars.ts"; | |
| 7 | + | import { gitRoutes } from "./routes/git.ts"; | |
| 8 | + | import { issueRoutes } from "./routes/issues.tsx"; | |
| 9 | + | import { patchRoutes } from "./routes/patches.tsx"; | |
| 10 | + | import { releasesRoutes } from "./routes/releases.tsx"; | |
| 11 | + | import { repoRoutes } from "./routes/repos.tsx"; | |
| 12 | + | import { settingsRoutes } from "./routes/settings.tsx"; | |
| 13 | + | import { syncStartup } from "./services/repoSync.ts"; | |
| 14 | + | import { startSshServer } from "./services/sshServer.ts"; | |
| 15 | + | ||
| 16 | + | for (const cmd of ["git", "ssh-keygen"]) { | |
| 17 | + | const result = Bun.spawnSync(["which", cmd]); | |
| 18 | + | if (result.exitCode !== 0) { | |
| 19 | + | console.error(`Missing required command: ${cmd}`); | |
| 20 | + | process.exit(1); | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | await syncStartup(); | |
| 25 | + | if (!SSH_DISABLED) await startSshServer(); | |
| 26 | + | ||
| 27 | + | const _app = new Elysia({ serve: { maxRequestBodySize: MAX_UPLOAD_BYTES } }) | |
| 28 | + | .use( | |
| 29 | + | staticPlugin({ | |
| 30 | + | assets: path.resolve("./public"), | |
| 31 | + | prefix: "/", | |
| 32 | + | }), | |
| 33 | + | ) | |
| 34 | + | .use(gitRoutes) | |
| 35 | + | .use(settingsRoutes) | |
| 36 | + | .use(authRoutes) | |
| 37 | + | .use(repoRoutes) | |
| 38 | + | .use(issueRoutes) | |
| 39 | + | .use(patchRoutes) | |
| 40 | + | .use(releasesRoutes) | |
| 41 | + | .use(avatarRoutes) | |
| 42 | + | .listen(PORT); | |
| 43 | + | ||
| 44 | + | console.log(`Hearthforge running at http://localhost:${PORT}`); | |
Asrc/lib/rateLimiter.ts
| @@ -0,0 +1,38 @@ | |||
|---|---|---|---|
| 1 | + | import { RATE_LIMIT_DISABLED, TRUSTED_PROXY } from "../config.ts"; | |
| 2 | + | ||
| 3 | + | interface Bucket { | |
| 4 | + | count: number; | |
| 5 | + | resetAt: number; | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | const buckets = new Map<string, Bucket>(); | |
| 9 | + | ||
| 10 | + | export function checkRateLimit( | |
| 11 | + | ip: string | null, | |
| 12 | + | maxRequests: number, | |
| 13 | + | windowMs: number, | |
| 14 | + | ): boolean { | |
| 15 | + | if (RATE_LIMIT_DISABLED || !ip) return true; | |
| 16 | + | const now = Date.now(); | |
| 17 | + | const bucket = buckets.get(ip); | |
| 18 | + | if (!bucket || now > bucket.resetAt) { | |
| 19 | + | buckets.set(ip, { count: 1, resetAt: now + windowMs }); | |
| 20 | + | return true; | |
| 21 | + | } | |
| 22 | + | if (bucket.count >= maxRequests) return false; | |
| 23 | + | bucket.count++; | |
| 24 | + | return true; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | export function getClientIp( | |
| 28 | + | request: Request, | |
| 29 | + | server: Bun.Server<unknown> | null, | |
| 30 | + | ): string | null { | |
| 31 | + | if (TRUSTED_PROXY) { | |
| 32 | + | return ( | |
| 33 | + | request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? | |
| 34 | + | null | |
| 35 | + | ); | |
| 36 | + | } | |
| 37 | + | return server?.requestIP(request)?.address ?? null; | |
| 38 | + | } | |
Asrc/lib/redirect.ts
| @@ -0,0 +1,5 @@ | |||
|---|---|---|---|
| 1 | + | export function redirect(location: string, setCookie?: string): Response { | |
| 2 | + | const headers: Record<string, string> = { Location: location }; | |
| 3 | + | if (setCookie) headers["Set-Cookie"] = setCookie; | |
| 4 | + | return new Response(null, { status: 302, headers }); | |
| 5 | + | } | |
Asrc/lib/users.ts
| @@ -0,0 +1,8 @@ | |||
|---|---|---|---|
| 1 | + | import { OWNER_DISPLAY_NAME } from "../config.ts"; | |
| 2 | + | import { ADMIN_USERNAME } from "../constants.ts"; | |
| 3 | + | ||
| 4 | + | /** Show OWNER_DISPLAY_NAME for the admin account, [Deleted user] for null, otherwise the raw username. */ | |
| 5 | + | export function displayName(username: string | null | undefined): string { | |
| 6 | + | if (!username) return "[Deleted user]"; | |
| 7 | + | return username === ADMIN_USERNAME ? OWNER_DISPLAY_NAME : username; | |
| 8 | + | } | |
Asrc/middleware/session.ts
| @@ -0,0 +1,61 @@ | |||
|---|---|---|---|
| 1 | + | import { ADMIN_USERNAME } from "../constants.ts"; | |
| 2 | + | import { db } from "../db"; | |
| 3 | + | ||
| 4 | + | export interface SessionUser { | |
| 5 | + | id: number; | |
| 6 | + | username: string; | |
| 7 | + | isAdmin: boolean; | |
| 8 | + | avatar_version: number; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | export async function resolveSession( | |
| 12 | + | cookie: string | undefined, | |
| 13 | + | ): Promise<SessionUser | null> { | |
| 14 | + | if (!cookie) return null; | |
| 15 | + | const now = new Date().toISOString(); | |
| 16 | + | const session = await db | |
| 17 | + | .selectFrom("sessions") | |
| 18 | + | .innerJoin("users", "users.id", "sessions.user_id") | |
| 19 | + | .select([ | |
| 20 | + | "users.id", | |
| 21 | + | "users.username", | |
| 22 | + | "users.avatar_version", | |
| 23 | + | "sessions.expires_at", | |
| 24 | + | ]) | |
| 25 | + | .where("sessions.id", "=", cookie) | |
| 26 | + | .where("sessions.expires_at", ">", now) | |
| 27 | + | .executeTakeFirst(); | |
| 28 | + | if (!session) return null; | |
| 29 | + | return { | |
| 30 | + | id: session.id, | |
| 31 | + | username: session.username, | |
| 32 | + | isAdmin: session.username === ADMIN_USERNAME, | |
| 33 | + | avatar_version: session.avatar_version, | |
| 34 | + | }; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export function requireAuth(user: SessionUser | null): Response | null { | |
| 38 | + | if (!user) { | |
| 39 | + | return new Response(null, { | |
| 40 | + | status: 302, | |
| 41 | + | headers: { Location: "/login" }, | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | return null; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export function requireAdmin(user: SessionUser | null): Response | null { | |
| 48 | + | if (!user) { | |
| 49 | + | return new Response(null, { | |
| 50 | + | status: 302, | |
| 51 | + | headers: { Location: "/login" }, | |
| 52 | + | }); | |
| 53 | + | } | |
| 54 | + | if (!user.isAdmin) { | |
| 55 | + | return new Response("Forbidden", { | |
| 56 | + | status: 403, | |
| 57 | + | headers: { "Content-Type": "text/plain; charset=utf-8" }, | |
| 58 | + | }); | |
| 59 | + | } | |
| 60 | + | return null; | |
| 61 | + | } | |
Asrc/routes/auth.tsx
| @@ -0,0 +1,513 @@ | |||
|---|---|---|---|
| 1 | + | import { | |
| 2 | + | generateAuthenticationOptions, | |
| 3 | + | generateRegistrationOptions, | |
| 4 | + | verifyAuthenticationResponse, | |
| 5 | + | verifyRegistrationResponse, | |
| 6 | + | } from "@simplewebauthn/server"; | |
| 7 | + | import * as argon2 from "argon2"; | |
| 8 | + | import { Elysia, t } from "elysia"; | |
| 9 | + | import { REGISTRATION_DISABLED } from "../config.ts"; | |
| 10 | + | import { | |
| 11 | + | ADMIN_USERNAME, | |
| 12 | + | CHALLENGE_TTL_MS, | |
| 13 | + | VALID_USERNAME_RE, | |
| 14 | + | WEBAUTHN_RP_NAME, | |
| 15 | + | } from "../constants.ts"; | |
| 16 | + | import { db } from "../db/index.ts"; | |
| 17 | + | import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts"; | |
| 18 | + | import { redirect } from "../lib/redirect.ts"; | |
| 19 | + | import { resolveSession } from "../middleware/session.ts"; | |
| 20 | + | import { Login } from "../views/auth/Login.tsx"; | |
| 21 | + | import { Register } from "../views/auth/Register.tsx"; | |
| 22 | + | import { html } from "../views/render.tsx"; | |
| 23 | + | ||
| 24 | + | function rpFromRequest(request: Request): { origin: string; rpId: string } { | |
| 25 | + | const origin = request.headers.get("origin"); | |
| 26 | + | if (!origin) throw new Error("Missing Origin header"); | |
| 27 | + | return { origin, rpId: new URL(origin).hostname }; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | function randomHex(bytes: number): string { | |
| 31 | + | const arr = new Uint8Array(bytes); | |
| 32 | + | crypto.getRandomValues(arr); | |
| 33 | + | return Array.from(arr) | |
| 34 | + | .map((b) => b.toString(16).padStart(2, "0")) | |
| 35 | + | .join(""); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | async function createSession(userId: number): Promise<string> { | |
| 39 | + | const id = randomHex(32); | |
| 40 | + | const now = new Date(); | |
| 41 | + | const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days | |
| 42 | + | await db | |
| 43 | + | .insertInto("sessions") | |
| 44 | + | .values({ | |
| 45 | + | id, | |
| 46 | + | user_id: userId, | |
| 47 | + | expires_at: expires.toISOString(), | |
| 48 | + | created_at: now.toISOString(), | |
| 49 | + | }) | |
| 50 | + | .execute(); | |
| 51 | + | return id; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | function sessionCookie(id: string): string { | |
| 55 | + | return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 60 * 60}`; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | function clearCookie(): string { | |
| 59 | + | return "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // In-memory challenge store (fine for single-process) | |
| 63 | + | type ChallengeEntry = { | |
| 64 | + | challenge: string; | |
| 65 | + | timeoutId: ReturnType<typeof setTimeout>; | |
| 66 | + | }; | |
| 67 | + | const pendingChallenges = new Map<string, ChallengeEntry>(); | |
| 68 | + | ||
| 69 | + | function setChallenge(key: string, challenge: string): void { | |
| 70 | + | const existing = pendingChallenges.get(key); | |
| 71 | + | if (existing) clearTimeout(existing.timeoutId); | |
| 72 | + | const timeoutId = setTimeout( | |
| 73 | + | () => pendingChallenges.delete(key), | |
| 74 | + | CHALLENGE_TTL_MS, | |
| 75 | + | ); | |
| 76 | + | pendingChallenges.set(key, { challenge, timeoutId }); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | function deleteChallenge(key: string): void { | |
| 80 | + | const entry = pendingChallenges.get(key); | |
| 81 | + | if (entry) clearTimeout(entry.timeoutId); | |
| 82 | + | pendingChallenges.delete(key); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | export const authRoutes = new Elysia() | |
| 86 | + | .guard({ | |
| 87 | + | cookie: t.Cookie({ session: t.Optional(t.String()) }), | |
| 88 | + | }) | |
| 89 | + | .get("/login", () => { | |
| 90 | + | return html(<Login />); | |
| 91 | + | }) | |
| 92 | + | ||
| 93 | + | .post( | |
| 94 | + | "/login", | |
| 95 | + | async ({ body, request, server }) => { | |
| 96 | + | const ip = getClientIp(request, server); | |
| 97 | + | if (!checkRateLimit(ip, 10, 60_000)) { | |
| 98 | + | return html( | |
| 99 | + | <Login error="Too many login attempts. Please try again later." />, | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | const { username, password } = body; | |
| 103 | + | const user = await db | |
| 104 | + | .selectFrom("users") | |
| 105 | + | .selectAll() | |
| 106 | + | .where("username", "=", username) | |
| 107 | + | .executeTakeFirst(); | |
| 108 | + | ||
| 109 | + | if (!user || !user.password_hash) { | |
| 110 | + | return html(<Login error="Invalid username or password" />); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | const valid = await argon2.verify(user.password_hash, password); | |
| 114 | + | if (!valid) { | |
| 115 | + | return html(<Login error="Invalid username or password" />); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | const sessionId = await createSession(user.id); | |
| 119 | + | return redirect("/", sessionCookie(sessionId)); | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | body: t.Object({ username: t.String(), password: t.String() }), | |
| 123 | + | }, | |
| 124 | + | ) | |
| 125 | + | ||
| 126 | + | .get("/register", () => { | |
| 127 | + | if (REGISTRATION_DISABLED) | |
| 128 | + | return new Response("Registration is disabled", { status: 403 }); | |
| 129 | + | return html(<Register />); | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | .post( | |
| 133 | + | "/register", | |
| 134 | + | async ({ body, request, server }) => { | |
| 135 | + | if (REGISTRATION_DISABLED) | |
| 136 | + | return new Response("Registration is disabled", { | |
| 137 | + | status: 403, | |
| 138 | + | }); | |
| 139 | + | const ip = getClientIp(request, server); | |
| 140 | + | if (!checkRateLimit(ip, 3, 60 * 60_000)) { | |
| 141 | + | return html( | |
| 142 | + | <Register error="Too many registration attempts. Please try again later." />, | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | const { username, password, password2 } = body; | |
| 146 | + | ||
| 147 | + | if (!VALID_USERNAME_RE.test(username)) { | |
| 148 | + | return html( | |
| 149 | + | <Register error="Username may only contain letters, numbers, hyphens, and underscores" />, | |
| 150 | + | ); | |
| 151 | + | } | |
| 152 | + | if (username === ADMIN_USERNAME) { | |
| 153 | + | return html(<Register error="That username is reserved" />); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | if (!password?.trim()) { | |
| 157 | + | return html( | |
| 158 | + | <Register error="Password is required (use the passkey button for passwordless registration)" />, | |
| 159 | + | ); | |
| 160 | + | } | |
| 161 | + | if (password !== password2) { | |
| 162 | + | return html(<Register error="Passwords do not match" />); | |
| 163 | + | } | |
| 164 | + | if (password.length < 8) { | |
| 165 | + | return html( | |
| 166 | + | <Register error="Password must be at least 8 characters" />, | |
| 167 | + | ); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | const hash = await argon2.hash(password); | |
| 171 | + | const now = new Date().toISOString(); | |
| 172 | + | let result: { id: number }; | |
| 173 | + | try { | |
| 174 | + | result = await db | |
| 175 | + | .insertInto("users") | |
| 176 | + | .values({ | |
| 177 | + | username, | |
| 178 | + | password_hash: hash, | |
| 179 | + | created_at: now, | |
| 180 | + | }) | |
| 181 | + | .returning("id") | |
| 182 | + | .executeTakeFirstOrThrow(); | |
| 183 | + | } catch (err) { | |
| 184 | + | if ( | |
| 185 | + | err instanceof Error && | |
| 186 | + | err.message.includes( | |
| 187 | + | "UNIQUE constraint failed: users.username", | |
| 188 | + | ) | |
| 189 | + | ) { | |
| 190 | + | return html(<Register error="Username already taken" />); | |
| 191 | + | } | |
| 192 | + | throw err; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | const sessionId = await createSession(result.id); | |
| 196 | + | return redirect("/", sessionCookie(sessionId)); | |
| 197 | + | }, | |
| 198 | + | { | |
| 199 | + | body: t.Object({ | |
| 200 | + | username: t.String(), | |
| 201 | + | password: t.Optional(t.String()), | |
| 202 | + | password2: t.Optional(t.String()), | |
| 203 | + | }), | |
| 204 | + | }, | |
| 205 | + | ) | |
| 206 | + | ||
| 207 | + | .post("/logout", async ({ cookie }) => { | |
| 208 | + | const sessionId = cookie.session.value; | |
| 209 | + | if (sessionId) { | |
| 210 | + | await db | |
| 211 | + | .deleteFrom("sessions") | |
| 212 | + | .where("id", "=", sessionId) | |
| 213 | + | .execute(); | |
| 214 | + | } | |
| 215 | + | return redirect("/", clearCookie()); | |
| 216 | + | }) | |
| 217 | + | ||
| 218 | + | // Create account (passkey-only path, called before passkey registration) | |
| 219 | + | .post( | |
| 220 | + | "/auth/passkey/create-user", | |
| 221 | + | async ({ body }) => { | |
| 222 | + | const { username } = body; | |
| 223 | + | if (!username || !VALID_USERNAME_RE.test(username)) { | |
| 224 | + | return new Response( | |
| 225 | + | JSON.stringify({ error: "Invalid username" }), | |
| 226 | + | { | |
| 227 | + | status: 400, | |
| 228 | + | }, | |
| 229 | + | ); | |
| 230 | + | } | |
| 231 | + | if (username === ADMIN_USERNAME) { | |
| 232 | + | return new Response( | |
| 233 | + | JSON.stringify({ error: "That username is reserved" }), | |
| 234 | + | { status: 400 }, | |
| 235 | + | ); | |
| 236 | + | } | |
| 237 | + | const now = new Date().toISOString(); | |
| 238 | + | let result: { id: number }; | |
| 239 | + | try { | |
| 240 | + | result = await db | |
| 241 | + | .insertInto("users") | |
| 242 | + | .values({ | |
| 243 | + | username, | |
| 244 | + | password_hash: null, | |
| 245 | + | created_at: now, | |
| 246 | + | }) | |
| 247 | + | .returning("id") | |
| 248 | + | .executeTakeFirstOrThrow(); | |
| 249 | + | } catch (err) { | |
| 250 | + | if ( | |
| 251 | + | err instanceof Error && | |
| 252 | + | err.message.includes( | |
| 253 | + | "UNIQUE constraint failed: users.username", | |
| 254 | + | ) | |
| 255 | + | ) { | |
| 256 | + | return new Response( | |
| 257 | + | JSON.stringify({ error: "Username already taken" }), | |
| 258 | + | { status: 400 }, | |
| 259 | + | ); | |
| 260 | + | } | |
| 261 | + | throw err; | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | const sessionId = await createSession(result.id); | |
| 265 | + | return new Response(JSON.stringify({ ok: true }), { | |
| 266 | + | headers: { | |
| 267 | + | "Content-Type": "application/json", | |
| 268 | + | "Set-Cookie": sessionCookie(sessionId), | |
| 269 | + | }, | |
| 270 | + | }); | |
| 271 | + | }, | |
| 272 | + | { | |
| 273 | + | body: t.Object({ username: t.String() }), | |
| 274 | + | }, | |
| 275 | + | ) | |
| 276 | + | ||
| 277 | + | // Passkey registration | |
| 278 | + | .post("/auth/passkey/register/options", async ({ cookie, request }) => { | |
| 279 | + | const user = await resolveSession(cookie.session.value); | |
| 280 | + | if (!user) | |
| 281 | + | return new Response( | |
| 282 | + | JSON.stringify({ error: "Not authenticated" }), | |
| 283 | + | { | |
| 284 | + | status: 401, | |
| 285 | + | }, | |
| 286 | + | ); | |
| 287 | + | ||
| 288 | + | const { rpId } = rpFromRequest(request); | |
| 289 | + | const passkeys = await db | |
| 290 | + | .selectFrom("passkeys") | |
| 291 | + | .select(["credential_id"]) | |
| 292 | + | .where("user_id", "=", user.id) | |
| 293 | + | .execute(); | |
| 294 | + | ||
| 295 | + | const options = await generateRegistrationOptions({ | |
| 296 | + | rpName: WEBAUTHN_RP_NAME, | |
| 297 | + | rpID: rpId, | |
| 298 | + | userID: Buffer.from(String(user.id)), | |
| 299 | + | userName: user.username, | |
| 300 | + | attestationType: "none", | |
| 301 | + | excludeCredentials: passkeys.map((p) => ({ | |
| 302 | + | id: p.credential_id, | |
| 303 | + | type: "public-key" as const, | |
| 304 | + | })), | |
| 305 | + | }); | |
| 306 | + | ||
| 307 | + | setChallenge(`${user.username}:reg`, options.challenge); | |
| 308 | + | return new Response(JSON.stringify(options), { | |
| 309 | + | headers: { "Content-Type": "application/json" }, | |
| 310 | + | }); | |
| 311 | + | }) | |
| 312 | + | ||
| 313 | + | .post( | |
| 314 | + | "/auth/passkey/register/verify", | |
| 315 | + | async ({ body, cookie, request }) => { | |
| 316 | + | const user = await resolveSession(cookie.session.value); | |
| 317 | + | if (!user) | |
| 318 | + | return new Response( | |
| 319 | + | JSON.stringify({ error: "Not authenticated" }), | |
| 320 | + | { | |
| 321 | + | status: 401, | |
| 322 | + | }, | |
| 323 | + | ); | |
| 324 | + | ||
| 325 | + | const challenge = pendingChallenges.get( | |
| 326 | + | `${user.username}:reg`, | |
| 327 | + | )?.challenge; | |
| 328 | + | if (!challenge) | |
| 329 | + | return new Response(JSON.stringify({ error: "No challenge" }), { | |
| 330 | + | status: 400, | |
| 331 | + | }); | |
| 332 | + | ||
| 333 | + | const { origin, rpId } = rpFromRequest(request); | |
| 334 | + | try { | |
| 335 | + | const verification = await verifyRegistrationResponse({ | |
| 336 | + | response: body as Parameters< | |
| 337 | + | typeof verifyRegistrationResponse | |
| 338 | + | >[0]["response"], | |
| 339 | + | expectedChallenge: challenge, | |
| 340 | + | expectedOrigin: origin, | |
| 341 | + | expectedRPID: rpId, | |
| 342 | + | }); | |
| 343 | + | ||
| 344 | + | if (!verification.verified || !verification.registrationInfo) { | |
| 345 | + | return new Response( | |
| 346 | + | JSON.stringify({ error: "Verification failed" }), | |
| 347 | + | { status: 400 }, | |
| 348 | + | ); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | const { credential } = verification.registrationInfo; | |
| 352 | + | const now = new Date().toISOString(); | |
| 353 | + | await db | |
| 354 | + | .insertInto("passkeys") | |
| 355 | + | .values({ | |
| 356 | + | user_id: user.id, | |
| 357 | + | credential_id: credential.id, | |
| 358 | + | public_key: Buffer.from( | |
| 359 | + | new Uint8Array(credential.publicKey), | |
| 360 | + | ).toString("base64"), | |
| 361 | + | counter: credential.counter, | |
| 362 | + | created_at: now, | |
| 363 | + | }) | |
| 364 | + | .execute(); | |
| 365 | + | ||
| 366 | + | deleteChallenge(`${user.username}:reg`); | |
| 367 | + | return new Response(JSON.stringify({ ok: true }), { | |
| 368 | + | headers: { "Content-Type": "application/json" }, | |
| 369 | + | }); | |
| 370 | + | } catch (e) { | |
| 371 | + | return new Response(JSON.stringify({ error: String(e) }), { | |
| 372 | + | status: 400, | |
| 373 | + | }); | |
| 374 | + | } | |
| 375 | + | }, | |
| 376 | + | { | |
| 377 | + | body: t.Any(), | |
| 378 | + | }, | |
| 379 | + | ) | |
| 380 | + | ||
| 381 | + | // Passkey login | |
| 382 | + | .post("/auth/passkey/login/options", async ({ request }) => { | |
| 383 | + | const { rpId } = rpFromRequest(request); | |
| 384 | + | const options = await generateAuthenticationOptions({ | |
| 385 | + | rpID: rpId, | |
| 386 | + | }); | |
| 387 | + | setChallenge(`login:${options.challenge}`, options.challenge); | |
| 388 | + | return new Response(JSON.stringify(options), { | |
| 389 | + | headers: { "Content-Type": "application/json" }, | |
| 390 | + | }); | |
| 391 | + | }) | |
| 392 | + | ||
| 393 | + | .post( | |
| 394 | + | "/auth/passkey/login/verify", | |
| 395 | + | async ({ body, request }) => { | |
| 396 | + | const reqBody = body as { id?: string }; | |
| 397 | + | const credentialId = reqBody?.id; | |
| 398 | + | if (!credentialId) { | |
| 399 | + | return new Response( | |
| 400 | + | JSON.stringify({ error: "Missing credential" }), | |
| 401 | + | { | |
| 402 | + | status: 400, | |
| 403 | + | }, | |
| 404 | + | ); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | const passkey = await db | |
| 408 | + | .selectFrom("passkeys") | |
| 409 | + | .innerJoin("users", "users.id", "passkeys.user_id") | |
| 410 | + | .selectAll("passkeys") | |
| 411 | + | .select("users.username") | |
| 412 | + | .where("passkeys.credential_id", "=", credentialId) | |
| 413 | + | .executeTakeFirst(); | |
| 414 | + | ||
| 415 | + | if (!passkey) { | |
| 416 | + | return new Response( | |
| 417 | + | JSON.stringify({ error: "Unknown credential" }), | |
| 418 | + | { | |
| 419 | + | status: 400, | |
| 420 | + | }, | |
| 421 | + | ); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | // Look up the challenge by decoding it from the signed clientDataJSON. | |
| 425 | + | // This ensures each verify request finds its own challenge rather than | |
| 426 | + | // an arbitrary "first login:" entry, which would fail under concurrency. | |
| 427 | + | const reqBodyTyped = body as { | |
| 428 | + | response?: { clientDataJSON?: string }; | |
| 429 | + | }; | |
| 430 | + | const clientDataJSON = reqBodyTyped?.response?.clientDataJSON; | |
| 431 | + | let challengeKey: string | undefined; | |
| 432 | + | let challenge: string | undefined; | |
| 433 | + | if (clientDataJSON) { | |
| 434 | + | try { | |
| 435 | + | const cd = JSON.parse( | |
| 436 | + | Buffer.from(clientDataJSON, "base64url").toString(), | |
| 437 | + | ) as { challenge?: string }; | |
| 438 | + | if (cd.challenge) { | |
| 439 | + | challengeKey = `login:${cd.challenge}`; | |
| 440 | + | challenge = | |
| 441 | + | pendingChallenges.get(challengeKey)?.challenge; | |
| 442 | + | } | |
| 443 | + | } catch { | |
| 444 | + | // malformed clientDataJSON — handled by the check below | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | if (!challenge) { | |
| 448 | + | return new Response(JSON.stringify({ error: "No challenge" }), { | |
| 449 | + | status: 400, | |
| 450 | + | }); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | const { origin, rpId } = rpFromRequest(request); | |
| 454 | + | try { | |
| 455 | + | const verification = await verifyAuthenticationResponse({ | |
| 456 | + | response: body as Parameters< | |
| 457 | + | typeof verifyAuthenticationResponse | |
| 458 | + | >[0]["response"], | |
| 459 | + | expectedChallenge: challenge, | |
| 460 | + | expectedOrigin: origin, | |
| 461 | + | expectedRPID: rpId, | |
| 462 | + | credential: { | |
| 463 | + | id: passkey.credential_id, | |
| 464 | + | publicKey: Buffer.from(passkey.public_key, "base64"), | |
| 465 | + | counter: passkey.counter, | |
| 466 | + | }, | |
| 467 | + | }); | |
| 468 | + | ||
| 469 | + | if (!verification.verified) { | |
| 470 | + | return new Response( | |
| 471 | + | JSON.stringify({ error: "Verification failed" }), | |
| 472 | + | { status: 401 }, | |
| 473 | + | ); | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // Atomically advance the counter using the expected old value as | |
| 477 | + | // a guard. If another concurrent request already updated it, 0 | |
| 478 | + | // rows are affected and we reject — this preserves WebAuthn's | |
| 479 | + | // monotonic-counter replay-attack protection. | |
| 480 | + | const updated = await db | |
| 481 | + | .updateTable("passkeys") | |
| 482 | + | .set({ | |
| 483 | + | counter: verification.authenticationInfo.newCounter, | |
| 484 | + | }) | |
| 485 | + | .where("id", "=", passkey.id) | |
| 486 | + | .where("counter", "=", passkey.counter) | |
| 487 | + | .executeTakeFirst(); | |
| 488 | + | ||
| 489 | + | if (!updated || updated.numUpdatedRows === 0n) { | |
| 490 | + | return new Response( | |
| 491 | + | JSON.stringify({ error: "Credential replay detected" }), | |
| 492 | + | { status: 401 }, | |
| 493 | + | ); | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | deleteChallenge(challengeKey!); | |
| 497 | + | const sessionId = await createSession(passkey.user_id); | |
| 498 | + | return new Response(JSON.stringify({ ok: true }), { | |
| 499 | + | headers: { | |
| 500 | + | "Content-Type": "application/json", | |
| 501 | + | "Set-Cookie": sessionCookie(sessionId), | |
| 502 | + | }, | |
| 503 | + | }); | |
| 504 | + | } catch (e) { | |
| 505 | + | return new Response(JSON.stringify({ error: String(e) }), { | |
| 506 | + | status: 400, | |
| 507 | + | }); | |
| 508 | + | } | |
| 509 | + | }, | |
| 510 | + | { | |
| 511 | + | body: t.Any(), | |
| 512 | + | }, | |
| 513 | + | ); | |
Asrc/routes/avatars.ts
| @@ -0,0 +1,84 @@ | |||
|---|---|---|---|
| 1 | + | import { Elysia, t } from "elysia"; | |
| 2 | + | import { MAX_USER_UPLOAD_BYTES } from "../config.ts"; | |
| 3 | + | import { db } from "../db/index.ts"; | |
| 4 | + | import { requireAuth, resolveSession } from "../middleware/session.ts"; | |
| 5 | + | import { | |
| 6 | + | avatarJxlPath, | |
| 7 | + | createDefaultAvatar, | |
| 8 | + | processAndStoreAvatar, | |
| 9 | + | } from "../services/avatar.ts"; | |
| 10 | + | ||
| 11 | + | const CACHE = "public, max-age=31536000, immutable"; | |
| 12 | + | ||
| 13 | + | async function bumpAvatarVersion(userId: number): Promise<void> { | |
| 14 | + | await db | |
| 15 | + | .updateTable("users") | |
| 16 | + | .set((eb) => ({ avatar_version: eb("avatar_version", "+", 1) })) | |
| 17 | + | .where("id", "=", userId) | |
| 18 | + | .execute(); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export const avatarRoutes = new Elysia() | |
| 22 | + | .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }) }) | |
| 23 | + | ||
| 24 | + | .get("/avatars/:id", async ({ params, request }) => { | |
| 25 | + | const userId = parseInt(params.id, 10); | |
| 26 | + | if (Number.isNaN(userId)) | |
| 27 | + | return new Response("Not found", { status: 404 }); | |
| 28 | + | ||
| 29 | + | if (!new URL(request.url).searchParams.has("v")) | |
| 30 | + | return new Response("Not found", { status: 404 }); | |
| 31 | + | ||
| 32 | + | return new Response(Bun.file(avatarJxlPath(userId)), { | |
| 33 | + | headers: { | |
| 34 | + | "Content-Type": "image/jxl", | |
| 35 | + | "Cache-Control": CACHE, | |
| 36 | + | }, | |
| 37 | + | }); | |
| 38 | + | }) | |
| 39 | + | ||
| 40 | + | .post( | |
| 41 | + | "/settings/avatar", | |
| 42 | + | async ({ body, cookie }) => { | |
| 43 | + | const user = await resolveSession(cookie.session.value); | |
| 44 | + | const deny = requireAuth(user); | |
| 45 | + | if (deny) return deny; | |
| 46 | + | if (body.avatar.size > MAX_USER_UPLOAD_BYTES) { | |
| 47 | + | return new Response("Avatar file too large", { status: 400 }); | |
| 48 | + | } | |
| 49 | + | const buffer = Buffer.from(await body.avatar.arrayBuffer()); | |
| 50 | + | try { | |
| 51 | + | await processAndStoreAvatar(user!.id, buffer); | |
| 52 | + | } catch (err) { | |
| 53 | + | if ( | |
| 54 | + | err instanceof Error && | |
| 55 | + | err.message === "Invalid image type" | |
| 56 | + | ) { | |
| 57 | + | return new Response("File is not a supported image type", { | |
| 58 | + | status: 400, | |
| 59 | + | }); | |
| 60 | + | } | |
| 61 | + | throw err; | |
| 62 | + | } | |
| 63 | + | await bumpAvatarVersion(user!.id); | |
| 64 | + | return new Response(null, { | |
| 65 | + | status: 302, | |
| 66 | + | headers: { Location: "/settings" }, | |
| 67 | + | }); | |
| 68 | + | }, | |
| 69 | + | { | |
| 70 | + | body: t.Object({ avatar: t.File() }), | |
| 71 | + | }, | |
| 72 | + | ) | |
| 73 | + | ||
| 74 | + | .post("/settings/avatar/delete", async ({ cookie }) => { | |
| 75 | + | const user = await resolveSession(cookie.session.value); | |
| 76 | + | const deny = requireAuth(user); | |
| 77 | + | if (deny) return deny; | |
| 78 | + | await createDefaultAvatar(user!.id, user!.username); | |
| 79 | + | await bumpAvatarVersion(user!.id); | |
| 80 | + | return new Response(null, { | |
| 81 | + | status: 302, | |
| 82 | + | headers: { Location: "/settings" }, | |
| 83 | + | }); | |
| 84 | + | }); | |
Asrc/routes/git.ts
| @@ -0,0 +1,177 @@ | |||
|---|---|---|---|
| 1 | + | import { existsSync } from "node:fs"; | |
| 2 | + | import path from "node:path"; | |
| 3 | + | import * as argon2 from "argon2"; | |
| 4 | + | import { Elysia, t } from "elysia"; | |
| 5 | + | import { ADMIN_USERNAME, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 6 | + | import { db } from "../db"; | |
| 7 | + | ||
| 8 | + | function pktLine(str: string): Buffer { | |
| 9 | + | const len = Buffer.byteLength(str, "utf-8") + 4; | |
| 10 | + | return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8"); | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | const PKT_FLUSH = Buffer.from("0000"); | |
| 14 | + | ||
| 15 | + | async function verifyBasicAuth( | |
| 16 | + | authHeader: string | null, | |
| 17 | + | adminOnly: boolean, | |
| 18 | + | ): Promise<boolean> { | |
| 19 | + | if (!authHeader?.startsWith("Basic ")) return false; | |
| 20 | + | const decoded = Buffer.from(authHeader.slice(6), "base64").toString( | |
| 21 | + | "utf-8", | |
| 22 | + | ); | |
| 23 | + | const sep = decoded.indexOf(":"); | |
| 24 | + | if (sep === -1) return false; | |
| 25 | + | const username = decoded.slice(0, sep); | |
| 26 | + | const password = decoded.slice(sep + 1); | |
| 27 | + | if (adminOnly && username !== ADMIN_USERNAME) return false; | |
| 28 | + | const user = await db | |
| 29 | + | .selectFrom("users") | |
| 30 | + | .select("password_hash") | |
| 31 | + | .where("username", "=", username) | |
| 32 | + | .executeTakeFirst(); | |
| 33 | + | if (!user?.password_hash) return false; | |
| 34 | + | try { | |
| 35 | + | return await argon2.verify(user.password_hash, password); | |
| 36 | + | } catch { | |
| 37 | + | return false; | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | function unauthorized(): Response { | |
| 42 | + | return new Response("Unauthorized", { | |
| 43 | + | status: 401, | |
| 44 | + | headers: { | |
| 45 | + | "WWW-Authenticate": 'Basic realm="Hearthforge"', | |
| 46 | + | "Content-Type": "text/plain", | |
| 47 | + | }, | |
| 48 | + | }); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | async function getRepo( | |
| 52 | + | slug: string, | |
| 53 | + | ): Promise<{ repoPath: string; isPrivate: boolean } | null> { | |
| 54 | + | const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug; | |
| 55 | + | if (!VALID_REPO_NAME_RE.test(repoName)) return null; | |
| 56 | + | const repo = await db | |
| 57 | + | .selectFrom("repositories") | |
| 58 | + | .select(["name", "is_private"]) | |
| 59 | + | .where("name", "=", repoName) | |
| 60 | + | .executeTakeFirst(); | |
| 61 | + | if (!repo) return null; | |
| 62 | + | const repoPath = path.join(REPOS_DIR, `${repo.name}.git`); | |
| 63 | + | if (!existsSync(repoPath)) return null; | |
| 64 | + | return { repoPath, isPrivate: repo.is_private === 1 }; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | async function spawnGit( | |
| 68 | + | args: string[], | |
| 69 | + | stdinBytes?: Uint8Array, | |
| 70 | + | ): Promise<Uint8Array> { | |
| 71 | + | const proc = Bun.spawn(args, { | |
| 72 | + | stdin: stdinBytes ?? "ignore", | |
| 73 | + | stdout: "pipe", | |
| 74 | + | stderr: "pipe", | |
| 75 | + | }); | |
| 76 | + | await proc.exited; | |
| 77 | + | return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout)); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | export const gitRoutes = new Elysia() | |
| 81 | + | // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push) | |
| 82 | + | .get( | |
| 83 | + | "/:repo/info/refs", | |
| 84 | + | async ({ params, query, request }) => { | |
| 85 | + | const service = query.service; | |
| 86 | + | if ( | |
| 87 | + | service !== "git-upload-pack" && | |
| 88 | + | service !== "git-receive-pack" | |
| 89 | + | ) { | |
| 90 | + | return new Response("Bad Request", { status: 400 }); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | const repo = await getRepo(params.repo); | |
| 94 | + | if (!repo) return new Response("Not Found", { status: 404 }); | |
| 95 | + | ||
| 96 | + | const authHeader = request.headers.get("Authorization"); | |
| 97 | + | if (service === "git-receive-pack") { | |
| 98 | + | if (!(await verifyBasicAuth(authHeader, true))) | |
| 99 | + | return unauthorized(); | |
| 100 | + | } else if (repo.isPrivate) { | |
| 101 | + | if (!(await verifyBasicAuth(authHeader, false))) | |
| 102 | + | return unauthorized(); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | const gitCmd = | |
| 106 | + | service === "git-receive-pack" ? "receive-pack" : "upload-pack"; | |
| 107 | + | const refs = await spawnGit([ | |
| 108 | + | "git", | |
| 109 | + | gitCmd, | |
| 110 | + | "--stateless-rpc", | |
| 111 | + | "--advertise-refs", | |
| 112 | + | repo.repoPath, | |
| 113 | + | ]); | |
| 114 | + | const body = Buffer.concat([ | |
| 115 | + | pktLine(`# service=git-${gitCmd}\n`), | |
| 116 | + | PKT_FLUSH, | |
| 117 | + | refs, | |
| 118 | + | ]); | |
| 119 | + | ||
| 120 | + | return new Response(body, { | |
| 121 | + | headers: { | |
| 122 | + | "Content-Type": `application/x-git-${gitCmd}-advertisement`, | |
| 123 | + | "Cache-Control": "no-cache", | |
| 124 | + | }, | |
| 125 | + | }); | |
| 126 | + | }, | |
| 127 | + | { | |
| 128 | + | query: t.Object({ service: t.Optional(t.String()) }), | |
| 129 | + | }, | |
| 130 | + | ) | |
| 131 | + | ||
| 132 | + | // upload-pack POST — clone/fetch pack transfer (public for public repos) | |
| 133 | + | .post("/:repo/git-upload-pack", async ({ params, request }) => { | |
| 134 | + | const repo = await getRepo(params.repo); | |
| 135 | + | if (!repo) return new Response("Not Found", { status: 404 }); | |
| 136 | + | if (repo.isPrivate) { | |
| 137 | + | if ( | |
| 138 | + | !(await verifyBasicAuth( | |
| 139 | + | request.headers.get("Authorization"), | |
| 140 | + | false, | |
| 141 | + | )) | |
| 142 | + | ) | |
| 143 | + | return unauthorized(); | |
| 144 | + | } | |
| 145 | + | const body = new Uint8Array(await request.arrayBuffer()); | |
| 146 | + | const result = await spawnGit( | |
| 147 | + | ["git", "upload-pack", "--stateless-rpc", repo.repoPath], | |
| 148 | + | body, | |
| 149 | + | ); | |
| 150 | + | return new Response(result, { | |
| 151 | + | headers: { | |
| 152 | + | "Content-Type": "application/x-git-upload-pack-result", | |
| 153 | + | "Cache-Control": "no-cache", | |
| 154 | + | }, | |
| 155 | + | }); | |
| 156 | + | }) | |
| 157 | + | ||
| 158 | + | // receive-pack POST — push pack transfer (admin only) | |
| 159 | + | .post("/:repo/git-receive-pack", async ({ params, request }) => { | |
| 160 | + | if ( | |
| 161 | + | !(await verifyBasicAuth(request.headers.get("Authorization"), true)) | |
| 162 | + | ) | |
| 163 | + | return unauthorized(); | |
| 164 | + | const repo = await getRepo(params.repo); | |
| 165 | + | if (!repo) return new Response("Not Found", { status: 404 }); | |
| 166 | + | const body = new Uint8Array(await request.arrayBuffer()); | |
| 167 | + | const result = await spawnGit( | |
| 168 | + | ["git", "receive-pack", "--stateless-rpc", repo.repoPath], | |
| 169 | + | body, | |
| 170 | + | ); | |
| 171 | + | return new Response(result, { | |
| 172 | + | headers: { | |
| 173 | + | "Content-Type": "application/x-git-receive-pack-result", | |
| 174 | + | "Cache-Control": "no-cache", | |
| 175 | + | }, | |
| 176 | + | }); | |
| 177 | + | }); | |
Asrc/routes/issues.tsx
| @@ -0,0 +1,539 @@ | |||
|---|---|---|---|
| 1 | + | import { Elysia, t } from "elysia"; | |
| 2 | + | import { sql } from "kysely"; | |
| 3 | + | import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts"; | |
| 4 | + | import { db, getRepo } from "../db/index.ts"; | |
| 5 | + | import { | |
| 6 | + | requireAdmin, | |
| 7 | + | requireAuth, | |
| 8 | + | resolveSession, | |
| 9 | + | } from "../middleware/session.ts"; | |
| 10 | + | import { renderMarkdown } from "../services/markdown.ts"; | |
| 11 | + | import { buildReactionCounts } from "../services/reactions.ts"; | |
| 12 | + | import { IssueDetail } from "../views/issues/IssueDetail.tsx"; | |
| 13 | + | import { IssueList } from "../views/issues/IssueList.tsx"; | |
| 14 | + | import { NewIssue } from "../views/issues/NewIssue.tsx"; | |
| 15 | + | import { html } from "../views/render.tsx"; | |
| 16 | + | ||
| 17 | + | export const issueRoutes = new Elysia() | |
| 18 | + | .guard({ | |
| 19 | + | cookie: t.Cookie({ session: t.Optional(t.String()) }), | |
| 20 | + | }) | |
| 21 | + | .get( | |
| 22 | + | "/:repo/issues", | |
| 23 | + | async ({ params, query, cookie }) => { | |
| 24 | + | const user = await resolveSession(cookie.session.value); | |
| 25 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 26 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 27 | + | ||
| 28 | + | const status = | |
| 29 | + | query.status === "closed" | |
| 30 | + | ? ("closed" as const) | |
| 31 | + | : ("open" as const); | |
| 32 | + | const page = Math.max(1, query.page ?? 1); | |
| 33 | + | ||
| 34 | + | const allCounts = await db | |
| 35 | + | .selectFrom("issues") | |
| 36 | + | .select(["status", db.fn.countAll<number>().as("count")]) | |
| 37 | + | .where("repo_id", "=", repo.id) | |
| 38 | + | .groupBy("status") | |
| 39 | + | .execute(); | |
| 40 | + | const counts: Record<string, number> = Object.fromEntries( | |
| 41 | + | allCounts.map((r) => [r.status, Number(r.count)]), | |
| 42 | + | ); | |
| 43 | + | const totalPages = Math.max( | |
| 44 | + | 1, | |
| 45 | + | Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE), | |
| 46 | + | ); | |
| 47 | + | const safePage = Math.min(page, totalPages); | |
| 48 | + | const offset = (safePage - 1) * ISSUES_PER_PAGE; | |
| 49 | + | ||
| 50 | + | const issues = await db | |
| 51 | + | .selectFrom("issues") | |
| 52 | + | .leftJoin("users", "users.id", "issues.author_id") | |
| 53 | + | .select([ | |
| 54 | + | "issues.id", | |
| 55 | + | "issues.repo_id", | |
| 56 | + | "issues.author_id", | |
| 57 | + | "issues.number", | |
| 58 | + | "issues.title", | |
| 59 | + | "issues.body", | |
| 60 | + | "issues.status", | |
| 61 | + | "issues.created_at", | |
| 62 | + | "issues.updated_at", | |
| 63 | + | "issues.edited_at", | |
| 64 | + | "users.username as author_username", | |
| 65 | + | ]) | |
| 66 | + | .where("issues.repo_id", "=", repo.id) | |
| 67 | + | .where("issues.status", "=", status) | |
| 68 | + | .orderBy("issues.number", "desc") | |
| 69 | + | .limit(ISSUES_PER_PAGE) | |
| 70 | + | .offset(offset) | |
| 71 | + | .execute(); | |
| 72 | + | ||
| 73 | + | const pagination = { | |
| 74 | + | page: safePage, | |
| 75 | + | totalPages, | |
| 76 | + | pageUrlTemplate: `/${repo.name}/issues?status=${status}&page={page}`, | |
| 77 | + | }; | |
| 78 | + | return html( | |
| 79 | + | <IssueList | |
| 80 | + | user={user} | |
| 81 | + | repo={repo} | |
| 82 | + | issues={ | |
| 83 | + | issues as ((typeof issues)[0] & { | |
| 84 | + | author_username: string; | |
| 85 | + | })[] | |
| 86 | + | } | |
| 87 | + | status={status} | |
| 88 | + | counts={counts} | |
| 89 | + | pagination={pagination} | |
| 90 | + | />, | |
| 91 | + | ); | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | query: t.Object({ | |
| 95 | + | status: t.Optional(t.String()), | |
| 96 | + | page: t.Optional(t.Numeric()), | |
| 97 | + | }), | |
| 98 | + | }, | |
| 99 | + | ) | |
| 100 | + | ||
| 101 | + | .get("/:repo/issues/new", async ({ params, cookie }) => { | |
| 102 | + | const user = await resolveSession(cookie.session.value); | |
| 103 | + | const deny = requireAuth(user); | |
| 104 | + | if (deny) return deny; | |
| 105 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 106 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 107 | + | return html(<NewIssue user={user!} repo={repo} />); | |
| 108 | + | }) | |
| 109 | + | ||
| 110 | + | .post( | |
| 111 | + | "/:repo/issues", | |
| 112 | + | async ({ params, body, cookie }) => { | |
| 113 | + | const user = await resolveSession(cookie.session.value); | |
| 114 | + | const deny = requireAuth(user); | |
| 115 | + | if (deny) return deny; | |
| 116 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 117 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 118 | + | ||
| 119 | + | const { title, body: issueBody } = body; | |
| 120 | + | if (!title?.trim()) { | |
| 121 | + | return html( | |
| 122 | + | <NewIssue | |
| 123 | + | user={user!} | |
| 124 | + | repo={repo} | |
| 125 | + | error="Title is required" | |
| 126 | + | />, | |
| 127 | + | ); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | const now = new Date().toISOString(); | |
| 131 | + | const { number } = await db.transaction().execute(async (trx) => { | |
| 132 | + | const { issue_seq } = await trx | |
| 133 | + | .updateTable("repositories") | |
| 134 | + | .set({ issue_seq: sql`issue_seq + 1` }) | |
| 135 | + | .where("id", "=", repo.id) | |
| 136 | + | .returning("issue_seq") | |
| 137 | + | .executeTakeFirstOrThrow(); | |
| 138 | + | await trx | |
| 139 | + | .insertInto("issues") | |
| 140 | + | .values({ | |
| 141 | + | repo_id: repo.id, | |
| 142 | + | author_id: user?.id, | |
| 143 | + | number: issue_seq, | |
| 144 | + | title: title.trim(), | |
| 145 | + | body: issueBody ?? "", | |
| 146 | + | status: "open", | |
| 147 | + | created_at: now, | |
| 148 | + | updated_at: now, | |
| 149 | + | }) | |
| 150 | + | .execute(); | |
| 151 | + | return { number: issue_seq }; | |
| 152 | + | }); | |
| 153 | + | ||
| 154 | + | return new Response(null, { | |
| 155 | + | status: 302, | |
| 156 | + | headers: { Location: `/${repo.name}/issues/${number}` }, | |
| 157 | + | }); | |
| 158 | + | }, | |
| 159 | + | { | |
| 160 | + | body: t.Object({ | |
| 161 | + | title: t.String(), | |
| 162 | + | body: t.Optional(t.String()), | |
| 163 | + | }), | |
| 164 | + | }, | |
| 165 | + | ) | |
| 166 | + | ||
| 167 | + | .get("/:repo/issues/:number", async ({ params, cookie }) => { | |
| 168 | + | const user = await resolveSession(cookie.session.value); | |
| 169 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 170 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 171 | + | ||
| 172 | + | const issueNum = parseInt(params.number, 10); | |
| 173 | + | const issue = await db | |
| 174 | + | .selectFrom("issues") | |
| 175 | + | .leftJoin("users", "users.id", "issues.author_id") | |
| 176 | + | .select([ | |
| 177 | + | "issues.id", | |
| 178 | + | "issues.repo_id", | |
| 179 | + | "issues.author_id", | |
| 180 | + | "issues.number", | |
| 181 | + | "issues.title", | |
| 182 | + | "issues.body", | |
| 183 | + | "issues.status", | |
| 184 | + | "issues.created_at", | |
| 185 | + | "issues.updated_at", | |
| 186 | + | "issues.edited_at", | |
| 187 | + | "users.username as author_username", | |
| 188 | + | "users.avatar_version as author_avatar_version", | |
| 189 | + | ]) | |
| 190 | + | .where("issues.repo_id", "=", repo.id) | |
| 191 | + | .where("issues.number", "=", issueNum) | |
| 192 | + | .executeTakeFirst(); | |
| 193 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 194 | + | ||
| 195 | + | const bodyHtml = renderMarkdown(issue.body); | |
| 196 | + | ||
| 197 | + | const comments = await db | |
| 198 | + | .selectFrom("issue_comments") | |
| 199 | + | .leftJoin("users", "users.id", "issue_comments.author_id") | |
| 200 | + | .select([ | |
| 201 | + | "issue_comments.id", | |
| 202 | + | "issue_comments.issue_id", | |
| 203 | + | "issue_comments.author_id", | |
| 204 | + | "issue_comments.body", | |
| 205 | + | "issue_comments.created_at", | |
| 206 | + | "issue_comments.edited_at", | |
| 207 | + | "users.username as author_username", | |
| 208 | + | "users.avatar_version as author_avatar_version", | |
| 209 | + | ]) | |
| 210 | + | .where("issue_comments.issue_id", "=", issue.id) | |
| 211 | + | .orderBy("issue_comments.created_at", "asc") | |
| 212 | + | .execute(); | |
| 213 | + | ||
| 214 | + | const commentsWithHtml = comments.map((c) => ({ | |
| 215 | + | ...c, | |
| 216 | + | bodyHtml: renderMarkdown(c.body), | |
| 217 | + | })); | |
| 218 | + | ||
| 219 | + | // Reactions on the issue itself | |
| 220 | + | const allReactions = await db | |
| 221 | + | .selectFrom("issue_reactions") | |
| 222 | + | .selectAll() | |
| 223 | + | .where("issue_id", "=", issue.id) | |
| 224 | + | .execute(); | |
| 225 | + | ||
| 226 | + | const reactions = buildReactionCounts(allReactions, null, user?.id); | |
| 227 | + | const commentReactions = new Map( | |
| 228 | + | comments.map((c) => [ | |
| 229 | + | c.id, | |
| 230 | + | buildReactionCounts(allReactions, c.id, user?.id), | |
| 231 | + | ]), | |
| 232 | + | ); | |
| 233 | + | ||
| 234 | + | return html( | |
| 235 | + | <IssueDetail | |
| 236 | + | user={user} | |
| 237 | + | repo={repo} | |
| 238 | + | issue={ | |
| 239 | + | issue as typeof issue & { | |
| 240 | + | author_username: string; | |
| 241 | + | author_avatar_version: number | null; | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | bodyHtml={bodyHtml} | |
| 245 | + | comments={ | |
| 246 | + | commentsWithHtml as ((typeof commentsWithHtml)[0] & { | |
| 247 | + | author_username: string; | |
| 248 | + | author_avatar_version: number | null; | |
| 249 | + | })[] | |
| 250 | + | } | |
| 251 | + | reactions={reactions} | |
| 252 | + | commentReactions={commentReactions} | |
| 253 | + | />, | |
| 254 | + | ); | |
| 255 | + | }) | |
| 256 | + | ||
| 257 | + | .post( | |
| 258 | + | "/:repo/issues/:number/comments", | |
| 259 | + | async ({ params, body, cookie }) => { | |
| 260 | + | const user = await resolveSession(cookie.session.value); | |
| 261 | + | const deny = requireAuth(user); | |
| 262 | + | if (deny) return deny; | |
| 263 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 264 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 265 | + | ||
| 266 | + | const issueNum = parseInt(params.number, 10); | |
| 267 | + | const issue = await db | |
| 268 | + | .selectFrom("issues") | |
| 269 | + | .select(["id", "status"]) | |
| 270 | + | .where("repo_id", "=", repo.id) | |
| 271 | + | .where("number", "=", issueNum) | |
| 272 | + | .executeTakeFirst(); | |
| 273 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 274 | + | ||
| 275 | + | // Only admin can comment on closed issues | |
| 276 | + | if (issue.status === "closed" && !user?.isAdmin) { | |
| 277 | + | return new Response(null, { | |
| 278 | + | status: 302, | |
| 279 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 280 | + | }); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | const { body: commentBody } = body; | |
| 284 | + | if (!commentBody?.trim()) { | |
| 285 | + | return new Response(null, { | |
| 286 | + | status: 302, | |
| 287 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 288 | + | }); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | await db.transaction().execute(async (trx) => { | |
| 292 | + | const now = new Date().toISOString(); | |
| 293 | + | await trx | |
| 294 | + | .insertInto("issue_comments") | |
| 295 | + | .values({ | |
| 296 | + | issue_id: issue.id, | |
| 297 | + | author_id: user?.id, | |
| 298 | + | body: commentBody.trim(), | |
| 299 | + | created_at: now, | |
| 300 | + | }) | |
| 301 | + | .execute(); | |
| 302 | + | await trx | |
| 303 | + | .updateTable("issues") | |
| 304 | + | .set({ updated_at: now }) | |
| 305 | + | .where("id", "=", issue.id) | |
| 306 | + | .execute(); | |
| 307 | + | }); | |
| 308 | + | ||
| 309 | + | return new Response(null, { | |
| 310 | + | status: 302, | |
| 311 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 312 | + | }); | |
| 313 | + | }, | |
| 314 | + | { | |
| 315 | + | body: t.Object({ body: t.String() }), | |
| 316 | + | }, | |
| 317 | + | ) | |
| 318 | + | ||
| 319 | + | .post( | |
| 320 | + | "/:repo/issues/:number/react", | |
| 321 | + | async ({ params, body, cookie }) => { | |
| 322 | + | const user = await resolveSession(cookie.session.value); | |
| 323 | + | const deny = requireAuth(user); | |
| 324 | + | if (deny) return deny; | |
| 325 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 326 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 327 | + | ||
| 328 | + | const { emoji, comment_id } = body; | |
| 329 | + | if (!ALLOWED_REACTIONS.has(emoji)) { | |
| 330 | + | return new Response("Invalid emoji", { status: 400 }); | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | const issueNum = parseInt(params.number, 10); | |
| 334 | + | const issue = await db | |
| 335 | + | .selectFrom("issues") | |
| 336 | + | .select(["id"]) | |
| 337 | + | .where("repo_id", "=", repo.id) | |
| 338 | + | .where("number", "=", issueNum) | |
| 339 | + | .executeTakeFirst(); | |
| 340 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 341 | + | ||
| 342 | + | const commentId = comment_id ? parseInt(comment_id, 10) : null; | |
| 343 | + | ||
| 344 | + | // One reaction per user per target: toggle off if same emoji, replace if different | |
| 345 | + | await db.transaction().execute(async (trx) => { | |
| 346 | + | const existing = await trx | |
| 347 | + | .selectFrom("issue_reactions") | |
| 348 | + | .select(["id", "emoji"]) | |
| 349 | + | .where("issue_id", "=", issue.id) | |
| 350 | + | .where((eb) => | |
| 351 | + | commentId !== null | |
| 352 | + | ? eb("comment_id", "=", commentId) | |
| 353 | + | : eb("comment_id", "is", null), | |
| 354 | + | ) | |
| 355 | + | .where("user_id", "=", user!.id) | |
| 356 | + | .executeTakeFirst(); | |
| 357 | + | ||
| 358 | + | if (existing) { | |
| 359 | + | if (existing.emoji === emoji) { | |
| 360 | + | await trx | |
| 361 | + | .deleteFrom("issue_reactions") | |
| 362 | + | .where("id", "=", existing.id) | |
| 363 | + | .execute(); | |
| 364 | + | } else { | |
| 365 | + | await trx | |
| 366 | + | .updateTable("issue_reactions") | |
| 367 | + | .set({ emoji }) | |
| 368 | + | .where("id", "=", existing.id) | |
| 369 | + | .execute(); | |
| 370 | + | } | |
| 371 | + | } else { | |
| 372 | + | await trx | |
| 373 | + | .insertInto("issue_reactions") | |
| 374 | + | .values({ | |
| 375 | + | issue_id: issue.id, | |
| 376 | + | comment_id: commentId, | |
| 377 | + | user_id: user!.id, | |
| 378 | + | emoji, | |
| 379 | + | }) | |
| 380 | + | .execute(); | |
| 381 | + | } | |
| 382 | + | }); | |
| 383 | + | ||
| 384 | + | return new Response(null, { | |
| 385 | + | status: 303, | |
| 386 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 387 | + | }); | |
| 388 | + | }, | |
| 389 | + | { | |
| 390 | + | body: t.Object({ | |
| 391 | + | emoji: t.String(), | |
| 392 | + | comment_id: t.Optional(t.String()), | |
| 393 | + | }), | |
| 394 | + | }, | |
| 395 | + | ) | |
| 396 | + | ||
| 397 | + | .post("/:repo/issues/:number/close", async ({ params, cookie }) => { | |
| 398 | + | const user = await resolveSession(cookie.session.value); | |
| 399 | + | const deny = requireAdmin(user); | |
| 400 | + | if (deny) return deny; | |
| 401 | + | const repo = await getRepo(params.repo, true); | |
| 402 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 403 | + | ||
| 404 | + | const issueNum = parseInt(params.number, 10); | |
| 405 | + | const issue = await db | |
| 406 | + | .selectFrom("issues") | |
| 407 | + | .select("id") | |
| 408 | + | .where("repo_id", "=", repo.id) | |
| 409 | + | .where("number", "=", issueNum) | |
| 410 | + | .executeTakeFirst(); | |
| 411 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 412 | + | ||
| 413 | + | await db | |
| 414 | + | .updateTable("issues") | |
| 415 | + | .set({ | |
| 416 | + | status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`, | |
| 417 | + | updated_at: new Date().toISOString(), | |
| 418 | + | }) | |
| 419 | + | .where("id", "=", issue.id) | |
| 420 | + | .execute(); | |
| 421 | + | ||
| 422 | + | return new Response(null, { | |
| 423 | + | status: 302, | |
| 424 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 425 | + | }); | |
| 426 | + | }) | |
| 427 | + | ||
| 428 | + | .post("/:repo/issues/:number/delete", async ({ params, cookie }) => { | |
| 429 | + | const user = await resolveSession(cookie.session.value); | |
| 430 | + | const deny = requireAuth(user); | |
| 431 | + | if (deny) return deny; | |
| 432 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 433 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 434 | + | ||
| 435 | + | const issueNum = parseInt(params.number, 10); | |
| 436 | + | const issue = await db | |
| 437 | + | .selectFrom("issues") | |
| 438 | + | .select(["id", "author_id"]) | |
| 439 | + | .where("repo_id", "=", repo.id) | |
| 440 | + | .where("number", "=", issueNum) | |
| 441 | + | .executeTakeFirst(); | |
| 442 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 443 | + | if (issue.author_id !== user?.id && !user?.isAdmin) | |
| 444 | + | return new Response("Forbidden", { status: 403 }); | |
| 445 | + | ||
| 446 | + | await db.deleteFrom("issues").where("id", "=", issue.id).execute(); | |
| 447 | + | ||
| 448 | + | return new Response(null, { | |
| 449 | + | status: 302, | |
| 450 | + | headers: { Location: `/${repo.name}/issues` }, | |
| 451 | + | }); | |
| 452 | + | }) | |
| 453 | + | ||
| 454 | + | .post( | |
| 455 | + | "/:repo/issues/:number/edit", | |
| 456 | + | async ({ params, body, cookie }) => { | |
| 457 | + | const user = await resolveSession(cookie.session.value); | |
| 458 | + | const deny = requireAuth(user); | |
| 459 | + | if (deny) return deny; | |
| 460 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 461 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 462 | + | ||
| 463 | + | const issueNum = parseInt(params.number, 10); | |
| 464 | + | const issue = await db | |
| 465 | + | .selectFrom("issues") | |
| 466 | + | .select(["id", "author_id"]) | |
| 467 | + | .where("repo_id", "=", repo.id) | |
| 468 | + | .where("number", "=", issueNum) | |
| 469 | + | .executeTakeFirst(); | |
| 470 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 471 | + | if (issue.author_id !== user?.id && !user?.isAdmin) | |
| 472 | + | return new Response("Forbidden", { status: 403 }); | |
| 473 | + | ||
| 474 | + | await db | |
| 475 | + | .updateTable("issues") | |
| 476 | + | .set({ | |
| 477 | + | title: body.title.trim(), | |
| 478 | + | body: body.edit_body ?? "", | |
| 479 | + | edited_at: new Date().toISOString(), | |
| 480 | + | updated_at: new Date().toISOString(), | |
| 481 | + | }) | |
| 482 | + | .where("id", "=", issue.id) | |
| 483 | + | .execute(); | |
| 484 | + | ||
| 485 | + | return new Response(null, { | |
| 486 | + | status: 302, | |
| 487 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 488 | + | }); | |
| 489 | + | }, | |
| 490 | + | { | |
| 491 | + | body: t.Object({ | |
| 492 | + | title: t.String(), | |
| 493 | + | edit_body: t.Optional(t.String()), | |
| 494 | + | }), | |
| 495 | + | }, | |
| 496 | + | ) | |
| 497 | + | ||
| 498 | + | .post( | |
| 499 | + | "/:repo/issues/:number/comments/:id/edit", | |
| 500 | + | async ({ params, body, cookie }) => { | |
| 501 | + | const user = await resolveSession(cookie.session.value); | |
| 502 | + | const deny = requireAuth(user); | |
| 503 | + | if (deny) return deny; | |
| 504 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 505 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 506 | + | ||
| 507 | + | const comment = await db | |
| 508 | + | .selectFrom("issue_comments") | |
| 509 | + | .select(["id", "author_id", "issue_id"]) | |
| 510 | + | .where("id", "=", params.id) | |
| 511 | + | .executeTakeFirst(); | |
| 512 | + | if (!comment) return new Response("Not found", { status: 404 }); | |
| 513 | + | if (comment.author_id !== user?.id && !user?.isAdmin) | |
| 514 | + | return new Response("Forbidden", { status: 403 }); | |
| 515 | + | ||
| 516 | + | const issueNum = parseInt(params.number, 10); | |
| 517 | + | await db | |
| 518 | + | .updateTable("issue_comments") | |
| 519 | + | .set({ | |
| 520 | + | body: body.edit_body.trim(), | |
| 521 | + | edited_at: new Date().toISOString(), | |
| 522 | + | }) | |
| 523 | + | .where("id", "=", comment.id) | |
| 524 | + | .execute(); | |
| 525 | + | ||
| 526 | + | return new Response(null, { | |
| 527 | + | status: 302, | |
| 528 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 529 | + | }); | |
| 530 | + | }, | |
| 531 | + | { | |
| 532 | + | params: t.Object({ | |
| 533 | + | repo: t.String(), | |
| 534 | + | number: t.String(), | |
| 535 | + | id: t.Numeric(), | |
| 536 | + | }), | |
| 537 | + | body: t.Object({ edit_body: t.String() }), | |
| 538 | + | }, | |
| 539 | + | ); | |
Asrc/routes/patches.tsx
| @@ -0,0 +1,698 @@ | |||
|---|---|---|---|
| 1 | + | import { Elysia, t } from "elysia"; | |
| 2 | + | import { sql } from "kysely"; | |
| 3 | + | import { MAX_USER_UPLOAD_BYTES } from "../config.ts"; | |
| 4 | + | import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts"; | |
| 5 | + | import { db, getRepo } from "../db/index.ts"; | |
| 6 | + | import { | |
| 7 | + | requireAdmin, | |
| 8 | + | requireAuth, | |
| 9 | + | resolveSession, | |
| 10 | + | } from "../middleware/session.ts"; | |
| 11 | + | import { git } from "../services/git.ts"; | |
| 12 | + | import { prepareDiff } from "../services/highlightWorker.ts"; | |
| 13 | + | import { renderMarkdown } from "../services/markdown.ts"; | |
| 14 | + | import { patchCache } from "../services/patchCache.ts"; | |
| 15 | + | import { buildReactionCounts } from "../services/reactions.ts"; | |
| 16 | + | import { NewPatch } from "../views/patches/NewPatch.tsx"; | |
| 17 | + | import { PatchDetail } from "../views/patches/PatchDetail.tsx"; | |
| 18 | + | import { PatchList } from "../views/patches/PatchList.tsx"; | |
| 19 | + | import { html } from "../views/render.tsx"; | |
| 20 | + | ||
| 21 | + | function isValidPatch(content: string): boolean { | |
| 22 | + | const lines = content.split("\n"); | |
| 23 | + | return lines.some( | |
| 24 | + | (l) => | |
| 25 | + | l.startsWith("diff --git ") || | |
| 26 | + | l.startsWith("--- ") || | |
| 27 | + | l.startsWith("+++ ") || | |
| 28 | + | l.startsWith("@@ ") || | |
| 29 | + | l.startsWith("Index: "), | |
| 30 | + | ); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | async function runPatchCheck( | |
| 34 | + | repoName: string, | |
| 35 | + | patchId: number, | |
| 36 | + | patchContent: string, | |
| 37 | + | ) { | |
| 38 | + | const result = await git.checkPatch(repoName, patchContent); | |
| 39 | + | const applyResult = { | |
| 40 | + | status: result.clean ? ("clean" as const) : ("conflict" as const), | |
| 41 | + | output: result.output, | |
| 42 | + | }; | |
| 43 | + | patchCache.set(patchId, applyResult); | |
| 44 | + | return applyResult; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export const patchRoutes = new Elysia() | |
| 48 | + | .guard({ | |
| 49 | + | cookie: t.Cookie({ session: t.Optional(t.String()) }), | |
| 50 | + | }) | |
| 51 | + | .get( | |
| 52 | + | "/:repo/patches", | |
| 53 | + | async ({ params, query, cookie }) => { | |
| 54 | + | const user = await resolveSession(cookie.session.value); | |
| 55 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 56 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 57 | + | ||
| 58 | + | const status = ["open", "merged", "closed"].includes( | |
| 59 | + | query.status ?? "", | |
| 60 | + | ) | |
| 61 | + | ? query.status! | |
| 62 | + | : "open"; | |
| 63 | + | const page = Math.max(1, query.page ?? 1); | |
| 64 | + | ||
| 65 | + | const allCounts = await db | |
| 66 | + | .selectFrom("patches") | |
| 67 | + | .select(["status", db.fn.countAll<number>().as("count")]) | |
| 68 | + | .where("repo_id", "=", repo.id) | |
| 69 | + | .groupBy("status") | |
| 70 | + | .execute(); | |
| 71 | + | const counts: Record<string, number> = Object.fromEntries( | |
| 72 | + | allCounts.map((r) => [r.status, Number(r.count)]), | |
| 73 | + | ); | |
| 74 | + | const totalPages = Math.max( | |
| 75 | + | 1, | |
| 76 | + | Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE), | |
| 77 | + | ); | |
| 78 | + | const safePage = Math.min(page, totalPages); | |
| 79 | + | const offset = (safePage - 1) * PATCHES_PER_PAGE; | |
| 80 | + | ||
| 81 | + | const patches = await db | |
| 82 | + | .selectFrom("patches") | |
| 83 | + | .leftJoin("users", "users.id", "patches.author_id") | |
| 84 | + | .select([ | |
| 85 | + | "patches.id", | |
| 86 | + | "patches.repo_id", | |
| 87 | + | "patches.author_id", | |
| 88 | + | "patches.number", | |
| 89 | + | "patches.title", | |
| 90 | + | "patches.description", | |
| 91 | + | "patches.patch_content", | |
| 92 | + | "patches.status", | |
| 93 | + | "patches.created_at", | |
| 94 | + | "patches.updated_at", | |
| 95 | + | "patches.edited_at", | |
| 96 | + | "users.username as author_username", | |
| 97 | + | ]) | |
| 98 | + | .where("patches.repo_id", "=", repo.id) | |
| 99 | + | .where("patches.status", "=", status) | |
| 100 | + | .orderBy("patches.number", "desc") | |
| 101 | + | .limit(PATCHES_PER_PAGE) | |
| 102 | + | .offset(offset) | |
| 103 | + | .execute(); | |
| 104 | + | ||
| 105 | + | const pagination = { | |
| 106 | + | page: safePage, | |
| 107 | + | totalPages, | |
| 108 | + | pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`, | |
| 109 | + | }; | |
| 110 | + | return html( | |
| 111 | + | <PatchList | |
| 112 | + | user={user} | |
| 113 | + | repo={repo} | |
| 114 | + | patches={ | |
| 115 | + | patches as ((typeof patches)[0] & { | |
| 116 | + | author_username: string; | |
| 117 | + | })[] | |
| 118 | + | } | |
| 119 | + | status={status} | |
| 120 | + | counts={counts} | |
| 121 | + | pagination={pagination} | |
| 122 | + | />, | |
| 123 | + | ); | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | query: t.Object({ | |
| 127 | + | status: t.Optional(t.String()), | |
| 128 | + | page: t.Optional(t.Numeric()), | |
| 129 | + | }), | |
| 130 | + | }, | |
| 131 | + | ) | |
| 132 | + | ||
| 133 | + | .get("/:repo/patches/new", async ({ params, cookie }) => { | |
| 134 | + | const user = await resolveSession(cookie.session.value); | |
| 135 | + | const deny = requireAuth(user); | |
| 136 | + | if (deny) return deny; | |
| 137 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 138 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 139 | + | return html(<NewPatch user={user!} repo={repo} />); | |
| 140 | + | }) | |
| 141 | + | ||
| 142 | + | .post( | |
| 143 | + | "/:repo/patches", | |
| 144 | + | async ({ params, body, cookie }) => { | |
| 145 | + | const user = await resolveSession(cookie.session.value); | |
| 146 | + | const deny = requireAuth(user); | |
| 147 | + | if (deny) return deny; | |
| 148 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 149 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 150 | + | ||
| 151 | + | if (!body.title?.trim()) { | |
| 152 | + | return html( | |
| 153 | + | <NewPatch | |
| 154 | + | user={user!} | |
| 155 | + | repo={repo} | |
| 156 | + | error="Title is required" | |
| 157 | + | />, | |
| 158 | + | ); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | if (!body.patch_file) { | |
| 162 | + | return html( | |
| 163 | + | <NewPatch | |
| 164 | + | user={user!} | |
| 165 | + | repo={repo} | |
| 166 | + | error="Patch file is required" | |
| 167 | + | />, | |
| 168 | + | ); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) { | |
| 172 | + | return html( | |
| 173 | + | <NewPatch | |
| 174 | + | user={user!} | |
| 175 | + | repo={repo} | |
| 176 | + | error="Patch file is too large" | |
| 177 | + | />, | |
| 178 | + | ); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | const patchContent = await body.patch_file.text(); | |
| 182 | + | if (!patchContent.trim()) { | |
| 183 | + | return html( | |
| 184 | + | <NewPatch | |
| 185 | + | user={user!} | |
| 186 | + | repo={repo} | |
| 187 | + | error="Patch file is empty" | |
| 188 | + | />, | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Validate it looks like a patch/diff file | |
| 193 | + | if (!isValidPatch(patchContent)) { | |
| 194 | + | return html( | |
| 195 | + | <NewPatch | |
| 196 | + | user={user!} | |
| 197 | + | repo={repo} | |
| 198 | + | error="File does not appear to be a valid patch or diff file" | |
| 199 | + | />, | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | const now = new Date().toISOString(); | |
| 204 | + | const { number, result } = await db | |
| 205 | + | .transaction() | |
| 206 | + | .execute(async (trx) => { | |
| 207 | + | const { patch_seq } = await trx | |
| 208 | + | .updateTable("repositories") | |
| 209 | + | .set({ patch_seq: sql`patch_seq + 1` }) | |
| 210 | + | .where("id", "=", repo.id) | |
| 211 | + | .returning("patch_seq") | |
| 212 | + | .executeTakeFirstOrThrow(); | |
| 213 | + | const inserted = await trx | |
| 214 | + | .insertInto("patches") | |
| 215 | + | .values({ | |
| 216 | + | repo_id: repo.id, | |
| 217 | + | author_id: user?.id, | |
| 218 | + | number: patch_seq, | |
| 219 | + | title: body.title!.trim(), | |
| 220 | + | description: body.description?.trim() ?? "", | |
| 221 | + | patch_content: patchContent, | |
| 222 | + | status: "open", | |
| 223 | + | created_at: now, | |
| 224 | + | updated_at: now, | |
| 225 | + | }) | |
| 226 | + | .returning("id") | |
| 227 | + | .executeTakeFirstOrThrow(); | |
| 228 | + | return { number: patch_seq, result: inserted }; | |
| 229 | + | }); | |
| 230 | + | ||
| 231 | + | await runPatchCheck(repo.name, result.id, patchContent); | |
| 232 | + | ||
| 233 | + | return new Response(null, { | |
| 234 | + | status: 302, | |
| 235 | + | headers: { Location: `/${repo.name}/patches/${number}` }, | |
| 236 | + | }); | |
| 237 | + | }, | |
| 238 | + | { | |
| 239 | + | body: t.Object({ | |
| 240 | + | title: t.Optional(t.String()), | |
| 241 | + | description: t.Optional(t.String()), | |
| 242 | + | patch_file: t.Optional(t.File()), | |
| 243 | + | }), | |
| 244 | + | }, | |
| 245 | + | ) | |
| 246 | + | ||
| 247 | + | .get( | |
| 248 | + | "/:repo/patches/:number", | |
| 249 | + | async ({ params, query, cookie }) => { | |
| 250 | + | const user = await resolveSession(cookie.session.value); | |
| 251 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 252 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 253 | + | ||
| 254 | + | const patchNum = parseInt(params.number, 10); | |
| 255 | + | const patch = await db | |
| 256 | + | .selectFrom("patches") | |
| 257 | + | .leftJoin("users", "users.id", "patches.author_id") | |
| 258 | + | .select([ | |
| 259 | + | "patches.id", | |
| 260 | + | "patches.repo_id", | |
| 261 | + | "patches.author_id", | |
| 262 | + | "patches.number", | |
| 263 | + | "patches.title", | |
| 264 | + | "patches.description", | |
| 265 | + | "patches.patch_content", | |
| 266 | + | "patches.status", | |
| 267 | + | "patches.created_at", | |
| 268 | + | "patches.updated_at", | |
| 269 | + | "patches.edited_at", | |
| 270 | + | "users.username as author_username", | |
| 271 | + | "users.avatar_version as author_avatar_version", | |
| 272 | + | ]) | |
| 273 | + | .where("patches.repo_id", "=", repo.id) | |
| 274 | + | .where("patches.number", "=", patchNum) | |
| 275 | + | .executeTakeFirst(); | |
| 276 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 277 | + | ||
| 278 | + | const descriptionHtml = patch.description | |
| 279 | + | ? renderMarkdown(patch.description) | |
| 280 | + | : ""; | |
| 281 | + | ||
| 282 | + | let applyResult = patchCache.get(patch.id) ?? null; | |
| 283 | + | // Cold cache (e.g. server restart) — re-check synchronously for open patches only | |
| 284 | + | if (!applyResult && patch.status === "open") { | |
| 285 | + | applyResult = await runPatchCheck( | |
| 286 | + | repo.name, | |
| 287 | + | patch.id, | |
| 288 | + | patch.patch_content, | |
| 289 | + | ); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | const files = await prepareDiff( | |
| 293 | + | patch.patch_content, | |
| 294 | + | `patch:${patch.id}`, | |
| 295 | + | ); | |
| 296 | + | ||
| 297 | + | const comments = await db | |
| 298 | + | .selectFrom("patch_comments") | |
| 299 | + | .leftJoin("users", "users.id", "patch_comments.author_id") | |
| 300 | + | .select([ | |
| 301 | + | "patch_comments.id", | |
| 302 | + | "patch_comments.patch_id", | |
| 303 | + | "patch_comments.author_id", | |
| 304 | + | "patch_comments.body", | |
| 305 | + | "patch_comments.created_at", | |
| 306 | + | "patch_comments.edited_at", | |
| 307 | + | "users.username as author_username", | |
| 308 | + | "users.avatar_version as author_avatar_version", | |
| 309 | + | ]) | |
| 310 | + | .where("patch_comments.patch_id", "=", patch.id) | |
| 311 | + | .orderBy("patch_comments.created_at", "asc") | |
| 312 | + | .execute(); | |
| 313 | + | ||
| 314 | + | const commentsWithHtml = comments.map((c) => ({ | |
| 315 | + | ...c, | |
| 316 | + | bodyHtml: renderMarkdown(c.body), | |
| 317 | + | })); | |
| 318 | + | ||
| 319 | + | const allReactions = await db | |
| 320 | + | .selectFrom("patch_reactions") | |
| 321 | + | .selectAll() | |
| 322 | + | .where("patch_id", "=", patch.id) | |
| 323 | + | .execute(); | |
| 324 | + | ||
| 325 | + | const reactions = buildReactionCounts(allReactions, null, user?.id); | |
| 326 | + | const commentReactions = new Map( | |
| 327 | + | comments.map((c) => [ | |
| 328 | + | c.id, | |
| 329 | + | buildReactionCounts(allReactions, c.id, user?.id), | |
| 330 | + | ]), | |
| 331 | + | ); | |
| 332 | + | ||
| 333 | + | const tab = | |
| 334 | + | query.tab === "changes" | |
| 335 | + | ? ("changes" as const) | |
| 336 | + | : ("conversation" as const); | |
| 337 | + | ||
| 338 | + | return html( | |
| 339 | + | <PatchDetail | |
| 340 | + | user={user} | |
| 341 | + | repo={repo} | |
| 342 | + | patch={ | |
| 343 | + | patch as typeof patch & { | |
| 344 | + | author_username: string; | |
| 345 | + | author_avatar_version: number | null; | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | descriptionHtml={descriptionHtml} | |
| 349 | + | applyResult={applyResult} | |
| 350 | + | files={files} | |
| 351 | + | tab={tab} | |
| 352 | + | comments={ | |
| 353 | + | commentsWithHtml as ((typeof commentsWithHtml)[0] & { | |
| 354 | + | author_username: string; | |
| 355 | + | author_avatar_version: number | null; | |
| 356 | + | })[] | |
| 357 | + | } | |
| 358 | + | reactions={reactions} | |
| 359 | + | commentReactions={commentReactions} | |
| 360 | + | />, | |
| 361 | + | ); | |
| 362 | + | }, | |
| 363 | + | { | |
| 364 | + | query: t.Object({ tab: t.Optional(t.String()) }), | |
| 365 | + | }, | |
| 366 | + | ) | |
| 367 | + | ||
| 368 | + | .post("/:repo/patches/:number/merge", async ({ params, cookie }) => { | |
| 369 | + | const user = await resolveSession(cookie.session.value); | |
| 370 | + | const deny = requireAdmin(user); | |
| 371 | + | if (deny) return deny; | |
| 372 | + | const repo = await getRepo(params.repo, true); | |
| 373 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 374 | + | ||
| 375 | + | const patchNum = parseInt(params.number, 10); | |
| 376 | + | const patch = await db | |
| 377 | + | .selectFrom("patches") | |
| 378 | + | .select(["id", "title", "description", "patch_content", "status"]) | |
| 379 | + | .where("repo_id", "=", repo.id) | |
| 380 | + | .where("number", "=", patchNum) | |
| 381 | + | .executeTakeFirst(); | |
| 382 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 383 | + | ||
| 384 | + | // Atomically claim the merge slot before the slow git operation to | |
| 385 | + | // prevent two concurrent requests from both applying the same patch. | |
| 386 | + | const claimed = await db | |
| 387 | + | .updateTable("patches") | |
| 388 | + | .set({ status: "merged", updated_at: new Date().toISOString() }) | |
| 389 | + | .where("id", "=", patch.id) | |
| 390 | + | .where("status", "=", "open") | |
| 391 | + | .executeTakeFirst(); | |
| 392 | + | if (!claimed || claimed.numUpdatedRows === 0n) | |
| 393 | + | return new Response("Patch is not open", { status: 400 }); | |
| 394 | + | ||
| 395 | + | try { | |
| 396 | + | await git.applyPatch( | |
| 397 | + | repo.name, | |
| 398 | + | patch.patch_content, | |
| 399 | + | patch.title, | |
| 400 | + | patch.description, | |
| 401 | + | ); | |
| 402 | + | } catch (err) { | |
| 403 | + | // Roll back the status if the git operation fails | |
| 404 | + | await db | |
| 405 | + | .updateTable("patches") | |
| 406 | + | .set({ status: "open", updated_at: new Date().toISOString() }) | |
| 407 | + | .where("id", "=", patch.id) | |
| 408 | + | .execute(); | |
| 409 | + | throw err; | |
| 410 | + | } | |
| 411 | + | patchCache.invalidate(patch.id); | |
| 412 | + | ||
| 413 | + | return new Response(null, { | |
| 414 | + | status: 302, | |
| 415 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 416 | + | }); | |
| 417 | + | }) | |
| 418 | + | ||
| 419 | + | .post("/:repo/patches/:number/close", async ({ params, cookie }) => { | |
| 420 | + | const user = await resolveSession(cookie.session.value); | |
| 421 | + | const deny = requireAdmin(user); | |
| 422 | + | if (deny) return deny; | |
| 423 | + | const repo = await getRepo(params.repo, true); | |
| 424 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 425 | + | ||
| 426 | + | const patchNum = parseInt(params.number, 10); | |
| 427 | + | const patch = await db | |
| 428 | + | .selectFrom("patches") | |
| 429 | + | .select("id") | |
| 430 | + | .where("repo_id", "=", repo.id) | |
| 431 | + | .where("number", "=", patchNum) | |
| 432 | + | .executeTakeFirst(); | |
| 433 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 434 | + | ||
| 435 | + | // Toggle open↔closed atomically; exclude merged patches from the WHERE | |
| 436 | + | // so that numUpdatedRows = 0 means the patch is merged (or gone). | |
| 437 | + | const toggled = await db | |
| 438 | + | .updateTable("patches") | |
| 439 | + | .set({ | |
| 440 | + | status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`, | |
| 441 | + | updated_at: new Date().toISOString(), | |
| 442 | + | }) | |
| 443 | + | .where("id", "=", patch.id) | |
| 444 | + | .where("status", "!=", "merged") | |
| 445 | + | .executeTakeFirst(); | |
| 446 | + | if (!toggled || toggled.numUpdatedRows === 0n) | |
| 447 | + | return new Response("Patch is merged", { status: 400 }); | |
| 448 | + | ||
| 449 | + | return new Response(null, { | |
| 450 | + | status: 302, | |
| 451 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 452 | + | }); | |
| 453 | + | }) | |
| 454 | + | ||
| 455 | + | .post("/:repo/patches/:number/delete", async ({ params, cookie }) => { | |
| 456 | + | const user = await resolveSession(cookie.session.value); | |
| 457 | + | const deny = requireAuth(user); | |
| 458 | + | if (deny) return deny; | |
| 459 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 460 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 461 | + | ||
| 462 | + | const patchNum = parseInt(params.number, 10); | |
| 463 | + | const patch = await db | |
| 464 | + | .selectFrom("patches") | |
| 465 | + | .select(["id", "author_id"]) | |
| 466 | + | .where("repo_id", "=", repo.id) | |
| 467 | + | .where("number", "=", patchNum) | |
| 468 | + | .executeTakeFirst(); | |
| 469 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 470 | + | if (patch.author_id !== user?.id && !user?.isAdmin) | |
| 471 | + | return new Response("Forbidden", { status: 403 }); | |
| 472 | + | ||
| 473 | + | patchCache.invalidate(patch.id); | |
| 474 | + | await db.deleteFrom("patches").where("id", "=", patch.id).execute(); | |
| 475 | + | ||
| 476 | + | return new Response(null, { | |
| 477 | + | status: 302, | |
| 478 | + | headers: { Location: `/${repo.name}/patches` }, | |
| 479 | + | }); | |
| 480 | + | }) | |
| 481 | + | ||
| 482 | + | .post( | |
| 483 | + | "/:repo/patches/:number/comments", | |
| 484 | + | async ({ params, body, cookie }) => { | |
| 485 | + | const user = await resolveSession(cookie.session.value); | |
| 486 | + | const deny = requireAuth(user); | |
| 487 | + | if (deny) return deny; | |
| 488 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 489 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 490 | + | ||
| 491 | + | const patchNum = parseInt(params.number, 10); | |
| 492 | + | const patch = await db | |
| 493 | + | .selectFrom("patches") | |
| 494 | + | .select(["id", "status"]) | |
| 495 | + | .where("repo_id", "=", repo.id) | |
| 496 | + | .where("number", "=", patchNum) | |
| 497 | + | .executeTakeFirst(); | |
| 498 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 499 | + | ||
| 500 | + | const { body: commentBody } = body; | |
| 501 | + | if (!commentBody?.trim()) { | |
| 502 | + | return new Response(null, { | |
| 503 | + | status: 302, | |
| 504 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 505 | + | }); | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | await db.transaction().execute(async (trx) => { | |
| 509 | + | const now = new Date().toISOString(); | |
| 510 | + | await trx | |
| 511 | + | .insertInto("patch_comments") | |
| 512 | + | .values({ | |
| 513 | + | patch_id: patch.id, | |
| 514 | + | author_id: user?.id, | |
| 515 | + | body: commentBody.trim(), | |
| 516 | + | created_at: now, | |
| 517 | + | }) | |
| 518 | + | .execute(); | |
| 519 | + | await trx | |
| 520 | + | .updateTable("patches") | |
| 521 | + | .set({ updated_at: now }) | |
| 522 | + | .where("id", "=", patch.id) | |
| 523 | + | .execute(); | |
| 524 | + | }); | |
| 525 | + | ||
| 526 | + | return new Response(null, { | |
| 527 | + | status: 302, | |
| 528 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 529 | + | }); | |
| 530 | + | }, | |
| 531 | + | { | |
| 532 | + | body: t.Object({ body: t.String() }), | |
| 533 | + | }, | |
| 534 | + | ) | |
| 535 | + | ||
| 536 | + | .post( | |
| 537 | + | "/:repo/patches/:number/react", | |
| 538 | + | async ({ params, body, cookie }) => { | |
| 539 | + | const user = await resolveSession(cookie.session.value); | |
| 540 | + | const deny = requireAuth(user); | |
| 541 | + | if (deny) return deny; | |
| 542 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 543 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 544 | + | ||
| 545 | + | const { emoji, comment_id } = body; | |
| 546 | + | if (!ALLOWED_REACTIONS.has(emoji)) { | |
| 547 | + | return new Response("Invalid emoji", { status: 400 }); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | const patchNum = parseInt(params.number, 10); | |
| 551 | + | const patch = await db | |
| 552 | + | .selectFrom("patches") | |
| 553 | + | .select(["id"]) | |
| 554 | + | .where("repo_id", "=", repo.id) | |
| 555 | + | .where("number", "=", patchNum) | |
| 556 | + | .executeTakeFirst(); | |
| 557 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 558 | + | ||
| 559 | + | const commentId = comment_id ? parseInt(comment_id, 10) : null; | |
| 560 | + | ||
| 561 | + | await db.transaction().execute(async (trx) => { | |
| 562 | + | const existing = await trx | |
| 563 | + | .selectFrom("patch_reactions") | |
| 564 | + | .select(["id", "emoji"]) | |
| 565 | + | .where("patch_id", "=", patch.id) | |
| 566 | + | .where((eb) => | |
| 567 | + | commentId !== null | |
| 568 | + | ? eb("comment_id", "=", commentId) | |
| 569 | + | : eb("comment_id", "is", null), | |
| 570 | + | ) | |
| 571 | + | .where("user_id", "=", user!.id) | |
| 572 | + | .executeTakeFirst(); | |
| 573 | + | ||
| 574 | + | if (existing) { | |
| 575 | + | if (existing.emoji === emoji) { | |
| 576 | + | await trx | |
| 577 | + | .deleteFrom("patch_reactions") | |
| 578 | + | .where("id", "=", existing.id) | |
| 579 | + | .execute(); | |
| 580 | + | } else { | |
| 581 | + | await trx | |
| 582 | + | .updateTable("patch_reactions") | |
| 583 | + | .set({ emoji }) | |
| 584 | + | .where("id", "=", existing.id) | |
| 585 | + | .execute(); | |
| 586 | + | } | |
| 587 | + | } else { | |
| 588 | + | await trx | |
| 589 | + | .insertInto("patch_reactions") | |
| 590 | + | .values({ | |
| 591 | + | patch_id: patch.id, | |
| 592 | + | comment_id: commentId, | |
| 593 | + | user_id: user!.id, | |
| 594 | + | emoji, | |
| 595 | + | }) | |
| 596 | + | .execute(); | |
| 597 | + | } | |
| 598 | + | }); | |
| 599 | + | ||
| 600 | + | return new Response(null, { | |
| 601 | + | status: 303, | |
| 602 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 603 | + | }); | |
| 604 | + | }, | |
| 605 | + | { | |
| 606 | + | body: t.Object({ | |
| 607 | + | emoji: t.String(), | |
| 608 | + | comment_id: t.Optional(t.String()), | |
| 609 | + | }), | |
| 610 | + | }, | |
| 611 | + | ) | |
| 612 | + | ||
| 613 | + | .post( | |
| 614 | + | "/:repo/patches/:number/comments/:id/edit", | |
| 615 | + | async ({ params, body, cookie }) => { | |
| 616 | + | const user = await resolveSession(cookie.session.value); | |
| 617 | + | const deny = requireAuth(user); | |
| 618 | + | if (deny) return deny; | |
| 619 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 620 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 621 | + | ||
| 622 | + | const comment = await db | |
| 623 | + | .selectFrom("patch_comments") | |
| 624 | + | .select(["id", "author_id"]) | |
| 625 | + | .where("id", "=", params.id) | |
| 626 | + | .executeTakeFirst(); | |
| 627 | + | if (!comment) return new Response("Not found", { status: 404 }); | |
| 628 | + | if (comment.author_id !== user?.id && !user?.isAdmin) | |
| 629 | + | return new Response("Forbidden", { status: 403 }); | |
| 630 | + | ||
| 631 | + | const patchNum = parseInt(params.number, 10); | |
| 632 | + | await db | |
| 633 | + | .updateTable("patch_comments") | |
| 634 | + | .set({ | |
| 635 | + | body: body.edit_body.trim(), | |
| 636 | + | edited_at: new Date().toISOString(), | |
| 637 | + | }) | |
| 638 | + | .where("id", "=", comment.id) | |
| 639 | + | .execute(); | |
| 640 | + | ||
| 641 | + | return new Response(null, { | |
| 642 | + | status: 302, | |
| 643 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 644 | + | }); | |
| 645 | + | }, | |
| 646 | + | { | |
| 647 | + | params: t.Object({ | |
| 648 | + | repo: t.String(), | |
| 649 | + | number: t.String(), | |
| 650 | + | id: t.Numeric(), | |
| 651 | + | }), | |
| 652 | + | body: t.Object({ edit_body: t.String() }), | |
| 653 | + | }, | |
| 654 | + | ) | |
| 655 | + | ||
| 656 | + | .post( | |
| 657 | + | "/:repo/patches/:number/edit", | |
| 658 | + | async ({ params, body, cookie }) => { | |
| 659 | + | const user = await resolveSession(cookie.session.value); | |
| 660 | + | const deny = requireAuth(user); | |
| 661 | + | if (deny) return deny; | |
| 662 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 663 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 664 | + | ||
| 665 | + | const patchNum = parseInt(params.number, 10); | |
| 666 | + | const patch = await db | |
| 667 | + | .selectFrom("patches") | |
| 668 | + | .select(["id", "author_id"]) | |
| 669 | + | .where("repo_id", "=", repo.id) | |
| 670 | + | .where("number", "=", patchNum) | |
| 671 | + | .executeTakeFirst(); | |
| 672 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 673 | + | if (patch.author_id !== user?.id && !user?.isAdmin) | |
| 674 | + | return new Response("Forbidden", { status: 403 }); | |
| 675 | + | ||
| 676 | + | await db | |
| 677 | + | .updateTable("patches") | |
| 678 | + | .set({ | |
| 679 | + | title: body.title.trim(), | |
| 680 | + | description: body.edit_description ?? "", | |
| 681 | + | edited_at: new Date().toISOString(), | |
| 682 | + | updated_at: new Date().toISOString(), | |
| 683 | + | }) | |
| 684 | + | .where("id", "=", patch.id) | |
| 685 | + | .execute(); | |
| 686 | + | ||
| 687 | + | return new Response(null, { | |
| 688 | + | status: 302, | |
| 689 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 690 | + | }); | |
| 691 | + | }, | |
| 692 | + | { | |
| 693 | + | body: t.Object({ | |
| 694 | + | title: t.String(), | |
| 695 | + | edit_description: t.Optional(t.String()), | |
| 696 | + | }), | |
| 697 | + | }, | |
| 698 | + | ); | |
Asrc/routes/releases.tsx
| @@ -0,0 +1,575 @@ | |||
|---|---|---|---|
| 1 | + | import { mkdirSync, rmSync } from "node:fs"; | |
| 2 | + | import path from "node:path"; | |
| 3 | + | import { Elysia, t } from "elysia"; | |
| 4 | + | import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts"; | |
| 5 | + | import { db, getRepo } from "../db/index.ts"; | |
| 6 | + | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 7 | + | import { archiveRepo, validateCommit } from "../services/git.ts"; | |
| 8 | + | import { renderMarkdown } from "../services/markdown.ts"; | |
| 9 | + | import { NewRelease } from "../views/releases/NewRelease.tsx"; | |
| 10 | + | import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx"; | |
| 11 | + | import { ReleaseList } from "../views/releases/ReleaseList.tsx"; | |
| 12 | + | import { html } from "../views/render.tsx"; | |
| 13 | + | ||
| 14 | + | // Tracks AbortControllers for source archive generation tasks that are | |
| 15 | + | // currently in progress, keyed by release ID. Used to cancel generation | |
| 16 | + | // immediately when the corresponding release is deleted. | |
| 17 | + | const archivingTasks = new Map<number, AbortController>(); | |
| 18 | + | ||
| 19 | + | function sanitizeFilename(name: string): string { | |
| 20 | + | const safe = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 21 | + | if (!safe || /^\.+$/.test(safe)) return "_"; | |
| 22 | + | return safe; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export const releasesRoutes = new Elysia() | |
| 26 | + | .guard({ | |
| 27 | + | cookie: t.Cookie({ session: t.Optional(t.String()) }), | |
| 28 | + | }) | |
| 29 | + | ||
| 30 | + | .get( | |
| 31 | + | "/:repo/releases", | |
| 32 | + | async ({ params, query, cookie }) => { | |
| 33 | + | const user = await resolveSession(cookie.session.value); | |
| 34 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 35 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 36 | + | ||
| 37 | + | const page = Math.max(1, query.page ?? 1); | |
| 38 | + | ||
| 39 | + | const countRow = await db | |
| 40 | + | .selectFrom("releases") | |
| 41 | + | .select(db.fn.countAll<number>().as("count")) | |
| 42 | + | .where("repo_id", "=", repo.id) | |
| 43 | + | .executeTakeFirst(); | |
| 44 | + | const totalPages = Math.max( | |
| 45 | + | 1, | |
| 46 | + | Math.ceil(Number(countRow?.count ?? 0) / RELEASES_PER_PAGE), | |
| 47 | + | ); | |
| 48 | + | const safePage = Math.min(page, totalPages); | |
| 49 | + | const offset = (safePage - 1) * RELEASES_PER_PAGE; | |
| 50 | + | ||
| 51 | + | const releasesRaw = await db | |
| 52 | + | .selectFrom("releases") | |
| 53 | + | .selectAll() | |
| 54 | + | .where("repo_id", "=", repo.id) | |
| 55 | + | .orderBy("id", "desc") | |
| 56 | + | .limit(RELEASES_PER_PAGE) | |
| 57 | + | .offset(offset) | |
| 58 | + | .execute(); | |
| 59 | + | ||
| 60 | + | // Attach asset counts | |
| 61 | + | const releaseIds = releasesRaw.map((r) => r.id); | |
| 62 | + | const assetCounts = | |
| 63 | + | releaseIds.length > 0 | |
| 64 | + | ? await db | |
| 65 | + | .selectFrom("release_assets") | |
| 66 | + | .select([ | |
| 67 | + | "release_id", | |
| 68 | + | db.fn.countAll<number>().as("count"), | |
| 69 | + | ]) | |
| 70 | + | .where("release_id", "in", releaseIds) | |
| 71 | + | .groupBy("release_id") | |
| 72 | + | .execute() | |
| 73 | + | : []; | |
| 74 | + | const countMap = new Map( | |
| 75 | + | assetCounts.map((r) => [r.release_id, Number(r.count)]), | |
| 76 | + | ); | |
| 77 | + | ||
| 78 | + | const releases = releasesRaw.map((r) => ({ | |
| 79 | + | ...r, | |
| 80 | + | asset_count: countMap.get(r.id) ?? 0, | |
| 81 | + | })); | |
| 82 | + | ||
| 83 | + | const pagination = { | |
| 84 | + | page: safePage, | |
| 85 | + | totalPages, | |
| 86 | + | pageUrlTemplate: `/${repo.name}/releases?page={page}`, | |
| 87 | + | }; | |
| 88 | + | ||
| 89 | + | return html( | |
| 90 | + | <ReleaseList | |
| 91 | + | user={user} | |
| 92 | + | repo={repo} | |
| 93 | + | releases={releases} | |
| 94 | + | pagination={pagination} | |
| 95 | + | />, | |
| 96 | + | ); | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | query: t.Object({ | |
| 100 | + | page: t.Optional(t.Numeric()), | |
| 101 | + | }), | |
| 102 | + | }, | |
| 103 | + | ) | |
| 104 | + | ||
| 105 | + | .get("/:repo/releases/new", async ({ params, cookie }) => { | |
| 106 | + | const user = await resolveSession(cookie.session.value); | |
| 107 | + | const deny = requireAdmin(user); | |
| 108 | + | if (deny) return deny; | |
| 109 | + | const repo = await getRepo(params.repo, true); | |
| 110 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 111 | + | return html(<NewRelease user={user!} repo={repo} />); | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | .post( | |
| 115 | + | "/:repo/releases", | |
| 116 | + | async ({ params, body, cookie }) => { | |
| 117 | + | const user = await resolveSession(cookie.session.value); | |
| 118 | + | const deny = requireAdmin(user); | |
| 119 | + | if (deny) return deny; | |
| 120 | + | const repo = await getRepo(params.repo, true); | |
| 121 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 122 | + | ||
| 123 | + | const tagName = body.tag_name?.trim() ?? ""; | |
| 124 | + | const commitHash = body.commit_hash?.trim() ?? ""; | |
| 125 | + | ||
| 126 | + | if (!tagName) { | |
| 127 | + | return html( | |
| 128 | + | <NewRelease | |
| 129 | + | user={user!} | |
| 130 | + | repo={repo} | |
| 131 | + | error="Tag name is required" | |
| 132 | + | values={{ | |
| 133 | + | ...body, | |
| 134 | + | include_source_code: | |
| 135 | + | body.include_source_code === "on", | |
| 136 | + | }} | |
| 137 | + | />, | |
| 138 | + | ); | |
| 139 | + | } | |
| 140 | + | if (tagName.includes("/")) { | |
| 141 | + | return html( | |
| 142 | + | <NewRelease | |
| 143 | + | user={user!} | |
| 144 | + | repo={repo} | |
| 145 | + | error="Tag name must not contain slashes" | |
| 146 | + | values={{ | |
| 147 | + | ...body, | |
| 148 | + | include_source_code: | |
| 149 | + | body.include_source_code === "on", | |
| 150 | + | }} | |
| 151 | + | />, | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | if (!commitHash) { | |
| 155 | + | return html( | |
| 156 | + | <NewRelease | |
| 157 | + | user={user!} | |
| 158 | + | repo={repo} | |
| 159 | + | error="Commit hash is required" | |
| 160 | + | values={{ | |
| 161 | + | ...body, | |
| 162 | + | include_source_code: | |
| 163 | + | body.include_source_code === "on", | |
| 164 | + | }} | |
| 165 | + | />, | |
| 166 | + | ); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | const validCommit = await validateCommit(repo.name, commitHash); | |
| 170 | + | if (!validCommit) { | |
| 171 | + | return html( | |
| 172 | + | <NewRelease | |
| 173 | + | user={user!} | |
| 174 | + | repo={repo} | |
| 175 | + | error="Invalid commit hash — no matching commit found in this repository" | |
| 176 | + | values={{ | |
| 177 | + | ...body, | |
| 178 | + | include_source_code: | |
| 179 | + | body.include_source_code === "on", | |
| 180 | + | }} | |
| 181 | + | />, | |
| 182 | + | ); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Check for duplicate tag | |
| 186 | + | const existing = await db | |
| 187 | + | .selectFrom("releases") | |
| 188 | + | .select("id") | |
| 189 | + | .where("repo_id", "=", repo.id) | |
| 190 | + | .where("tag_name", "=", tagName) | |
| 191 | + | .executeTakeFirst(); | |
| 192 | + | if (existing) { | |
| 193 | + | return html( | |
| 194 | + | <NewRelease | |
| 195 | + | user={user!} | |
| 196 | + | repo={repo} | |
| 197 | + | error={`A release with tag "${tagName}" already exists`} | |
| 198 | + | values={{ | |
| 199 | + | ...body, | |
| 200 | + | include_source_code: | |
| 201 | + | body.include_source_code === "on", | |
| 202 | + | }} | |
| 203 | + | />, | |
| 204 | + | ); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | const includeSource = body.include_source_code === "on"; | |
| 208 | + | const now = new Date().toISOString(); | |
| 209 | + | ||
| 210 | + | // Collect uploaded file data before opening the transaction so we | |
| 211 | + | // don't hold it open across slow I/O. | |
| 212 | + | const rawFiles = body.files; | |
| 213 | + | const uploadedFiles: { | |
| 214 | + | filename: string; | |
| 215 | + | data: Blob; | |
| 216 | + | size: number; | |
| 217 | + | contentType: string; | |
| 218 | + | }[] = []; | |
| 219 | + | if (rawFiles) { | |
| 220 | + | const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles]; | |
| 221 | + | for (const file of files) { | |
| 222 | + | if (file.size === 0) continue; | |
| 223 | + | uploadedFiles.push({ | |
| 224 | + | filename: sanitizeFilename(file.name), | |
| 225 | + | data: file, | |
| 226 | + | size: file.size, | |
| 227 | + | contentType: file.type || "application/octet-stream", | |
| 228 | + | }); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Insert the release record and all asset records in one transaction | |
| 233 | + | // so that partial failures don't leave orphaned DB rows. | |
| 234 | + | // releaseDir is captured inside the callback so the catch can clean | |
| 235 | + | // up files even though the auto-increment ID isn't known until after | |
| 236 | + | // the INSERT. | |
| 237 | + | let releaseDir: string | null = null; | |
| 238 | + | const releaseId = await db | |
| 239 | + | .transaction() | |
| 240 | + | .execute(async (trx) => { | |
| 241 | + | const inserted = await trx | |
| 242 | + | .insertInto("releases") | |
| 243 | + | .values({ | |
| 244 | + | repo_id: repo.id, | |
| 245 | + | tag_name: tagName, | |
| 246 | + | name: body.name?.trim() || null, | |
| 247 | + | notes: body.notes?.trim() || null, | |
| 248 | + | commit_hash: commitHash, | |
| 249 | + | include_source_code: includeSource ? 1 : 0, | |
| 250 | + | created_at: now, | |
| 251 | + | }) | |
| 252 | + | .returning("id") | |
| 253 | + | .executeTakeFirstOrThrow(); | |
| 254 | + | ||
| 255 | + | const id = inserted.id; | |
| 256 | + | releaseDir = path.join(RELEASES_DIR, String(id)); | |
| 257 | + | ||
| 258 | + | if (includeSource) { | |
| 259 | + | const sourceDir = path.join(releaseDir, "source"); | |
| 260 | + | mkdirSync(sourceDir, { recursive: true }); | |
| 261 | + | // Write a sentinel file; the actual archives are | |
| 262 | + | // generated asynchronously after the response is sent. | |
| 263 | + | await Bun.write(path.join(sourceDir, ".pending"), ""); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | if (uploadedFiles.length > 0) { | |
| 267 | + | const assetsDir = path.join(releaseDir, "assets"); | |
| 268 | + | mkdirSync(assetsDir, { recursive: true }); | |
| 269 | + | for (const f of uploadedFiles) { | |
| 270 | + | await Bun.write( | |
| 271 | + | path.join(assetsDir, f.filename), | |
| 272 | + | f.data, | |
| 273 | + | ); | |
| 274 | + | await trx | |
| 275 | + | .insertInto("release_assets") | |
| 276 | + | .values({ | |
| 277 | + | release_id: id, | |
| 278 | + | filename: f.filename, | |
| 279 | + | size: f.size, | |
| 280 | + | content_type: f.contentType, | |
| 281 | + | created_at: now, | |
| 282 | + | }) | |
| 283 | + | .execute(); | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | return id; | |
| 288 | + | }) | |
| 289 | + | .catch((err) => { | |
| 290 | + | // Roll back any partially-written files if the transaction | |
| 291 | + | // failed — the DB rollback handles the DB side automatically. | |
| 292 | + | if (releaseDir) { | |
| 293 | + | rmSync(releaseDir, { recursive: true, force: true }); | |
| 294 | + | } | |
| 295 | + | throw err; | |
| 296 | + | }); | |
| 297 | + | ||
| 298 | + | // Kick off source archive generation in the background so the | |
| 299 | + | // response can be sent immediately. The .pending sentinel written | |
| 300 | + | // inside the transaction signals to the detail view that archives | |
| 301 | + | // are still being prepared. If the release is deleted while | |
| 302 | + | // generation is in progress the background task will hit errors | |
| 303 | + | // (the directory will have been removed) and silently bail out; | |
| 304 | + | // SQLite AUTOINCREMENT guarantees the ID is never reused, so there | |
| 305 | + | // is no risk of contaminating a later release. | |
| 306 | + | if (includeSource) { | |
| 307 | + | const sourceDir = path.join( | |
| 308 | + | RELEASES_DIR, | |
| 309 | + | String(releaseId), | |
| 310 | + | "source", | |
| 311 | + | ); | |
| 312 | + | const controller = new AbortController(); | |
| 313 | + | archivingTasks.set(releaseId, controller); | |
| 314 | + | (async () => { | |
| 315 | + | try { | |
| 316 | + | await archiveRepo( | |
| 317 | + | repo.name, | |
| 318 | + | commitHash, | |
| 319 | + | repo.name, | |
| 320 | + | sourceDir, | |
| 321 | + | controller.signal, | |
| 322 | + | ); | |
| 323 | + | rmSync(path.join(sourceDir, ".pending"), { | |
| 324 | + | force: true, | |
| 325 | + | }); | |
| 326 | + | } catch { | |
| 327 | + | // Either the release was deleted (abort) or archiving | |
| 328 | + | // failed. Remove the source dir so the UI shows no | |
| 329 | + | // stale state. | |
| 330 | + | rmSync(sourceDir, { recursive: true, force: true }); | |
| 331 | + | } finally { | |
| 332 | + | archivingTasks.delete(releaseId); | |
| 333 | + | } | |
| 334 | + | })(); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | return new Response(null, { | |
| 338 | + | status: 302, | |
| 339 | + | headers: { | |
| 340 | + | Location: `/${repo.name}/releases/${releaseId}`, | |
| 341 | + | }, | |
| 342 | + | }); | |
| 343 | + | }, | |
| 344 | + | { | |
| 345 | + | body: t.Object({ | |
| 346 | + | tag_name: t.Optional(t.String()), | |
| 347 | + | name: t.Optional(t.String()), | |
| 348 | + | notes: t.Optional(t.String()), | |
| 349 | + | commit_hash: t.Optional(t.String()), | |
| 350 | + | include_source_code: t.Optional(t.String()), | |
| 351 | + | files: t.Optional(t.Union([t.File(), t.Array(t.File())])), | |
| 352 | + | }), | |
| 353 | + | type: "multipart/form-data", | |
| 354 | + | }, | |
| 355 | + | ) | |
| 356 | + | ||
| 357 | + | .get( | |
| 358 | + | "/:repo/releases/:id", | |
| 359 | + | async ({ params, cookie }) => { | |
| 360 | + | const user = await resolveSession(cookie.session.value); | |
| 361 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 362 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 363 | + | ||
| 364 | + | const release = await db | |
| 365 | + | .selectFrom("releases") | |
| 366 | + | .selectAll() | |
| 367 | + | .where("repo_id", "=", repo.id) | |
| 368 | + | .where("id", "=", params.id) | |
| 369 | + | .executeTakeFirst(); | |
| 370 | + | if (!release) return new Response("Not found", { status: 404 }); | |
| 371 | + | ||
| 372 | + | const assets = await db | |
| 373 | + | .selectFrom("release_assets") | |
| 374 | + | .selectAll() | |
| 375 | + | .where("release_id", "=", release.id) | |
| 376 | + | .orderBy("id", "asc") | |
| 377 | + | .execute(); | |
| 378 | + | ||
| 379 | + | const notesHtml = release.notes | |
| 380 | + | ? renderMarkdown(release.notes) | |
| 381 | + | : ""; | |
| 382 | + | ||
| 383 | + | // Detect which source archives exist on disk, and whether | |
| 384 | + | // generation is still in progress (indicated by a .pending file). | |
| 385 | + | const sourceArchives: { | |
| 386 | + | format: string; | |
| 387 | + | filename: string; | |
| 388 | + | size: number; | |
| 389 | + | }[] = []; | |
| 390 | + | let sourceArchivesPending = false; | |
| 391 | + | if (release.include_source_code) { | |
| 392 | + | const base = `${repo.name}-${release.commit_hash.slice(0, 8)}`; | |
| 393 | + | const sourceDir = path.join( | |
| 394 | + | RELEASES_DIR, | |
| 395 | + | String(release.id), | |
| 396 | + | "source", | |
| 397 | + | ); | |
| 398 | + | if (await Bun.file(path.join(sourceDir, ".pending")).exists()) { | |
| 399 | + | sourceArchivesPending = true; | |
| 400 | + | } else { | |
| 401 | + | for (const [format, ext] of [ | |
| 402 | + | ["zip", ".zip"], | |
| 403 | + | ["tar.gz", ".tar.gz"], | |
| 404 | + | ["tar.zst", ".tar.zst"], | |
| 405 | + | ] as const) { | |
| 406 | + | const filePath = path.join(sourceDir, `${base}${ext}`); | |
| 407 | + | const f = Bun.file(filePath); | |
| 408 | + | if (await f.exists()) { | |
| 409 | + | sourceArchives.push({ | |
| 410 | + | format, | |
| 411 | + | filename: `${base}${ext}`, | |
| 412 | + | size: f.size, | |
| 413 | + | }); | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | return html( | |
| 420 | + | <ReleaseDetail | |
| 421 | + | user={user} | |
| 422 | + | repo={repo} | |
| 423 | + | release={release} | |
| 424 | + | notesHtml={notesHtml} | |
| 425 | + | assets={assets} | |
| 426 | + | sourceArchives={sourceArchives} | |
| 427 | + | sourceArchivesPending={sourceArchivesPending} | |
| 428 | + | />, | |
| 429 | + | ); | |
| 430 | + | }, | |
| 431 | + | { | |
| 432 | + | params: t.Object({ | |
| 433 | + | repo: t.String(), | |
| 434 | + | id: t.Numeric(), | |
| 435 | + | }), | |
| 436 | + | }, | |
| 437 | + | ) | |
| 438 | + | ||
| 439 | + | .post( | |
| 440 | + | "/:repo/releases/:id/delete", | |
| 441 | + | async ({ params, cookie }) => { | |
| 442 | + | const user = await resolveSession(cookie.session.value); | |
| 443 | + | const deny = requireAdmin(user); | |
| 444 | + | if (deny) return deny; | |
| 445 | + | const repo = await getRepo(params.repo, true); | |
| 446 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 447 | + | ||
| 448 | + | const release = await db | |
| 449 | + | .selectFrom("releases") | |
| 450 | + | .select("id") | |
| 451 | + | .where("repo_id", "=", repo.id) | |
| 452 | + | .where("id", "=", params.id) | |
| 453 | + | .executeTakeFirst(); | |
| 454 | + | if (!release) return new Response("Not found", { status: 404 }); | |
| 455 | + | ||
| 456 | + | // Abort any in-progress archive generation before touching disk so | |
| 457 | + | // the background task doesn't race with the rmSync below. | |
| 458 | + | archivingTasks.get(release.id)?.abort(); | |
| 459 | + | archivingTasks.delete(release.id); | |
| 460 | + | ||
| 461 | + | // Remove files from disk before the DB record so that a crash | |
| 462 | + | // between the two leaves a broken-but-visible repo rather than a | |
| 463 | + | // DB record pointing to missing files. | |
| 464 | + | const releaseDir = path.join(RELEASES_DIR, String(release.id)); | |
| 465 | + | rmSync(releaseDir, { recursive: true, force: true }); | |
| 466 | + | await db | |
| 467 | + | .deleteFrom("releases") | |
| 468 | + | .where("id", "=", release.id) | |
| 469 | + | .execute(); | |
| 470 | + | ||
| 471 | + | return new Response(null, { | |
| 472 | + | status: 302, | |
| 473 | + | headers: { Location: `/${repo.name}/releases` }, | |
| 474 | + | }); | |
| 475 | + | }, | |
| 476 | + | { | |
| 477 | + | params: t.Object({ | |
| 478 | + | repo: t.String(), | |
| 479 | + | id: t.Numeric(), | |
| 480 | + | }), | |
| 481 | + | }, | |
| 482 | + | ) | |
| 483 | + | ||
| 484 | + | .get( | |
| 485 | + | "/:repo/releases/:id/assets/:filename", | |
| 486 | + | async ({ params, cookie }) => { | |
| 487 | + | const user = await resolveSession(cookie.session.value); | |
| 488 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 489 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 490 | + | ||
| 491 | + | const release = await db | |
| 492 | + | .selectFrom("releases") | |
| 493 | + | .select("id") | |
| 494 | + | .where("repo_id", "=", repo.id) | |
| 495 | + | .where("id", "=", params.id) | |
| 496 | + | .executeTakeFirst(); | |
| 497 | + | if (!release) return new Response("Not found", { status: 404 }); | |
| 498 | + | ||
| 499 | + | const safeFilename = path.basename(params.filename); | |
| 500 | + | const asset = await db | |
| 501 | + | .selectFrom("release_assets") | |
| 502 | + | .selectAll() | |
| 503 | + | .where("release_id", "=", release.id) | |
| 504 | + | .where("filename", "=", safeFilename) | |
| 505 | + | .executeTakeFirst(); | |
| 506 | + | if (!asset) return new Response("Not found", { status: 404 }); | |
| 507 | + | ||
| 508 | + | const filePath = path.join( | |
| 509 | + | RELEASES_DIR, | |
| 510 | + | String(release.id), | |
| 511 | + | "assets", | |
| 512 | + | safeFilename, | |
| 513 | + | ); | |
| 514 | + | const file = Bun.file(filePath); | |
| 515 | + | if (!(await file.exists())) | |
| 516 | + | return new Response("Not found", { status: 404 }); | |
| 517 | + | ||
| 518 | + | return new Response(file, { | |
| 519 | + | headers: { | |
| 520 | + | "Content-Disposition": `attachment; filename="${safeFilename}"`, | |
| 521 | + | "Content-Type": "application/octet-stream", | |
| 522 | + | }, | |
| 523 | + | }); | |
| 524 | + | }, | |
| 525 | + | { | |
| 526 | + | params: t.Object({ | |
| 527 | + | repo: t.String(), | |
| 528 | + | id: t.Numeric(), | |
| 529 | + | filename: t.String(), | |
| 530 | + | }), | |
| 531 | + | }, | |
| 532 | + | ) | |
| 533 | + | ||
| 534 | + | .get( | |
| 535 | + | "/:repo/releases/:id/source/:filename", | |
| 536 | + | async ({ params, cookie }) => { | |
| 537 | + | const user = await resolveSession(cookie.session.value); | |
| 538 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 539 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 540 | + | ||
| 541 | + | const release = await db | |
| 542 | + | .selectFrom("releases") | |
| 543 | + | .select(["id", "include_source_code"]) | |
| 544 | + | .where("repo_id", "=", repo.id) | |
| 545 | + | .where("id", "=", params.id) | |
| 546 | + | .executeTakeFirst(); | |
| 547 | + | if (!release || !release.include_source_code) | |
| 548 | + | return new Response("Not found", { status: 404 }); | |
| 549 | + | ||
| 550 | + | const safeFilename = path.basename(params.filename); | |
| 551 | + | const filePath = path.join( | |
| 552 | + | RELEASES_DIR, | |
| 553 | + | String(release.id), | |
| 554 | + | "source", | |
| 555 | + | safeFilename, | |
| 556 | + | ); | |
| 557 | + | const file = Bun.file(filePath); | |
| 558 | + | if (!(await file.exists())) | |
| 559 | + | return new Response("Not found", { status: 404 }); | |
| 560 | + | ||
| 561 | + | return new Response(file, { | |
| 562 | + | headers: { | |
| 563 | + | "Content-Disposition": `attachment; filename="${safeFilename}"`, | |
| 564 | + | "Content-Type": "application/octet-stream", | |
| 565 | + | }, | |
| 566 | + | }); | |
| 567 | + | }, | |
| 568 | + | { | |
| 569 | + | params: t.Object({ | |
| 570 | + | repo: t.String(), | |
| 571 | + | id: t.Numeric(), | |
| 572 | + | filename: t.String(), | |
| 573 | + | }), | |
| 574 | + | }, | |
| 575 | + | ); | |
Asrc/routes/repos.tsx
| @@ -0,0 +1,617 @@ | |||
|---|---|---|---|
| 1 | + | import { rmSync } from "node:fs"; | |
| 2 | + | import path from "node:path"; | |
| 3 | + | import { Elysia, t } from "elysia"; | |
| 4 | + | import { fileTypeFromBuffer } from "file-type"; | |
| 5 | + | import { | |
| 6 | + | COMMITS_PER_PAGE, | |
| 7 | + | REPOS_PER_PAGE, | |
| 8 | + | VALID_REPO_NAME_RE, | |
| 9 | + | } from "../constants.ts"; | |
| 10 | + | import { db } from "../db/index.ts"; | |
| 11 | + | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 12 | + | import { git, repoPath } from "../services/git.ts"; | |
| 13 | + | import { | |
| 14 | + | hasBinaryContent, | |
| 15 | + | prepareDiff, | |
| 16 | + | serveFile, | |
| 17 | + | } from "../services/highlightWorker.ts"; | |
| 18 | + | import { renderMarkdown } from "../services/markdown.ts"; | |
| 19 | + | import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts"; | |
| 20 | + | import { html } from "../views/render.tsx"; | |
| 21 | + | import { CommitDetail } from "../views/repos/CommitDetail.tsx"; | |
| 22 | + | import { CommitLog } from "../views/repos/CommitLog.tsx"; | |
| 23 | + | import { FileBlob } from "../views/repos/FileBlob.tsx"; | |
| 24 | + | import { FileTree } from "../views/repos/FileTree.tsx"; | |
| 25 | + | import { NewRepo } from "../views/repos/NewRepo.tsx"; | |
| 26 | + | import { RepoHome } from "../views/repos/RepoHome.tsx"; | |
| 27 | + | import { RepoList } from "../views/repos/RepoList.tsx"; | |
| 28 | + | import { RepoSettings } from "../views/repos/RepoSettings.tsx"; | |
| 29 | + | ||
| 30 | + | async function getRepo(name: string, isAdmin: boolean) { | |
| 31 | + | if (!repoDiskExists(name)) return null; | |
| 32 | + | const repo = await ensureRepoRecord(name); | |
| 33 | + | if (repo.is_private && !isAdmin) return null; | |
| 34 | + | return repo; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | async function mimeForContent(content: Buffer): Promise<string> { | |
| 38 | + | const result = await fileTypeFromBuffer(content); | |
| 39 | + | if (result) return result.mime; | |
| 40 | + | return hasBinaryContent(content.subarray(0, 8000)) | |
| 41 | + | ? "text/plain; charset=utf-8" | |
| 42 | + | : "application/octet-stream"; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | async function readReadme( | |
| 46 | + | repo: string, | |
| 47 | + | ref: string, | |
| 48 | + | dir = "", | |
| 49 | + | ): Promise<Buffer | null> { | |
| 50 | + | const prefix = dir ? `${dir}/` : ""; | |
| 51 | + | const [md, mdLc, readme, readmeLc] = await Promise.all([ | |
| 52 | + | git.show(repo, ref, `${prefix}README.md`), | |
| 53 | + | git.show(repo, ref, `${prefix}readme.md`), | |
| 54 | + | git.show(repo, ref, `${prefix}README`), | |
| 55 | + | git.show(repo, ref, `${prefix}readme`), | |
| 56 | + | ]); | |
| 57 | + | return md ?? mdLc ?? readme ?? readmeLc; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | export const repoRoutes = new Elysia() | |
| 61 | + | .guard({ | |
| 62 | + | cookie: t.Cookie({ session: t.Optional(t.String()) }), | |
| 63 | + | }) | |
| 64 | + | .get( | |
| 65 | + | "/", | |
| 66 | + | async ({ cookie, query }) => { | |
| 67 | + | const user = await resolveSession(cookie.session.value); | |
| 68 | + | const search = query.q?.trim() || undefined; | |
| 69 | + | const page = Math.max(1, query.page ?? 1); | |
| 70 | + | ||
| 71 | + | const isAdmin = user?.isAdmin ?? false; | |
| 72 | + | ||
| 73 | + | const countResult = await db | |
| 74 | + | .selectFrom("repositories") | |
| 75 | + | .select(db.fn.countAll<number>().as("count")) | |
| 76 | + | .where((eb) => | |
| 77 | + | isAdmin | |
| 78 | + | ? eb.or([ | |
| 79 | + | eb("is_private", "=", 0), | |
| 80 | + | eb("is_private", "=", 1), | |
| 81 | + | ]) | |
| 82 | + | : eb("is_private", "=", 0), | |
| 83 | + | ) | |
| 84 | + | .$if(!!search, (qb) => | |
| 85 | + | qb.where((eb) => | |
| 86 | + | eb.or([ | |
| 87 | + | eb("name", "like", `%${search}%`), | |
| 88 | + | eb("description", "like", `%${search}%`), | |
| 89 | + | ]), | |
| 90 | + | ), | |
| 91 | + | ) | |
| 92 | + | .executeTakeFirst(); | |
| 93 | + | ||
| 94 | + | const totalCount = Number(countResult?.count ?? 0); | |
| 95 | + | const totalPages = Math.max( | |
| 96 | + | 1, | |
| 97 | + | Math.ceil(totalCount / REPOS_PER_PAGE), | |
| 98 | + | ); | |
| 99 | + | const safePage = Math.min(page, totalPages); | |
| 100 | + | ||
| 101 | + | const repos = await db | |
| 102 | + | .selectFrom("repositories") | |
| 103 | + | .selectAll() | |
| 104 | + | .where((eb) => | |
| 105 | + | isAdmin | |
| 106 | + | ? eb.or([ | |
| 107 | + | eb("is_private", "=", 0), | |
| 108 | + | eb("is_private", "=", 1), | |
| 109 | + | ]) | |
| 110 | + | : eb("is_private", "=", 0), | |
| 111 | + | ) | |
| 112 | + | .$if(!!search, (qb) => | |
| 113 | + | qb.where((eb) => | |
| 114 | + | eb.or([ | |
| 115 | + | eb("name", "like", `%${search}%`), | |
| 116 | + | eb("description", "like", `%${search}%`), | |
| 117 | + | ]), | |
| 118 | + | ), | |
| 119 | + | ) | |
| 120 | + | .orderBy("created_at", "desc") | |
| 121 | + | .limit(REPOS_PER_PAGE) | |
| 122 | + | .offset((safePage - 1) * REPOS_PER_PAGE) | |
| 123 | + | .execute(); | |
| 124 | + | ||
| 125 | + | const searchParam = search | |
| 126 | + | ? `&q=${encodeURIComponent(search)}` | |
| 127 | + | : ""; | |
| 128 | + | const pagination = { | |
| 129 | + | page: safePage, | |
| 130 | + | totalPages, | |
| 131 | + | pageUrlTemplate: `/?page={page}${searchParam}`, | |
| 132 | + | }; | |
| 133 | + | ||
| 134 | + | return html( | |
| 135 | + | <RepoList | |
| 136 | + | user={user} | |
| 137 | + | repos={repos} | |
| 138 | + | search={search} | |
| 139 | + | pagination={pagination} | |
| 140 | + | />, | |
| 141 | + | ); | |
| 142 | + | }, | |
| 143 | + | { | |
| 144 | + | query: t.Object({ | |
| 145 | + | q: t.Optional(t.String()), | |
| 146 | + | page: t.Optional(t.Numeric()), | |
| 147 | + | }), | |
| 148 | + | }, | |
| 149 | + | ) | |
| 150 | + | ||
| 151 | + | .get("/new", async ({ cookie }) => { | |
| 152 | + | const user = await resolveSession(cookie.session.value); | |
| 153 | + | const deny = requireAdmin(user); | |
| 154 | + | if (deny) return deny; | |
| 155 | + | return html(<NewRepo user={user!} />); | |
| 156 | + | }) | |
| 157 | + | ||
| 158 | + | .post( | |
| 159 | + | "/new", | |
| 160 | + | async ({ body, cookie }) => { | |
| 161 | + | const user = await resolveSession(cookie.session.value); | |
| 162 | + | const deny = requireAdmin(user); | |
| 163 | + | if (deny) return deny; | |
| 164 | + | ||
| 165 | + | const { name, description, is_private, default_branch } = body; | |
| 166 | + | ||
| 167 | + | if (!VALID_REPO_NAME_RE.test(name)) { | |
| 168 | + | return html( | |
| 169 | + | <NewRepo user={user!} error="Invalid repository name" />, | |
| 170 | + | ); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | const branch = (default_branch?.trim() || "main").replace( | |
| 174 | + | /[^a-zA-Z0-9._/-]/g, | |
| 175 | + | "", | |
| 176 | + | ); | |
| 177 | + | ||
| 178 | + | const existing = await db | |
| 179 | + | .selectFrom("repositories") | |
| 180 | + | .select("id") | |
| 181 | + | .where("name", "=", name) | |
| 182 | + | .executeTakeFirst(); | |
| 183 | + | if (existing) { | |
| 184 | + | return html( | |
| 185 | + | <NewRepo | |
| 186 | + | user={user!} | |
| 187 | + | error="Repository name already taken" | |
| 188 | + | />, | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | const now = new Date().toISOString(); | |
| 193 | + | await db | |
| 194 | + | .insertInto("repositories") | |
| 195 | + | .values({ | |
| 196 | + | name, | |
| 197 | + | description: description || null, | |
| 198 | + | is_private: is_private === "1" ? 1 : 0, | |
| 199 | + | default_branch: branch, | |
| 200 | + | created_at: now, | |
| 201 | + | }) | |
| 202 | + | .execute(); | |
| 203 | + | ||
| 204 | + | // Initialise the git repo after the DB record is committed. If | |
| 205 | + | // git.init fails we roll back the DB record so the two stay in sync. | |
| 206 | + | try { | |
| 207 | + | await git.init(name, branch); | |
| 208 | + | } catch (err) { | |
| 209 | + | await db | |
| 210 | + | .deleteFrom("repositories") | |
| 211 | + | .where("name", "=", name) | |
| 212 | + | .execute(); | |
| 213 | + | throw err; | |
| 214 | + | } | |
| 215 | + | return new Response(null, { | |
| 216 | + | status: 302, | |
| 217 | + | headers: { Location: `/${name}` }, | |
| 218 | + | }); | |
| 219 | + | }, | |
| 220 | + | { | |
| 221 | + | body: t.Object({ | |
| 222 | + | name: t.String(), | |
| 223 | + | description: t.Optional(t.String()), | |
| 224 | + | is_private: t.Optional(t.String()), | |
| 225 | + | default_branch: t.Optional(t.String()), | |
| 226 | + | }), | |
| 227 | + | }, | |
| 228 | + | ) | |
| 229 | + | ||
| 230 | + | .get("/:repo", async ({ params, cookie }) => { | |
| 231 | + | const user = await resolveSession(cookie.session.value); | |
| 232 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 233 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 234 | + | ||
| 235 | + | const hasContent = await git.hasCommits(repo.name); | |
| 236 | + | let readmeHtml: string | null = null; | |
| 237 | + | let entries: Awaited<ReturnType<typeof git.lsTree>> = []; | |
| 238 | + | let branches: string[] = []; | |
| 239 | + | ||
| 240 | + | if (hasContent) { | |
| 241 | + | const [lsResult, branchResult, resolved] = await Promise.all([ | |
| 242 | + | git.lsTree(repo.name, repo.default_branch), | |
| 243 | + | git.branches(repo.name), | |
| 244 | + | git.resolveRef(repo.name, repo.default_branch), | |
| 245 | + | ]); | |
| 246 | + | entries = lsResult; | |
| 247 | + | branches = branchResult; | |
| 248 | + | const readmeBuf = await readReadme(repo.name, repo.default_branch); | |
| 249 | + | if (readmeBuf) { | |
| 250 | + | const key = resolved | |
| 251 | + | ? `readme:${repo.name}:${resolved}:` | |
| 252 | + | : undefined; | |
| 253 | + | readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key); | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | return html( | |
| 258 | + | <RepoHome | |
| 259 | + | user={user} | |
| 260 | + | repo={repo} | |
| 261 | + | entries={entries} | |
| 262 | + | readmeHtml={readmeHtml} | |
| 263 | + | hasContent={hasContent} | |
| 264 | + | branches={branches} | |
| 265 | + | />, | |
| 266 | + | ); | |
| 267 | + | }) | |
| 268 | + | ||
| 269 | + | .get( | |
| 270 | + | "/:repo/branch-switch", | |
| 271 | + | async ({ params, query, cookie }) => { | |
| 272 | + | const user = await resolveSession(cookie.session.value); | |
| 273 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 274 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 275 | + | ||
| 276 | + | const ref = query.ref?.trim(); | |
| 277 | + | if (!ref) | |
| 278 | + | return new Response(null, { | |
| 279 | + | status: 302, | |
| 280 | + | headers: { Location: `/${repo.name}` }, | |
| 281 | + | }); | |
| 282 | + | ||
| 283 | + | const view = query.view; | |
| 284 | + | const subpath = query.path ?? ""; | |
| 285 | + | ||
| 286 | + | if (view === "commits") { | |
| 287 | + | return new Response(null, { | |
| 288 | + | status: 302, | |
| 289 | + | headers: { Location: `/${repo.name}/commits/${ref}` }, | |
| 290 | + | }); | |
| 291 | + | } | |
| 292 | + | if (view === "blob" && subpath) { | |
| 293 | + | return new Response(null, { | |
| 294 | + | status: 302, | |
| 295 | + | headers: { | |
| 296 | + | Location: `/${repo.name}/blob/${ref}/${subpath}`, | |
| 297 | + | }, | |
| 298 | + | }); | |
| 299 | + | } | |
| 300 | + | const location = subpath | |
| 301 | + | ? `/${repo.name}/tree/${ref}/${subpath}` | |
| 302 | + | : `/${repo.name}/tree/${ref}`; | |
| 303 | + | return new Response(null, { | |
| 304 | + | status: 302, | |
| 305 | + | headers: { Location: location }, | |
| 306 | + | }); | |
| 307 | + | }, | |
| 308 | + | { | |
| 309 | + | query: t.Object({ | |
| 310 | + | ref: t.Optional(t.String()), | |
| 311 | + | view: t.Optional(t.String()), | |
| 312 | + | path: t.Optional(t.String()), | |
| 313 | + | }), | |
| 314 | + | }, | |
| 315 | + | ) | |
| 316 | + | ||
| 317 | + | .get("/:repo/tree/:ref", async ({ params, cookie }) => { | |
| 318 | + | const user = await resolveSession(cookie.session.value); | |
| 319 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 320 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 321 | + | ||
| 322 | + | const resolved = await git.resolveRef(repo.name, params.ref); | |
| 323 | + | if (!resolved) return new Response("Not found", { status: 404 }); | |
| 324 | + | ||
| 325 | + | const [entries, branches] = await Promise.all([ | |
| 326 | + | git.lsTree(repo.name, params.ref), | |
| 327 | + | git.branches(repo.name), | |
| 328 | + | ]); | |
| 329 | + | const readmeBuf = await readReadme(repo.name, params.ref); | |
| 330 | + | const readmeHtml = readmeBuf | |
| 331 | + | ? renderMarkdown( | |
| 332 | + | readmeBuf.toString("utf-8"), | |
| 333 | + | `readme:${repo.name}:${resolved}:`, | |
| 334 | + | ) | |
| 335 | + | : null; | |
| 336 | + | return html( | |
| 337 | + | <FileTree | |
| 338 | + | user={user} | |
| 339 | + | repo={repo} | |
| 340 | + | ref={params.ref} | |
| 341 | + | subpath="" | |
| 342 | + | entries={entries} | |
| 343 | + | branches={branches} | |
| 344 | + | readmeHtml={readmeHtml} | |
| 345 | + | />, | |
| 346 | + | ); | |
| 347 | + | }) | |
| 348 | + | ||
| 349 | + | .get("/:repo/tree/:ref/*", async ({ params, cookie }) => { | |
| 350 | + | const user = await resolveSession(cookie.session.value); | |
| 351 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 352 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 353 | + | ||
| 354 | + | const resolved = await git.resolveRef(repo.name, params.ref); | |
| 355 | + | if (!resolved) return new Response("Not found", { status: 404 }); | |
| 356 | + | ||
| 357 | + | const subpath = params["*"]; | |
| 358 | + | const [entries, branches] = await Promise.all([ | |
| 359 | + | git.lsTree(repo.name, params.ref, subpath), | |
| 360 | + | git.branches(repo.name), | |
| 361 | + | ]); | |
| 362 | + | if (entries.length === 0) { | |
| 363 | + | // Could be a file — redirect to blob | |
| 364 | + | return new Response(null, { | |
| 365 | + | status: 302, | |
| 366 | + | headers: { | |
| 367 | + | Location: `/${repo.name}/blob/${params.ref}/${subpath}`, | |
| 368 | + | }, | |
| 369 | + | }); | |
| 370 | + | } | |
| 371 | + | const readmeBuf = await readReadme(repo.name, params.ref, subpath); | |
| 372 | + | const readmeHtml = readmeBuf | |
| 373 | + | ? renderMarkdown( | |
| 374 | + | readmeBuf.toString("utf-8"), | |
| 375 | + | `readme:${repo.name}:${resolved}:${subpath}`, | |
| 376 | + | ) | |
| 377 | + | : null; | |
| 378 | + | return html( | |
| 379 | + | <FileTree | |
| 380 | + | user={user} | |
| 381 | + | repo={repo} | |
| 382 | + | ref={params.ref} | |
| 383 | + | subpath={subpath} | |
| 384 | + | entries={entries} | |
| 385 | + | branches={branches} | |
| 386 | + | readmeHtml={readmeHtml} | |
| 387 | + | />, | |
| 388 | + | ); | |
| 389 | + | }) | |
| 390 | + | ||
| 391 | + | .get("/:repo/blob/:ref/*", async ({ params, cookie }) => { | |
| 392 | + | const user = await resolveSession(cookie.session.value); | |
| 393 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 394 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 395 | + | ||
| 396 | + | const filePath = params["*"]; | |
| 397 | + | const [content, branches, commitSHA] = await Promise.all([ | |
| 398 | + | git.show(repo.name, params.ref, filePath), | |
| 399 | + | git.branches(repo.name), | |
| 400 | + | git.resolveRef(repo.name, params.ref), | |
| 401 | + | ]); | |
| 402 | + | if (!content || !commitSHA) | |
| 403 | + | return new Response("Not found", { status: 404 }); | |
| 404 | + | ||
| 405 | + | const filename = path.basename(filePath); | |
| 406 | + | const view = await serveFile( | |
| 407 | + | content, | |
| 408 | + | filename, | |
| 409 | + | `${repo.name}:${commitSHA}:${filePath}`, | |
| 410 | + | ); | |
| 411 | + | return html( | |
| 412 | + | <FileBlob | |
| 413 | + | user={user} | |
| 414 | + | repo={repo} | |
| 415 | + | ref={params.ref} | |
| 416 | + | filePath={filePath} | |
| 417 | + | view={view} | |
| 418 | + | branches={branches} | |
| 419 | + | />, | |
| 420 | + | ); | |
| 421 | + | }) | |
| 422 | + | ||
| 423 | + | .get("/:repo/raw/:ref/*", async ({ params, cookie }) => { | |
| 424 | + | const user = await resolveSession(cookie.session.value); | |
| 425 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 426 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 427 | + | ||
| 428 | + | const filePath = params["*"]; | |
| 429 | + | const content = await git.show(repo.name, params.ref, filePath); | |
| 430 | + | if (!content) return new Response("Not found", { status: 404 }); | |
| 431 | + | ||
| 432 | + | const filename = path.basename(filePath); | |
| 433 | + | const contentType = await mimeForContent(content); | |
| 434 | + | return new Response(content, { | |
| 435 | + | headers: { | |
| 436 | + | "Content-Type": contentType, | |
| 437 | + | "Content-Disposition": `inline; filename="${filename}"`, | |
| 438 | + | "Content-Length": String(content.length), | |
| 439 | + | }, | |
| 440 | + | }); | |
| 441 | + | }) | |
| 442 | + | ||
| 443 | + | .get( | |
| 444 | + | "/:repo/commits/:ref", | |
| 445 | + | async ({ params, cookie, query }) => { | |
| 446 | + | const user = await resolveSession(cookie.session.value); | |
| 447 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 448 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 449 | + | ||
| 450 | + | // Cursor-based pagination — O(1) regardless of history depth. | |
| 451 | + | // `after` = SHA of the last commit on the previous page (resume cursor). | |
| 452 | + | // `prev` = the `after` value used on the page that linked here, so we can | |
| 453 | + | // reconstruct a "← Newer" link without a full history traversal. | |
| 454 | + | const after = query.after?.trim() || null; | |
| 455 | + | const prev = query.prev?.trim() || null; | |
| 456 | + | ||
| 457 | + | const [rawCommits, branches] = await Promise.all([ | |
| 458 | + | // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)), | |
| 459 | + | // then fetch LIMIT+1 to detect whether another page exists. | |
| 460 | + | after | |
| 461 | + | ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1) | |
| 462 | + | : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0), | |
| 463 | + | git.branches(repo.name), | |
| 464 | + | ]); | |
| 465 | + | ||
| 466 | + | const hasNext = rawCommits.length > COMMITS_PER_PAGE; | |
| 467 | + | const commits = rawCommits.slice(0, COMMITS_PER_PAGE); | |
| 468 | + | ||
| 469 | + | // Build cursor URLs. | |
| 470 | + | // "Older" advances past the last commit on this page. | |
| 471 | + | // "Newer" goes back one page using the `prev` cursor saved in the URL, | |
| 472 | + | // or to the first page if we're on page 2. | |
| 473 | + | const base = `/${repo.name}/commits/${params.ref}`; | |
| 474 | + | const olderUrl = hasNext | |
| 475 | + | ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}` | |
| 476 | + | : null; | |
| 477 | + | const newerUrl = after | |
| 478 | + | ? prev | |
| 479 | + | ? `${base}?after=${prev}` | |
| 480 | + | : base | |
| 481 | + | : null; | |
| 482 | + | ||
| 483 | + | return html( | |
| 484 | + | <CommitLog | |
| 485 | + | user={user} | |
| 486 | + | repo={repo} | |
| 487 | + | ref={params.ref} | |
| 488 | + | commits={commits} | |
| 489 | + | branches={branches} | |
| 490 | + | olderUrl={olderUrl} | |
| 491 | + | newerUrl={newerUrl} | |
| 492 | + | />, | |
| 493 | + | ); | |
| 494 | + | }, | |
| 495 | + | { | |
| 496 | + | query: t.Object({ | |
| 497 | + | after: t.Optional(t.String()), | |
| 498 | + | prev: t.Optional(t.String()), | |
| 499 | + | }), | |
| 500 | + | }, | |
| 501 | + | ) | |
| 502 | + | ||
| 503 | + | .get("/:repo/commit/:sha", async ({ params, cookie }) => { | |
| 504 | + | const user = await resolveSession(cookie.session.value); | |
| 505 | + | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 506 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 507 | + | ||
| 508 | + | const [meta, rawDiff] = await Promise.all([ | |
| 509 | + | git.commitMeta(repo.name, params.sha), | |
| 510 | + | git.diff(repo.name, params.sha), | |
| 511 | + | ]); | |
| 512 | + | if (!meta) return new Response("Commit not found", { status: 404 }); | |
| 513 | + | const files = await prepareDiff( | |
| 514 | + | rawDiff, | |
| 515 | + | `commit:${repo.name}:${params.sha}`, | |
| 516 | + | ); | |
| 517 | + | return html( | |
| 518 | + | <CommitDetail | |
| 519 | + | user={user} | |
| 520 | + | repo={repo} | |
| 521 | + | sha={params.sha} | |
| 522 | + | meta={meta} | |
| 523 | + | files={files} | |
| 524 | + | />, | |
| 525 | + | ); | |
| 526 | + | }) | |
| 527 | + | ||
| 528 | + | .get("/:repo/settings", async ({ params, cookie }) => { | |
| 529 | + | const user = await resolveSession(cookie.session.value); | |
| 530 | + | const deny = requireAdmin(user); | |
| 531 | + | if (deny) return deny; | |
| 532 | + | const repo = await getRepo(params.repo, true); | |
| 533 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 534 | + | const branches = await git.branches(repo.name); | |
| 535 | + | return html( | |
| 536 | + | <RepoSettings user={user!} repo={repo} branches={branches} />, | |
| 537 | + | ); | |
| 538 | + | }) | |
| 539 | + | ||
| 540 | + | .post( | |
| 541 | + | "/:repo/settings", | |
| 542 | + | async ({ params, body, cookie }) => { | |
| 543 | + | const user = await resolveSession(cookie.session.value); | |
| 544 | + | const deny = requireAdmin(user); | |
| 545 | + | if (deny) return deny; | |
| 546 | + | const repo = await getRepo(params.repo, true); | |
| 547 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 548 | + | ||
| 549 | + | const { description, is_private, default_branch } = body; | |
| 550 | + | ||
| 551 | + | const branches = await git.branches(repo.name); | |
| 552 | + | const newBranch = default_branch?.trim() || repo.default_branch; | |
| 553 | + | ||
| 554 | + | // Validate the selected branch exists (only if repo has commits) | |
| 555 | + | if (branches.length > 0 && !branches.includes(newBranch)) { | |
| 556 | + | return html( | |
| 557 | + | <RepoSettings | |
| 558 | + | user={user!} | |
| 559 | + | repo={repo} | |
| 560 | + | branches={branches} | |
| 561 | + | error={`Branch "${newBranch}" does not exist.`} | |
| 562 | + | />, | |
| 563 | + | ); | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | await db | |
| 567 | + | .updateTable("repositories") | |
| 568 | + | .set({ | |
| 569 | + | description: description?.trim() || null, | |
| 570 | + | is_private: is_private === "1" ? 1 : 0, | |
| 571 | + | default_branch: newBranch, | |
| 572 | + | }) | |
| 573 | + | .where("id", "=", repo.id) | |
| 574 | + | .execute(); | |
| 575 | + | ||
| 576 | + | // Keep git HEAD in sync if the branch actually exists | |
| 577 | + | if (branches.includes(newBranch)) { | |
| 578 | + | await git.setHead(repo.name, newBranch).catch(() => {}); | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | const updated = await db | |
| 582 | + | .selectFrom("repositories") | |
| 583 | + | .selectAll() | |
| 584 | + | .where("id", "=", repo.id) | |
| 585 | + | .executeTakeFirstOrThrow(); | |
| 586 | + | return html( | |
| 587 | + | <RepoSettings | |
| 588 | + | user={user!} | |
| 589 | + | repo={updated} | |
| 590 | + | branches={branches} | |
| 591 | + | success="Settings saved." | |
| 592 | + | />, | |
| 593 | + | ); | |
| 594 | + | }, | |
| 595 | + | { | |
| 596 | + | body: t.Object({ | |
| 597 | + | description: t.Optional(t.String()), | |
| 598 | + | is_private: t.Optional(t.String()), | |
| 599 | + | default_branch: t.Optional(t.String()), | |
| 600 | + | }), | |
| 601 | + | }, | |
| 602 | + | ) | |
| 603 | + | ||
| 604 | + | .post("/:repo/settings/delete", async ({ params, cookie }) => { | |
| 605 | + | const user = await resolveSession(cookie.session.value); | |
| 606 | + | const deny = requireAdmin(user); | |
| 607 | + | if (deny) return deny; | |
| 608 | + | const repo = await getRepo(params.repo, true); | |
| 609 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 610 | + | ||
| 611 | + | // Remove the on-disk repo first. If this fails (e.g. permission error), | |
| 612 | + | // we abort before touching the DB so the repo remains accessible. | |
| 613 | + | rmSync(repoPath(repo.name), { recursive: true, force: true }); | |
| 614 | + | await db.deleteFrom("repositories").where("id", "=", repo.id).execute(); | |
| 615 | + | ||
| 616 | + | return new Response(null, { status: 302, headers: { Location: "/" } }); | |
| 617 | + | }); | |
Asrc/routes/settings.tsx
| @@ -0,0 +1,433 @@ | |||
|---|---|---|---|
| 1 | + | import * as argon2 from "argon2"; | |
| 2 | + | import { Elysia, t } from "elysia"; | |
| 3 | + | import { utils as sshUtils } from "ssh2"; | |
| 4 | + | import { | |
| 5 | + | ADMIN_USERNAME, | |
| 6 | + | VALID_KEY_TYPES, | |
| 7 | + | VALID_USERNAME_RE, | |
| 8 | + | } from "../constants.ts"; | |
| 9 | + | import { db } from "../db"; | |
| 10 | + | import { redirect } from "../lib/redirect.ts"; | |
| 11 | + | import { resolveSession } from "../middleware/session.ts"; | |
| 12 | + | import { createDefaultAvatar } from "../services/avatar.ts"; | |
| 13 | + | import { fingerprintFromLine } from "../services/sshServer.ts"; | |
| 14 | + | import { html } from "../views/render.tsx"; | |
| 15 | + | import { Settings } from "../views/Settings.tsx"; | |
| 16 | + | ||
| 17 | + | export const settingsRoutes = new Elysia() | |
| 18 | + | .guard({ | |
| 19 | + | cookie: t.Cookie({ | |
| 20 | + | session: t.Optional(t.String()), | |
| 21 | + | theme: t.Optional(t.String()), | |
| 22 | + | }), | |
| 23 | + | }) | |
| 24 | + | ||
| 25 | + | .get( | |
| 26 | + | "/settings", | |
| 27 | + | async ({ cookie, query }) => { | |
| 28 | + | const user = await resolveSession( | |
| 29 | + | cookie.session?.value as string | undefined, | |
| 30 | + | ); | |
| 31 | + | if (!user) return redirect("/login"); | |
| 32 | + | ||
| 33 | + | const { success, error } = query; | |
| 34 | + | ||
| 35 | + | const userRow = await db | |
| 36 | + | .selectFrom("users") | |
| 37 | + | .select(["id", "password_hash"]) | |
| 38 | + | .where("id", "=", user.id) | |
| 39 | + | .executeTakeFirst(); | |
| 40 | + | ||
| 41 | + | const passkeys = await db | |
| 42 | + | .selectFrom("passkeys") | |
| 43 | + | .select(["id", "created_at"]) | |
| 44 | + | .where("user_id", "=", user.id) | |
| 45 | + | .execute(); | |
| 46 | + | ||
| 47 | + | const sshKeys = await db | |
| 48 | + | .selectFrom("ssh_keys") | |
| 49 | + | .select(["id", "name", "fingerprint", "created_at"]) | |
| 50 | + | .where("user_id", "=", user.id) | |
| 51 | + | .execute(); | |
| 52 | + | ||
| 53 | + | const theme = (cookie.theme?.value as string | undefined) ?? "auto"; | |
| 54 | + | const hasPassword = !!userRow?.password_hash; | |
| 55 | + | ||
| 56 | + | const decodedError = error | |
| 57 | + | ? decodeURIComponent((error as string).replace(/\+/g, " ")) | |
| 58 | + | : null; | |
| 59 | + | ||
| 60 | + | return html( | |
| 61 | + | <Settings | |
| 62 | + | user={user} | |
| 63 | + | hasPassword={hasPassword} | |
| 64 | + | passkeys={passkeys} | |
| 65 | + | sshKeys={sshKeys} | |
| 66 | + | theme={theme} | |
| 67 | + | success={(success as string | null) ?? null} | |
| 68 | + | error={decodedError} | |
| 69 | + | />, | |
| 70 | + | ); | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | query: t.Object({ | |
| 74 | + | success: t.Optional(t.String()), | |
| 75 | + | error: t.Optional(t.String()), | |
| 76 | + | }), | |
| 77 | + | }, | |
| 78 | + | ) | |
| 79 | + | ||
| 80 | + | .post( | |
| 81 | + | "/settings/password", | |
| 82 | + | async ({ cookie, body }) => { | |
| 83 | + | const user = await resolveSession( | |
| 84 | + | cookie.session?.value as string | undefined, | |
| 85 | + | ); | |
| 86 | + | if (!user) return redirect("/login"); | |
| 87 | + | ||
| 88 | + | const { current_password, new_password, confirm_password } = body; | |
| 89 | + | ||
| 90 | + | if (!new_password || new_password.length < 8) { | |
| 91 | + | return redirect( | |
| 92 | + | "/settings?error=Password+must+be+at+least+8+characters", | |
| 93 | + | ); | |
| 94 | + | } | |
| 95 | + | if (new_password !== confirm_password) { | |
| 96 | + | return redirect("/settings?error=Passwords+do+not+match"); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | const userRow = await db | |
| 100 | + | .selectFrom("users") | |
| 101 | + | .select(["id", "password_hash"]) | |
| 102 | + | .where("id", "=", user.id) | |
| 103 | + | .executeTakeFirst(); | |
| 104 | + | ||
| 105 | + | if (userRow?.password_hash) { | |
| 106 | + | if (!current_password) { | |
| 107 | + | return redirect( | |
| 108 | + | "/settings?error=Current+password+is+required", | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | const valid = await argon2.verify( | |
| 112 | + | userRow.password_hash, | |
| 113 | + | current_password, | |
| 114 | + | ); | |
| 115 | + | if (!valid) { | |
| 116 | + | return redirect( | |
| 117 | + | "/settings?error=Current+password+is+incorrect", | |
| 118 | + | ); | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | const hash = await argon2.hash(new_password); | |
| 123 | + | await db | |
| 124 | + | .updateTable("users") | |
| 125 | + | .set({ password_hash: hash }) | |
| 126 | + | .where("id", "=", user.id) | |
| 127 | + | .execute(); | |
| 128 | + | ||
| 129 | + | return redirect("/settings?success=password"); | |
| 130 | + | }, | |
| 131 | + | { | |
| 132 | + | body: t.Object({ | |
| 133 | + | current_password: t.Optional(t.String()), | |
| 134 | + | new_password: t.String(), | |
| 135 | + | confirm_password: t.String(), | |
| 136 | + | }), | |
| 137 | + | }, | |
| 138 | + | ) | |
| 139 | + | ||
| 140 | + | .post( | |
| 141 | + | "/settings/password/remove", | |
| 142 | + | async ({ cookie }) => { | |
| 143 | + | const user = await resolveSession( | |
| 144 | + | cookie.session?.value as string | undefined, | |
| 145 | + | ); | |
| 146 | + | if (!user) return redirect("/login"); | |
| 147 | + | ||
| 148 | + | const passkeys = await db | |
| 149 | + | .selectFrom("passkeys") | |
| 150 | + | .select(["id"]) | |
| 151 | + | .where("user_id", "=", user.id) | |
| 152 | + | .execute(); | |
| 153 | + | ||
| 154 | + | if (passkeys.length === 0) { | |
| 155 | + | return redirect( | |
| 156 | + | "/settings?error=Cannot+remove+password+without+a+passkey", | |
| 157 | + | ); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | await db | |
| 161 | + | .updateTable("users") | |
| 162 | + | .set({ password_hash: null }) | |
| 163 | + | .where("id", "=", user.id) | |
| 164 | + | .execute(); | |
| 165 | + | ||
| 166 | + | return redirect("/settings?success=password_removed"); | |
| 167 | + | }, | |
| 168 | + | { | |
| 169 | + | body: t.Object({}), | |
| 170 | + | }, | |
| 171 | + | ) | |
| 172 | + | ||
| 173 | + | .post( | |
| 174 | + | "/settings/passkey/revoke", | |
| 175 | + | async ({ cookie, body }) => { | |
| 176 | + | const user = await resolveSession( | |
| 177 | + | cookie.session?.value as string | undefined, | |
| 178 | + | ); | |
| 179 | + | if (!user) return redirect("/login"); | |
| 180 | + | ||
| 181 | + | const { id } = body; | |
| 182 | + | ||
| 183 | + | const passkey = await db | |
| 184 | + | .selectFrom("passkeys") | |
| 185 | + | .select(["id", "user_id"]) | |
| 186 | + | .where("id", "=", id) | |
| 187 | + | .executeTakeFirst(); | |
| 188 | + | ||
| 189 | + | if (!passkey || passkey.user_id !== user.id) { | |
| 190 | + | return redirect("/settings?error=Passkey+not+found"); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | const userRow = await db | |
| 194 | + | .selectFrom("users") | |
| 195 | + | .select(["password_hash"]) | |
| 196 | + | .where("id", "=", user.id) | |
| 197 | + | .executeTakeFirst(); | |
| 198 | + | ||
| 199 | + | const hasPassword = !!userRow?.password_hash; | |
| 200 | + | ||
| 201 | + | // Wrap the count check and delete in a transaction so that two | |
| 202 | + | // concurrent revocations can't both pass the "last auth method" | |
| 203 | + | // guard and both succeed. | |
| 204 | + | let lastAuthMethod = false; | |
| 205 | + | await db.transaction().execute(async (trx) => { | |
| 206 | + | const allPasskeys = await trx | |
| 207 | + | .selectFrom("passkeys") | |
| 208 | + | .select(["id"]) | |
| 209 | + | .where("user_id", "=", user.id) | |
| 210 | + | .execute(); | |
| 211 | + | ||
| 212 | + | const remaining = allPasskeys.filter((p) => p.id !== id); | |
| 213 | + | if (!hasPassword && remaining.length === 0) { | |
| 214 | + | lastAuthMethod = true; | |
| 215 | + | return; | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | await trx.deleteFrom("passkeys").where("id", "=", id).execute(); | |
| 219 | + | }); | |
| 220 | + | ||
| 221 | + | if (lastAuthMethod) { | |
| 222 | + | return redirect( | |
| 223 | + | "/settings?error=Cannot+revoke+last+auth+method", | |
| 224 | + | ); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | return redirect("/settings?success=passkey_revoked"); | |
| 228 | + | }, | |
| 229 | + | { | |
| 230 | + | body: t.Object({ id: t.Numeric() }), | |
| 231 | + | }, | |
| 232 | + | ) | |
| 233 | + | ||
| 234 | + | .post( | |
| 235 | + | "/settings/theme", | |
| 236 | + | async ({ cookie, body }) => { | |
| 237 | + | const _user = await resolveSession( | |
| 238 | + | cookie.session?.value as string | undefined, | |
| 239 | + | ); | |
| 240 | + | // Theme can be set even without auth, but we check session for settings redirect | |
| 241 | + | const { theme } = body; | |
| 242 | + | ||
| 243 | + | if (!["auto", "light", "dark"].includes(theme)) { | |
| 244 | + | return redirect("/settings?error=Invalid+theme"); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`; | |
| 248 | + | return redirect("/settings?success=theme", cookieHeader); | |
| 249 | + | }, | |
| 250 | + | { | |
| 251 | + | body: t.Object({ theme: t.String() }), | |
| 252 | + | }, | |
| 253 | + | ) | |
| 254 | + | ||
| 255 | + | .post( | |
| 256 | + | "/admin/users", | |
| 257 | + | async ({ cookie, body }) => { | |
| 258 | + | const user = await resolveSession( | |
| 259 | + | cookie.session?.value as string | undefined, | |
| 260 | + | ); | |
| 261 | + | if (!user) return redirect("/login"); | |
| 262 | + | if (!user.isAdmin) | |
| 263 | + | return new Response("Forbidden", { status: 403 }); | |
| 264 | + | ||
| 265 | + | const { username, password } = body; | |
| 266 | + | ||
| 267 | + | if (!VALID_USERNAME_RE.test(username)) { | |
| 268 | + | return redirect( | |
| 269 | + | "/settings?error=Username+may+only+contain+letters,+numbers,+hyphens,+and+underscores", | |
| 270 | + | ); | |
| 271 | + | } | |
| 272 | + | if (username === ADMIN_USERNAME) { | |
| 273 | + | return redirect("/settings?error=That+username+is+reserved"); | |
| 274 | + | } | |
| 275 | + | if (!password || password.length < 8) { | |
| 276 | + | return redirect( | |
| 277 | + | "/settings?error=Password+must+be+at+least+8+characters", | |
| 278 | + | ); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | const existing = await db | |
| 282 | + | .selectFrom("users") | |
| 283 | + | .select("id") | |
| 284 | + | .where("username", "=", username) | |
| 285 | + | .executeTakeFirst(); | |
| 286 | + | ||
| 287 | + | if (existing) { | |
| 288 | + | return redirect("/settings?error=Username+already+taken"); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | const hash = await argon2.hash(password); | |
| 292 | + | const now = new Date().toISOString(); | |
| 293 | + | const result = await db | |
| 294 | + | .insertInto("users") | |
| 295 | + | .values({ | |
| 296 | + | username, | |
| 297 | + | password_hash: hash, | |
| 298 | + | created_at: now, | |
| 299 | + | }) | |
| 300 | + | .executeTakeFirstOrThrow(); | |
| 301 | + | await createDefaultAvatar(Number(result.insertId), username); | |
| 302 | + | ||
| 303 | + | return redirect("/settings?success=user_created"); | |
| 304 | + | }, | |
| 305 | + | { | |
| 306 | + | body: t.Object({ username: t.String(), password: t.String() }), | |
| 307 | + | }, | |
| 308 | + | ) | |
| 309 | + | ||
| 310 | + | .post( | |
| 311 | + | "/admin/users/delete", | |
| 312 | + | async ({ cookie, body }) => { | |
| 313 | + | const user = await resolveSession( | |
| 314 | + | cookie.session?.value as string | undefined, | |
| 315 | + | ); | |
| 316 | + | if (!user) return redirect("/login"); | |
| 317 | + | if (!user.isAdmin) | |
| 318 | + | return new Response("Forbidden", { status: 403 }); | |
| 319 | + | ||
| 320 | + | const { username } = body; | |
| 321 | + | if (username === ADMIN_USERNAME) { | |
| 322 | + | return redirect("/settings?error=Cannot+delete+admin+user"); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | const targetUser = await db | |
| 326 | + | .selectFrom("users") | |
| 327 | + | .select("id") | |
| 328 | + | .where("username", "=", username) | |
| 329 | + | .executeTakeFirst(); | |
| 330 | + | ||
| 331 | + | if (!targetUser) { | |
| 332 | + | return redirect("/settings?error=User+not+found"); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | await db | |
| 336 | + | .deleteFrom("users") | |
| 337 | + | .where("id", "=", targetUser.id) | |
| 338 | + | .execute(); | |
| 339 | + | ||
| 340 | + | return redirect("/settings?success=user_deleted"); | |
| 341 | + | }, | |
| 342 | + | { | |
| 343 | + | body: t.Object({ username: t.String() }), | |
| 344 | + | }, | |
| 345 | + | ) | |
| 346 | + | ||
| 347 | + | .post( | |
| 348 | + | "/settings/ssh-keys", | |
| 349 | + | async ({ cookie, body }) => { | |
| 350 | + | const user = await resolveSession( | |
| 351 | + | cookie.session?.value as string | undefined, | |
| 352 | + | ); | |
| 353 | + | if (!user) return redirect("/login"); | |
| 354 | + | ||
| 355 | + | const { name, public_key } = body; | |
| 356 | + | const keyLine = public_key.trim(); | |
| 357 | + | const parts = keyLine.split(/\s+/); | |
| 358 | + | ||
| 359 | + | if (!VALID_KEY_TYPES.has(parts[0] ?? "")) { | |
| 360 | + | return redirect("/settings?error=Unsupported+key+type"); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Validate the key is parseable | |
| 364 | + | try { | |
| 365 | + | const parsed = sshUtils.parseKey( | |
| 366 | + | Buffer.from(parts[1] ?? "", "base64"), | |
| 367 | + | ); | |
| 368 | + | if (parsed instanceof Error) throw parsed; | |
| 369 | + | } catch { | |
| 370 | + | return redirect("/settings?error=Invalid+public+key"); | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | const fingerprint = fingerprintFromLine(keyLine); | |
| 374 | + | if (!fingerprint) | |
| 375 | + | return redirect("/settings?error=Invalid+public+key"); | |
| 376 | + | ||
| 377 | + | try { | |
| 378 | + | await db | |
| 379 | + | .insertInto("ssh_keys") | |
| 380 | + | .values({ | |
| 381 | + | user_id: user.id, | |
| 382 | + | name: name.trim() || "Unnamed key", | |
| 383 | + | public_key: keyLine, | |
| 384 | + | fingerprint, | |
| 385 | + | created_at: new Date().toISOString(), | |
| 386 | + | }) | |
| 387 | + | .execute(); | |
| 388 | + | } catch (err) { | |
| 389 | + | if ( | |
| 390 | + | err instanceof Error && | |
| 391 | + | err.message.includes( | |
| 392 | + | "UNIQUE constraint failed: ssh_keys.fingerprint", | |
| 393 | + | ) | |
| 394 | + | ) { | |
| 395 | + | return redirect( | |
| 396 | + | "/settings?error=This+key+is+already+registered", | |
| 397 | + | ); | |
| 398 | + | } | |
| 399 | + | throw err; | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | return redirect("/settings?success=ssh_key_added"); | |
| 403 | + | }, | |
| 404 | + | { | |
| 405 | + | body: t.Object({ name: t.String(), public_key: t.String() }), | |
| 406 | + | }, | |
| 407 | + | ) | |
| 408 | + | ||
| 409 | + | .post( | |
| 410 | + | "/settings/ssh-keys/delete", | |
| 411 | + | async ({ cookie, body }) => { | |
| 412 | + | const user = await resolveSession( | |
| 413 | + | cookie.session?.value as string | undefined, | |
| 414 | + | ); | |
| 415 | + | if (!user) return redirect("/login"); | |
| 416 | + | ||
| 417 | + | const key = await db | |
| 418 | + | .selectFrom("ssh_keys") | |
| 419 | + | .select(["id", "user_id"]) | |
| 420 | + | .where("id", "=", body.id) | |
| 421 | + | .executeTakeFirst(); | |
| 422 | + | ||
| 423 | + | if (!key || key.user_id !== user.id) { | |
| 424 | + | return redirect("/settings?error=Key+not+found"); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | await db.deleteFrom("ssh_keys").where("id", "=", body.id).execute(); | |
| 428 | + | return redirect("/settings?success=ssh_key_deleted"); | |
| 429 | + | }, | |
| 430 | + | { | |
| 431 | + | body: t.Object({ id: t.Numeric() }), | |
| 432 | + | }, | |
| 433 | + | ); | |
Asrc/services/avatar.ts
| @@ -0,0 +1,123 @@ | |||
|---|---|---|---|
| 1 | + | import { mkdirSync } from "node:fs"; | |
| 2 | + | import path from "node:path"; | |
| 3 | + | import { encode } from "@jsquash/jxl"; | |
| 4 | + | import { fileTypeFromBuffer } from "file-type"; | |
| 5 | + | import sharp from "sharp"; | |
| 6 | + | import { AVATARS_DIR } from "../constants.ts"; | |
| 7 | + | ||
| 8 | + | mkdirSync(AVATARS_DIR, { recursive: true }); | |
| 9 | + | ||
| 10 | + | // FNV-1a: produces n deterministic bytes from a string | |
| 11 | + | function hashBytes(s: string, n: number): number[] { | |
| 12 | + | const out: number[] = []; | |
| 13 | + | let h = 0x811c9dc5; | |
| 14 | + | for (const c of s) { | |
| 15 | + | h ^= c.charCodeAt(0); | |
| 16 | + | h = Math.imul(h, 0x01000193) >>> 0; | |
| 17 | + | } | |
| 18 | + | while (out.length < n) { | |
| 19 | + | h = Math.imul(h ^ (out.length & 0xff), 0x01000193) >>> 0; | |
| 20 | + | out.push( | |
| 21 | + | h & 0xff, | |
| 22 | + | (h >>> 8) & 0xff, | |
| 23 | + | (h >>> 16) & 0xff, | |
| 24 | + | (h >>> 24) & 0xff, | |
| 25 | + | ); | |
| 26 | + | } | |
| 27 | + | return out.slice(0, n); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | function hslToRgb(h: number, s: number, l: number): [number, number, number] { | |
| 31 | + | s /= 100; | |
| 32 | + | l /= 100; | |
| 33 | + | const a = s * Math.min(l, 1 - l); | |
| 34 | + | const f = (n: number) => { | |
| 35 | + | const k = (n + h / 30) % 12; | |
| 36 | + | return Math.round( | |
| 37 | + | (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255, | |
| 38 | + | ); | |
| 39 | + | }; | |
| 40 | + | return [f(0), f(8), f(4)]; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | export function avatarJxlPath(userId: number): string { | |
| 44 | + | return path.join(AVATARS_DIR, `${userId}.jxl`); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export async function processAndStoreAvatar( | |
| 48 | + | userId: number, | |
| 49 | + | buffer: Buffer, | |
| 50 | + | ): Promise<void> { | |
| 51 | + | const type = await fileTypeFromBuffer(buffer); | |
| 52 | + | if (!type?.mime.startsWith("image/")) { | |
| 53 | + | throw new Error("Invalid image type"); | |
| 54 | + | } | |
| 55 | + | const { data, info } = await sharp(buffer) | |
| 56 | + | .resize(128, 128, { fit: "cover", position: "center" }) | |
| 57 | + | .ensureAlpha() | |
| 58 | + | .raw() | |
| 59 | + | .toBuffer({ resolveWithObject: true }); | |
| 60 | + | ||
| 61 | + | const jxl = await encode( | |
| 62 | + | { | |
| 63 | + | data: new Uint8ClampedArray( | |
| 64 | + | data.buffer, | |
| 65 | + | data.byteOffset, | |
| 66 | + | data.byteLength, | |
| 67 | + | ), | |
| 68 | + | width: info.width, | |
| 69 | + | height: info.height, | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | progressive: true, | |
| 73 | + | quality: 90, | |
| 74 | + | effort: 9, | |
| 75 | + | }, | |
| 76 | + | ); | |
| 77 | + | await Bun.write(avatarJxlPath(userId), jxl); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | export async function createDefaultAvatar( | |
| 81 | + | userId: number, | |
| 82 | + | username: string, | |
| 83 | + | ): Promise<void> { | |
| 84 | + | const b = hashBytes(username, 16); | |
| 85 | + | ||
| 86 | + | const hue = (b[0]! | (b[1]! << 8)) % 360; | |
| 87 | + | const sat = (b[2]! % 20) + 65; // 65–84% | |
| 88 | + | const lit = (b[3]! % 20) + 40; // 40–59% | |
| 89 | + | const [fr, fg, fb] = hslToRgb(hue, sat, lit); | |
| 90 | + | ||
| 91 | + | // 128×128, background #f0f0f0, 5×5 symmetric identicon | |
| 92 | + | // PADDING=24, CELL=16: 2×24 + 5×16 = 128 | |
| 93 | + | const SIZE = 128, | |
| 94 | + | PADDING = 24, | |
| 95 | + | CELL = 16; | |
| 96 | + | const pixels = new Uint8ClampedArray(SIZE * SIZE * 4).fill(255); | |
| 97 | + | for (let i = 0; i < SIZE * SIZE * 4; i += 4) { | |
| 98 | + | pixels[i] = 240; | |
| 99 | + | pixels[i + 1] = 240; | |
| 100 | + | pixels[i + 2] = 240; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // 5×5 symmetric identicon (col mapping: 0→0, 1→1, 2→2, 3→1, 4→0) | |
| 104 | + | for (let row = 0; row < 5; row++) { | |
| 105 | + | for (let col = 0; col < 5; col++) { | |
| 106 | + | const srcCol = col < 3 ? col : 4 - col; | |
| 107 | + | if ((b[row * 3 + srcCol]! & 1) === 0) continue; | |
| 108 | + | const x0 = PADDING + col * CELL; | |
| 109 | + | const y0 = PADDING + row * CELL; | |
| 110 | + | for (let y = y0; y < y0 + CELL; y++) { | |
| 111 | + | for (let x = x0; x < x0 + CELL; x++) { | |
| 112 | + | const i = (y * SIZE + x) * 4; | |
| 113 | + | pixels[i] = fr; | |
| 114 | + | pixels[i + 1] = fg; | |
| 115 | + | pixels[i + 2] = fb; | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | const jxl = await encode({ data: pixels, width: SIZE, height: SIZE }); | |
| 122 | + | await Bun.write(avatarJxlPath(userId), jxl); | |
| 123 | + | } | |
Asrc/services/diffHighlight.ts
| @@ -0,0 +1,268 @@ | |||
|---|---|---|---|
| 1 | + | import path from "node:path"; | |
| 2 | + | import type { BundledLanguage } from "shiki"; | |
| 3 | + | import { MAX_DIFF_CACHE } from "../constants.ts"; | |
| 4 | + | import { detectLang, getHighlighter } from "./highlight.ts"; | |
| 5 | + | ||
| 6 | + | // ─── Types ─────────────────────────────────────────────────────────────────── | |
| 7 | + | ||
| 8 | + | export type DiffStatus = | |
| 9 | + | | "added" | |
| 10 | + | | "deleted" | |
| 11 | + | | "modified" | |
| 12 | + | | "renamed" | |
| 13 | + | | "copied"; | |
| 14 | + | ||
| 15 | + | export interface RenderedRow { | |
| 16 | + | type: "add" | "del" | "context"; | |
| 17 | + | oldLine: number | null; | |
| 18 | + | newLine: number | null; | |
| 19 | + | html: string; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export interface RenderedHunk { | |
| 23 | + | header: string; | |
| 24 | + | rows: RenderedRow[]; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | export interface RenderedDiffFile { | |
| 28 | + | oldPath: string; | |
| 29 | + | newPath: string; | |
| 30 | + | status: DiffStatus; | |
| 31 | + | added: number; | |
| 32 | + | removed: number; | |
| 33 | + | isBinary: boolean; | |
| 34 | + | hunks: RenderedHunk[]; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ─── Parser ────────────────────────────────────────────────────────────────── | |
| 38 | + | ||
| 39 | + | interface ParsedLine { | |
| 40 | + | type: "add" | "del" | "context"; | |
| 41 | + | content: string; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | interface ParsedHunk { | |
| 45 | + | header: string; | |
| 46 | + | oldStart: number; | |
| 47 | + | newStart: number; | |
| 48 | + | lines: ParsedLine[]; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | export interface ParsedFile { | |
| 52 | + | oldPath: string; | |
| 53 | + | newPath: string; | |
| 54 | + | status: DiffStatus; | |
| 55 | + | added: number; | |
| 56 | + | removed: number; | |
| 57 | + | isBinary: boolean; | |
| 58 | + | hunks: ParsedHunk[]; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | export function parseDiff(raw: string): ParsedFile[] { | |
| 62 | + | const files: ParsedFile[] = []; | |
| 63 | + | const allLines = raw.split("\n"); | |
| 64 | + | let i = 0; | |
| 65 | + | ||
| 66 | + | while (i < allLines.length) { | |
| 67 | + | if (!allLines[i]!.startsWith("diff --git ")) { | |
| 68 | + | i++; | |
| 69 | + | continue; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | const m = allLines[i]!.match(/^diff --git a\/(.+) b\/(.+)$/); | |
| 73 | + | const fallback = m ? (m[2] ?? "") : ""; | |
| 74 | + | const file: ParsedFile = { | |
| 75 | + | oldPath: fallback, | |
| 76 | + | newPath: fallback, | |
| 77 | + | status: "modified", | |
| 78 | + | added: 0, | |
| 79 | + | removed: 0, | |
| 80 | + | isBinary: false, | |
| 81 | + | hunks: [], | |
| 82 | + | }; | |
| 83 | + | i++; | |
| 84 | + | ||
| 85 | + | while (i < allLines.length) { | |
| 86 | + | const line = allLines[i]!; | |
| 87 | + | if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break; | |
| 88 | + | if (line.startsWith("new file")) file.status = "added"; | |
| 89 | + | else if (line.startsWith("deleted file")) file.status = "deleted"; | |
| 90 | + | else if (line.startsWith("rename from ")) { | |
| 91 | + | file.status = "renamed"; | |
| 92 | + | file.oldPath = line.slice(12); | |
| 93 | + | } else if (line.startsWith("rename to ")) { | |
| 94 | + | file.newPath = line.slice(10); | |
| 95 | + | } else if (line.startsWith("copy from ")) { | |
| 96 | + | file.status = "copied"; | |
| 97 | + | file.oldPath = line.slice(10); | |
| 98 | + | } else if (line.startsWith("copy to ")) { | |
| 99 | + | file.newPath = line.slice(8); | |
| 100 | + | } else if (line.startsWith("--- ") && line !== "--- /dev/null") | |
| 101 | + | file.oldPath = line.slice(6); | |
| 102 | + | else if (line.startsWith("+++ ") && line !== "+++ /dev/null") | |
| 103 | + | file.newPath = line.slice(6); | |
| 104 | + | else if (line.startsWith("Binary files ")) file.isBinary = true; | |
| 105 | + | i++; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | while (i < allLines.length && allLines[i]!.startsWith("@@ ")) { | |
| 109 | + | const hm = allLines[i]!.match( | |
| 110 | + | /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/, | |
| 111 | + | ); | |
| 112 | + | const hunk: ParsedHunk = { | |
| 113 | + | header: allLines[i]!, | |
| 114 | + | oldStart: hm ? parseInt(hm[1]!, 10) : 1, | |
| 115 | + | newStart: hm ? parseInt(hm[2]!, 10) : 1, | |
| 116 | + | lines: [], | |
| 117 | + | }; | |
| 118 | + | i++; | |
| 119 | + | while ( | |
| 120 | + | i < allLines.length && | |
| 121 | + | !allLines[i]!.startsWith("@@ ") && | |
| 122 | + | !allLines[i]!.startsWith("diff --git ") | |
| 123 | + | ) { | |
| 124 | + | const l = allLines[i]!; | |
| 125 | + | if (l.startsWith("+")) { | |
| 126 | + | hunk.lines.push({ type: "add", content: l.slice(1) }); | |
| 127 | + | file.added++; | |
| 128 | + | } else if (l.startsWith("-")) { | |
| 129 | + | hunk.lines.push({ type: "del", content: l.slice(1) }); | |
| 130 | + | file.removed++; | |
| 131 | + | } else if (l.startsWith(" ")) { | |
| 132 | + | hunk.lines.push({ type: "context", content: l.slice(1) }); | |
| 133 | + | } | |
| 134 | + | // skip "\ No newline at end of file" | |
| 135 | + | i++; | |
| 136 | + | } | |
| 137 | + | file.hunks.push(hunk); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | files.push(file); | |
| 141 | + | } | |
| 142 | + | return files; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | function escapeHtml(s: string): string { | |
| 146 | + | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // ─── Highlight a hunk ──────────────────────────────────────────────────────── | |
| 150 | + | ||
| 151 | + | async function highlightHunk( | |
| 152 | + | hunk: ParsedHunk, | |
| 153 | + | lang: string, | |
| 154 | + | ): Promise<string[]> { | |
| 155 | + | if (hunk.lines.length === 0) return []; | |
| 156 | + | const code = hunk.lines.map((l) => l.content).join("\n"); | |
| 157 | + | try { | |
| 158 | + | const h = await getHighlighter(); | |
| 159 | + | const tokensByLine = h.codeToTokensWithThemes(code, { | |
| 160 | + | lang: lang as BundledLanguage, | |
| 161 | + | themes: { light: "github-light", dark: "github-dark" }, | |
| 162 | + | }); | |
| 163 | + | const lines = tokensByLine.map((lineTokens) => | |
| 164 | + | lineTokens | |
| 165 | + | .map((token) => { | |
| 166 | + | const style = Object.entries(token.variants) | |
| 167 | + | .map( | |
| 168 | + | ([theme, v]) => | |
| 169 | + | `--shiki-${theme}:${v.color ?? "inherit"}`, | |
| 170 | + | ) | |
| 171 | + | .join(";"); | |
| 172 | + | return `<span style="${style}">${escapeHtml(token.content)}</span>`; | |
| 173 | + | }) | |
| 174 | + | .join(""), | |
| 175 | + | ); | |
| 176 | + | while (lines.length < hunk.lines.length) lines.push(""); | |
| 177 | + | return lines; | |
| 178 | + | } catch { | |
| 179 | + | return hunk.lines.map((l) => escapeHtml(l.content)); | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // ─── Build rendered rows ────────────────────────────────────────────────────── | |
| 184 | + | ||
| 185 | + | function buildRows(hunk: ParsedHunk, highlighted: string[]): RenderedRow[] { | |
| 186 | + | const rows: RenderedRow[] = []; | |
| 187 | + | let oldLine = hunk.oldStart; | |
| 188 | + | let newLine = hunk.newStart; | |
| 189 | + | for (let i = 0; i < hunk.lines.length; i++) { | |
| 190 | + | const { type } = hunk.lines[i]!; | |
| 191 | + | const html = highlighted[i] ?? ""; | |
| 192 | + | if (type === "context") { | |
| 193 | + | rows.push({ type, oldLine: oldLine++, newLine: newLine++, html }); | |
| 194 | + | } else if (type === "add") { | |
| 195 | + | rows.push({ type, oldLine: null, newLine: newLine++, html }); | |
| 196 | + | } else { | |
| 197 | + | rows.push({ type, oldLine: oldLine++, newLine: null, html }); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | return rows; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // ─── Highlight a single parsed file ────────────────────────────────────────── | |
| 204 | + | ||
| 205 | + | export async function highlightFile( | |
| 206 | + | file: ParsedFile, | |
| 207 | + | ): Promise<RenderedDiffFile> { | |
| 208 | + | const displayPath = file.newPath || file.oldPath; | |
| 209 | + | const lang = detectLang(path.basename(displayPath)); | |
| 210 | + | const highlightedHunks = await Promise.all( | |
| 211 | + | file.hunks.map((hunk) => highlightHunk(hunk, lang)), | |
| 212 | + | ); | |
| 213 | + | const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({ | |
| 214 | + | header: hunk.header, | |
| 215 | + | rows: buildRows(hunk, highlightedHunks[i]!), | |
| 216 | + | })); | |
| 217 | + | return { | |
| 218 | + | oldPath: file.oldPath, | |
| 219 | + | newPath: file.newPath, | |
| 220 | + | status: file.status, | |
| 221 | + | added: file.added, | |
| 222 | + | removed: file.removed, | |
| 223 | + | isBinary: file.isBinary, | |
| 224 | + | hunks, | |
| 225 | + | }; | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // ─── Public API ────────────────────────────────────────────────────────────── | |
| 229 | + | ||
| 230 | + | const diffCache = new Map<string, RenderedDiffFile[]>(); | |
| 231 | + | ||
| 232 | + | export async function prepareDiff( | |
| 233 | + | rawDiff: string, | |
| 234 | + | cacheKey: string, | |
| 235 | + | ): Promise<RenderedDiffFile[]> { | |
| 236 | + | const cached = diffCache.get(cacheKey); | |
| 237 | + | if (cached) return cached; | |
| 238 | + | ||
| 239 | + | const parsed = parseDiff(rawDiff); | |
| 240 | + | const result = await Promise.all( | |
| 241 | + | parsed.map(async (file) => { | |
| 242 | + | const displayPath = file.newPath || file.oldPath; | |
| 243 | + | const lang = detectLang(path.basename(displayPath)); | |
| 244 | + | const highlightedHunks = await Promise.all( | |
| 245 | + | file.hunks.map((hunk) => highlightHunk(hunk, lang)), | |
| 246 | + | ); | |
| 247 | + | const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({ | |
| 248 | + | header: hunk.header, | |
| 249 | + | rows: buildRows(hunk, highlightedHunks[i]!), | |
| 250 | + | })); | |
| 251 | + | return { | |
| 252 | + | oldPath: file.oldPath, | |
| 253 | + | newPath: file.newPath, | |
| 254 | + | status: file.status, | |
| 255 | + | added: file.added, | |
| 256 | + | removed: file.removed, | |
| 257 | + | isBinary: file.isBinary, | |
| 258 | + | hunks, | |
| 259 | + | }; | |
| 260 | + | }), | |
| 261 | + | ); | |
| 262 | + | if (cacheKey) { | |
| 263 | + | if (diffCache.size >= MAX_DIFF_CACHE) | |
| 264 | + | diffCache.delete(diffCache.keys().next().value!); | |
| 265 | + | diffCache.set(cacheKey, result); | |
| 266 | + | } | |
| 267 | + | return result; | |
| 268 | + | } | |
Asrc/services/git.ts
| @@ -0,0 +1,438 @@ | |||
|---|---|---|---|
| 1 | + | import path from "node:path"; | |
| 2 | + | import { $ as _$ } from "bun"; | |
| 3 | + | ||
| 4 | + | import { REPOS_DIR } from "../constants.ts"; | |
| 5 | + | ||
| 6 | + | const $ = _$.env({ ...process.env, LC_ALL: "C", LANG: "C" }); | |
| 7 | + | ||
| 8 | + | // Per-repo mutex: prevents concurrent git write operations on the same repo | |
| 9 | + | // (e.g. two patches being merged simultaneously, which would corrupt the index). | |
| 10 | + | const repoWriteLocks = new Map<string, Promise<void>>(); | |
| 11 | + | ||
| 12 | + | async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> { | |
| 13 | + | const prev = repoWriteLocks.get(name) ?? Promise.resolve(); | |
| 14 | + | let unlock!: () => void; | |
| 15 | + | repoWriteLocks.set( | |
| 16 | + | name, | |
| 17 | + | prev.then( | |
| 18 | + | () => | |
| 19 | + | new Promise<void>((res) => { | |
| 20 | + | unlock = res; | |
| 21 | + | }), | |
| 22 | + | ), | |
| 23 | + | ); | |
| 24 | + | await prev; | |
| 25 | + | try { | |
| 26 | + | return await fn(); | |
| 27 | + | } finally { | |
| 28 | + | unlock(); | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function repoPath(name: string): string { | |
| 33 | + | return path.join(REPOS_DIR, `${name}.git`); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | export async function validateCommit( | |
| 37 | + | repoName: string, | |
| 38 | + | hash: string, | |
| 39 | + | ): Promise<boolean> { | |
| 40 | + | const p = repoPath(repoName); | |
| 41 | + | try { | |
| 42 | + | const out = await $`git -C ${p} cat-file -t ${hash}`.text(); | |
| 43 | + | return out.trim() === "commit"; | |
| 44 | + | } catch { | |
| 45 | + | return false; | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | export async function archiveRepo( | |
| 50 | + | repoName: string, | |
| 51 | + | commitHash: string, | |
| 52 | + | slug: string, | |
| 53 | + | outDir: string, | |
| 54 | + | signal?: AbortSignal, | |
| 55 | + | ): Promise<void> { | |
| 56 | + | const p = repoPath(repoName); | |
| 57 | + | const base = `${slug}-${commitHash.slice(0, 8)}`; | |
| 58 | + | const env = { ...process.env, LC_ALL: "C", LANG: "C" }; | |
| 59 | + | ||
| 60 | + | const zip = Bun.spawn( | |
| 61 | + | [ | |
| 62 | + | "git", | |
| 63 | + | "-C", | |
| 64 | + | p, | |
| 65 | + | "archive", | |
| 66 | + | "--format=zip", | |
| 67 | + | `--output=${path.join(outDir, `${base}.zip`)}`, | |
| 68 | + | commitHash, | |
| 69 | + | ], | |
| 70 | + | { signal, env }, | |
| 71 | + | ); | |
| 72 | + | if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed"); | |
| 73 | + | ||
| 74 | + | const tgz = Bun.spawn( | |
| 75 | + | [ | |
| 76 | + | "git", | |
| 77 | + | "-C", | |
| 78 | + | p, | |
| 79 | + | "archive", | |
| 80 | + | "--format=tar.gz", | |
| 81 | + | `--output=${path.join(outDir, `${base}.tar.gz`)}`, | |
| 82 | + | commitHash, | |
| 83 | + | ], | |
| 84 | + | { signal, env }, | |
| 85 | + | ); | |
| 86 | + | if ((await tgz.exited) !== 0) | |
| 87 | + | throw new Error("git archive (tar.gz) failed"); | |
| 88 | + | ||
| 89 | + | try { | |
| 90 | + | const tar = Bun.spawn( | |
| 91 | + | ["git", "-C", p, "archive", "--format=tar", commitHash], | |
| 92 | + | { signal, env, stdout: "pipe" }, | |
| 93 | + | ); | |
| 94 | + | const zst = Bun.spawn( | |
| 95 | + | ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)], | |
| 96 | + | { signal, env, stdin: tar.stdout }, | |
| 97 | + | ); | |
| 98 | + | await Promise.all([tar.exited, zst.exited]); | |
| 99 | + | } catch { | |
| 100 | + | // zstd not available — skip silently | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | export interface CommitEntry { | |
| 105 | + | hash: string; | |
| 106 | + | subject: string; | |
| 107 | + | author: string; | |
| 108 | + | date: string; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | export interface CommitMeta { | |
| 112 | + | hash: string; | |
| 113 | + | subject: string; | |
| 114 | + | body: string; | |
| 115 | + | author: string; | |
| 116 | + | email: string; | |
| 117 | + | date: string; | |
| 118 | + | parents: string[]; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | export interface TreeEntry { | |
| 122 | + | mode: string; | |
| 123 | + | type: "blob" | "tree"; | |
| 124 | + | hash: string; | |
| 125 | + | size: string; | |
| 126 | + | name: string; | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | function parseLog(out: string): CommitEntry[] { | |
| 130 | + | return out | |
| 131 | + | .split("\n") | |
| 132 | + | .filter(Boolean) | |
| 133 | + | .map((line) => { | |
| 134 | + | const parts = line.split("\x1f"); | |
| 135 | + | return { | |
| 136 | + | hash: parts[0] ?? "", | |
| 137 | + | subject: parts[1] ?? "", | |
| 138 | + | author: parts[2] ?? "", | |
| 139 | + | date: parts[3] ?? "", | |
| 140 | + | }; | |
| 141 | + | }); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | function parseLsTree(out: string): TreeEntry[] { | |
| 145 | + | return out | |
| 146 | + | .split("\n") | |
| 147 | + | .filter(Boolean) | |
| 148 | + | .map((line) => { | |
| 149 | + | // format: <mode> SP <type> SP <object> SP <object size> TAB <file> | |
| 150 | + | const tabIdx = line.indexOf("\t"); | |
| 151 | + | const name = line.slice(tabIdx + 1); | |
| 152 | + | const meta = line.slice(0, tabIdx).trim().split(/\s+/); | |
| 153 | + | return { | |
| 154 | + | mode: meta[0] ?? "", | |
| 155 | + | type: (meta[1] ?? "blob") as "blob" | "tree", | |
| 156 | + | hash: meta[2] ?? "", | |
| 157 | + | size: meta[3] ?? "-", | |
| 158 | + | name, | |
| 159 | + | }; | |
| 160 | + | }); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | function extractPatchSubject(patch: string): string { | |
| 164 | + | for (const line of patch.split("\n").slice(0, 30)) { | |
| 165 | + | if (line.startsWith("Subject: ")) { | |
| 166 | + | // Strip "[PATCH ...] " prefix added by git format-patch | |
| 167 | + | return line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, ""); | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | return ""; | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | export const git = { | |
| 174 | + | async init(name: string, branch = "main") { | |
| 175 | + | return withRepoLock(name, async () => { | |
| 176 | + | const p = repoPath(name); | |
| 177 | + | await $`git init --bare --initial-branch=${branch} ${p}`; | |
| 178 | + | }); | |
| 179 | + | }, | |
| 180 | + | ||
| 181 | + | async log( | |
| 182 | + | name: string, | |
| 183 | + | ref = "HEAD", | |
| 184 | + | limit = 30, | |
| 185 | + | skip = 0, | |
| 186 | + | ): Promise<CommitEntry[]> { | |
| 187 | + | const p = repoPath(name); | |
| 188 | + | try { | |
| 189 | + | const out = | |
| 190 | + | await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text(); | |
| 191 | + | return parseLog(out); | |
| 192 | + | } catch { | |
| 193 | + | return []; | |
| 194 | + | } | |
| 195 | + | }, | |
| 196 | + | ||
| 197 | + | async lsTree( | |
| 198 | + | name: string, | |
| 199 | + | ref: string, | |
| 200 | + | subpath = "", | |
| 201 | + | ): Promise<TreeEntry[]> { | |
| 202 | + | const p = repoPath(name); | |
| 203 | + | try { | |
| 204 | + | const args = subpath | |
| 205 | + | ? [ | |
| 206 | + | "git", | |
| 207 | + | "-C", | |
| 208 | + | p, | |
| 209 | + | "ls-tree", | |
| 210 | + | "--long", | |
| 211 | + | ref, | |
| 212 | + | "--", | |
| 213 | + | `${subpath}/`, | |
| 214 | + | ] | |
| 215 | + | : ["git", "-C", p, "ls-tree", "--long", ref]; | |
| 216 | + | const out = await $`${args}`.text(); | |
| 217 | + | const entries = parseLsTree(out); | |
| 218 | + | if (subpath) { | |
| 219 | + | // git ls-tree returns full paths like "subpath/name" — strip the prefix | |
| 220 | + | const prefix = `${subpath}/`; | |
| 221 | + | return entries.map((e) => ({ | |
| 222 | + | ...e, | |
| 223 | + | name: e.name.startsWith(prefix) | |
| 224 | + | ? e.name.slice(prefix.length) | |
| 225 | + | : e.name, | |
| 226 | + | })); | |
| 227 | + | } | |
| 228 | + | return entries; | |
| 229 | + | } catch { | |
| 230 | + | return []; | |
| 231 | + | } | |
| 232 | + | }, | |
| 233 | + | ||
| 234 | + | async show( | |
| 235 | + | name: string, | |
| 236 | + | ref: string, | |
| 237 | + | filePath: string, | |
| 238 | + | ): Promise<Buffer | null> { | |
| 239 | + | const p = repoPath(name); | |
| 240 | + | try { | |
| 241 | + | const buf = | |
| 242 | + | await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer(); | |
| 243 | + | return Buffer.from(buf); | |
| 244 | + | } catch { | |
| 245 | + | return null; | |
| 246 | + | } | |
| 247 | + | }, | |
| 248 | + | ||
| 249 | + | async diff(name: string, sha: string): Promise<string> { | |
| 250 | + | const p = repoPath(name); | |
| 251 | + | try { | |
| 252 | + | return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text(); | |
| 253 | + | } catch { | |
| 254 | + | return ""; | |
| 255 | + | } | |
| 256 | + | }, | |
| 257 | + | ||
| 258 | + | async branches(name: string): Promise<string[]> { | |
| 259 | + | const p = repoPath(name); | |
| 260 | + | try { | |
| 261 | + | // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax | |
| 262 | + | const fmt = "%(refname:short)"; | |
| 263 | + | const out = await $`git -C ${p} branch --format=${fmt}`.text(); | |
| 264 | + | return out.split("\n").filter(Boolean); | |
| 265 | + | } catch { | |
| 266 | + | return []; | |
| 267 | + | } | |
| 268 | + | }, | |
| 269 | + | ||
| 270 | + | async defaultBranch(name: string): Promise<string> { | |
| 271 | + | const p = repoPath(name); | |
| 272 | + | try { | |
| 273 | + | const fmt = "%(refname:short)"; | |
| 274 | + | const branchesOut = | |
| 275 | + | await $`git -C ${p} branch --format=${fmt}`.text(); | |
| 276 | + | const branches = branchesOut.split("\n").filter(Boolean); | |
| 277 | + | ||
| 278 | + | // Read what HEAD points to (may be an unborn branch). | |
| 279 | + | let headBranch: string | null = null; | |
| 280 | + | try { | |
| 281 | + | const out = | |
| 282 | + | await $`git -C ${p} symbolic-ref --short HEAD`.text(); | |
| 283 | + | headBranch = out.trim(); | |
| 284 | + | } catch { | |
| 285 | + | // detached HEAD — fall through | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // Only trust HEAD if it names a branch that actually exists. | |
| 289 | + | if (headBranch && branches.includes(headBranch)) { | |
| 290 | + | return headBranch; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // HEAD points to an unborn branch or is detached — prefer "main", | |
| 294 | + | // then "master", then whatever branch exists first. | |
| 295 | + | return ( | |
| 296 | + | branches.find((b) => b === "main") ?? | |
| 297 | + | branches.find((b) => b === "master") ?? | |
| 298 | + | branches[0] ?? | |
| 299 | + | "main" | |
| 300 | + | ); | |
| 301 | + | } catch { | |
| 302 | + | return "main"; | |
| 303 | + | } | |
| 304 | + | }, | |
| 305 | + | ||
| 306 | + | async getFileSize( | |
| 307 | + | name: string, | |
| 308 | + | ref: string, | |
| 309 | + | filePath: string, | |
| 310 | + | ): Promise<number | null> { | |
| 311 | + | const p = repoPath(name); | |
| 312 | + | try { | |
| 313 | + | const out = | |
| 314 | + | await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text(); | |
| 315 | + | return parseInt(out.trim(), 10); | |
| 316 | + | } catch { | |
| 317 | + | return null; | |
| 318 | + | } | |
| 319 | + | }, | |
| 320 | + | ||
| 321 | + | async checkPatch( | |
| 322 | + | name: string, | |
| 323 | + | patchContent: string, | |
| 324 | + | ): Promise<{ clean: boolean; output: string }> { | |
| 325 | + | const p = repoPath(name); | |
| 326 | + | const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`; | |
| 327 | + | try { | |
| 328 | + | await Bun.write(tmpFile, patchContent); | |
| 329 | + | // Bare repos have no working tree; populate the index from HEAD so we can | |
| 330 | + | // check against git objects (--cached) rather than the filesystem. | |
| 331 | + | await $`git -C ${p} read-tree HEAD`.quiet(); | |
| 332 | + | const result = | |
| 333 | + | await $`git -C ${p} apply --check --cached ${tmpFile}` | |
| 334 | + | .quiet() | |
| 335 | + | .nothrow(); | |
| 336 | + | return { | |
| 337 | + | clean: result.exitCode === 0, | |
| 338 | + | output: result.stderr.toString(), | |
| 339 | + | }; | |
| 340 | + | } catch (e) { | |
| 341 | + | return { clean: false, output: String(e) }; | |
| 342 | + | } finally { | |
| 343 | + | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 344 | + | } | |
| 345 | + | }, | |
| 346 | + | ||
| 347 | + | async applyPatch( | |
| 348 | + | name: string, | |
| 349 | + | patchContent: string, | |
| 350 | + | title: string, | |
| 351 | + | description?: string, | |
| 352 | + | ): Promise<void> { | |
| 353 | + | return withRepoLock(name, async () => { | |
| 354 | + | const p = repoPath(name); | |
| 355 | + | const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`; | |
| 356 | + | try { | |
| 357 | + | await Bun.write(tmpFile, patchContent); | |
| 358 | + | // Populate index, apply to index, then create a real commit in the bare repo. | |
| 359 | + | await $`git -C ${p} read-tree HEAD`; | |
| 360 | + | await $`git -C ${p} apply --cached ${tmpFile}`; | |
| 361 | + | const tree = (await $`git -C ${p} write-tree`.text()).trim(); | |
| 362 | + | const parent = ( | |
| 363 | + | await $`git -C ${p} rev-parse HEAD`.text() | |
| 364 | + | ).trim(); | |
| 365 | + | const fallback = description?.trim() | |
| 366 | + | ? `${title}\n\n${description.trim()}` | |
| 367 | + | : title; | |
| 368 | + | const msg = extractPatchSubject(patchContent) || fallback; | |
| 369 | + | const commit = ( | |
| 370 | + | await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`.text() | |
| 371 | + | ).trim(); | |
| 372 | + | const ref = ( | |
| 373 | + | await $`git -C ${p} symbolic-ref HEAD`.text() | |
| 374 | + | ).trim(); | |
| 375 | + | await $`git -C ${p} update-ref ${ref} ${commit}`; | |
| 376 | + | } finally { | |
| 377 | + | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 378 | + | } | |
| 379 | + | }); | |
| 380 | + | }, | |
| 381 | + | ||
| 382 | + | async setHead(name: string, branch: string): Promise<void> { | |
| 383 | + | const p = repoPath(name); | |
| 384 | + | await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`; | |
| 385 | + | }, | |
| 386 | + | ||
| 387 | + | async resolveRef(name: string, ref: string): Promise<string | null> { | |
| 388 | + | const p = repoPath(name); | |
| 389 | + | try { | |
| 390 | + | const out = await $`git -C ${p} rev-parse --verify ${ref}`.text(); | |
| 391 | + | return out.trim() || null; | |
| 392 | + | } catch { | |
| 393 | + | return null; | |
| 394 | + | } | |
| 395 | + | }, | |
| 396 | + | ||
| 397 | + | async hasCommits(name: string): Promise<boolean> { | |
| 398 | + | const p = repoPath(name); | |
| 399 | + | try { | |
| 400 | + | const out = await $`git -C ${p} log --oneline -1`.quiet().text(); | |
| 401 | + | return out.trim().length > 0; | |
| 402 | + | } catch { | |
| 403 | + | return false; | |
| 404 | + | } | |
| 405 | + | }, | |
| 406 | + | ||
| 407 | + | async commitMeta(name: string, sha: string): Promise<CommitMeta | null> { | |
| 408 | + | const p = repoPath(name); | |
| 409 | + | try { | |
| 410 | + | const [metaOut, msgOut] = await Promise.all([ | |
| 411 | + | $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%P ${sha}`.text(), | |
| 412 | + | $`git -C ${p} log --format=%B -1 ${sha}`.text(), | |
| 413 | + | ]); | |
| 414 | + | const parts = metaOut.trim().split("\x1f"); | |
| 415 | + | const fullMsg = msgOut.trimEnd(); | |
| 416 | + | const firstNl = fullMsg.indexOf("\n"); | |
| 417 | + | const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg; | |
| 418 | + | const body = | |
| 419 | + | firstNl >= 0 | |
| 420 | + | ? fullMsg | |
| 421 | + | .slice(firstNl + 1) | |
| 422 | + | .trimStart() | |
| 423 | + | .trimEnd() | |
| 424 | + | : ""; | |
| 425 | + | return { | |
| 426 | + | hash: parts[0] ?? sha, | |
| 427 | + | subject, | |
| 428 | + | body, | |
| 429 | + | author: parts[1] ?? "", | |
| 430 | + | email: parts[2] ?? "", | |
| 431 | + | date: parts[3] ?? "", | |
| 432 | + | parents: (parts[4] ?? "").trim().split(/\s+/).filter(Boolean), | |
| 433 | + | }; | |
| 434 | + | } catch { | |
| 435 | + | return null; | |
| 436 | + | } | |
| 437 | + | }, | |
| 438 | + | }; | |
Asrc/services/highlight.ts
| @@ -0,0 +1,143 @@ | |||
|---|---|---|---|
| 1 | + | import type { Language } from "linguist-languages"; | |
| 2 | + | import * as linguistLangs from "linguist-languages"; | |
| 3 | + | import { | |
| 4 | + | bundledLanguages, | |
| 5 | + | bundledLanguagesInfo, | |
| 6 | + | createHighlighter, | |
| 7 | + | type Highlighter, | |
| 8 | + | } from "shiki"; | |
| 9 | + | import { INLINE_MAX_BYTES } from "../config.ts"; | |
| 10 | + | import { MAX_FILE_CACHE } from "../constants.ts"; | |
| 11 | + | ||
| 12 | + | let highlighter: Highlighter | null = null; | |
| 13 | + | let extToLangId: Map<string, string> | null = null; | |
| 14 | + | let filenameToLangId: Map<string, string> | null = null; | |
| 15 | + | ||
| 16 | + | export async function highlightStartup(): Promise<void> { | |
| 17 | + | console.log( | |
| 18 | + | "[highlight] initializing highlighter and language detection maps...", | |
| 19 | + | ); | |
| 20 | + | ||
| 21 | + | // Index linguist languages by lowercase name and aliases for precise lookup. | |
| 22 | + | // Many shiki languages share the same tmScope (e.g. source.js has 31 entries), | |
| 23 | + | // so scope-based matching is unreliable — name/alias matching is used instead. | |
| 24 | + | const linguistByName = new Map<string, Language>(); | |
| 25 | + | for (const lang of Object.values( | |
| 26 | + | linguistLangs as Record<string, Language>, | |
| 27 | + | )) { | |
| 28 | + | linguistByName.set(lang.name.toLowerCase(), lang); | |
| 29 | + | for (const alias of lang.aliases ?? []) { | |
| 30 | + | if (!linguistByName.has(alias.toLowerCase())) { | |
| 31 | + | linguistByName.set(alias.toLowerCase(), lang); | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | const extMap = new Map<string, string>(); | |
| 37 | + | const fnMap = new Map<string, string>(); | |
| 38 | + | ||
| 39 | + | for (const { id, aliases } of bundledLanguagesInfo) { | |
| 40 | + | // Match shiki lang → linguist language by ID then aliases (no scope fallback) | |
| 41 | + | const linguistLang = | |
| 42 | + | linguistByName.get(id) ?? | |
| 43 | + | aliases?.reduce<Language | undefined>( | |
| 44 | + | (found, a) => found ?? linguistByName.get(a.toLowerCase()), | |
| 45 | + | undefined, | |
| 46 | + | ); | |
| 47 | + | if (!linguistLang) continue; | |
| 48 | + | for (const ext of linguistLang.extensions ?? []) { | |
| 49 | + | if (!extMap.has(ext)) extMap.set(ext, id); | |
| 50 | + | } | |
| 51 | + | for (const fn of linguistLang.filenames ?? []) { | |
| 52 | + | if (!fnMap.has(fn)) fnMap.set(fn, id); | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | extToLangId = extMap; | |
| 57 | + | filenameToLangId = fnMap; | |
| 58 | + | highlighter = await createHighlighter({ | |
| 59 | + | themes: ["github-light", "github-dark"], | |
| 60 | + | langs: Object.keys(bundledLanguages), | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | console.log( | |
| 64 | + | `[highlight] ready: ${extMap.size} extensions, ${fnMap.size} filenames`, | |
| 65 | + | ); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | export function getHighlighter(): Highlighter { | |
| 69 | + | if (!highlighter) throw new Error("highlightStartup() has not been called"); | |
| 70 | + | return highlighter; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional binary content detection | |
| 74 | + | const BINARY_RE = /[\x00-\x08\x0e-\x1f]/; | |
| 75 | + | ||
| 76 | + | export function hasBinaryContent(buf: Buffer): boolean { | |
| 77 | + | const sample = buf.subarray(0, 8000); | |
| 78 | + | return BINARY_RE.test(sample.toString("binary")); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | export function detectLang(filename: string): string { | |
| 82 | + | if (!extToLangId || !filenameToLangId) return "text"; | |
| 83 | + | const base = filename.split("/").pop() ?? filename; | |
| 84 | + | const ext = base.includes(".") ? base.slice(base.lastIndexOf(".")) : ""; | |
| 85 | + | return ( | |
| 86 | + | filenameToLangId.get(base) ?? | |
| 87 | + | (ext ? extToLangId.get(ext) : undefined) ?? | |
| 88 | + | "text" | |
| 89 | + | ); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | export type FileView = | |
| 93 | + | | { type: "inline"; html: string; lines: number } | |
| 94 | + | | { type: "download"; size: number } | |
| 95 | + | | { type: "binary"; size: number }; | |
| 96 | + | ||
| 97 | + | const fileCache = new Map<string, FileView>(); | |
| 98 | + | ||
| 99 | + | export async function serveFile( | |
| 100 | + | content: Buffer, | |
| 101 | + | filename: string, | |
| 102 | + | cacheKey: string, | |
| 103 | + | ): Promise<FileView> { | |
| 104 | + | const cached = fileCache.get(cacheKey); | |
| 105 | + | if (cached) return cached; | |
| 106 | + | ||
| 107 | + | const isBinary = hasBinaryContent(content); | |
| 108 | + | if (isBinary) { | |
| 109 | + | return { type: "binary", size: content.length }; | |
| 110 | + | } | |
| 111 | + | if (content.length > INLINE_MAX_BYTES) { | |
| 112 | + | return { type: "download", size: content.length }; | |
| 113 | + | } | |
| 114 | + | const text = content.toString("utf-8"); | |
| 115 | + | const lines = text.split("\n").length; | |
| 116 | + | let view: FileView; | |
| 117 | + | try { | |
| 118 | + | const h = getHighlighter(); | |
| 119 | + | const lang = detectLang(filename); | |
| 120 | + | const html = h.codeToHtml(text, { | |
| 121 | + | lang, | |
| 122 | + | themes: { light: "github-light", dark: "github-dark" }, | |
| 123 | + | }); | |
| 124 | + | view = { type: "inline", html, lines }; | |
| 125 | + | } catch { | |
| 126 | + | // fallback: plain pre | |
| 127 | + | const escaped = text | |
| 128 | + | .replace(/&/g, "&") | |
| 129 | + | .replace(/</g, "<") | |
| 130 | + | .replace(/>/g, ">"); | |
| 131 | + | view = { | |
| 132 | + | type: "inline", | |
| 133 | + | html: `<pre class="code-plain"><code>${escaped}</code></pre>`, | |
| 134 | + | lines, | |
| 135 | + | }; | |
| 136 | + | } | |
| 137 | + | if (cacheKey) { | |
| 138 | + | if (fileCache.size >= MAX_FILE_CACHE) | |
| 139 | + | fileCache.delete(fileCache.keys().next().value!); | |
| 140 | + | fileCache.set(cacheKey, view); | |
| 141 | + | } | |
| 142 | + | return view; | |
| 143 | + | } | |
Asrc/services/highlightWorker.ts
| @@ -0,0 +1,122 @@ | |||
|---|---|---|---|
| 1 | + | import { HIGHLIGHT_WORKERS } from "../config.ts"; | |
| 2 | + | import { MAX_DIFF_CACHE } from "../constants.ts"; | |
| 3 | + | import type { ParsedFile, RenderedDiffFile } from "./diffHighlight.ts"; | |
| 4 | + | import { parseDiff } from "./diffHighlight.ts"; | |
| 5 | + | import type { FileView } from "./highlight.ts"; | |
| 6 | + | import { hasBinaryContent } from "./highlight.ts"; | |
| 7 | + | ||
| 8 | + | export { hasBinaryContent }; | |
| 9 | + | ||
| 10 | + | // ─── Worker pool ────────────────────────────────────────────────────────────── | |
| 11 | + | ||
| 12 | + | interface WorkerHandle { | |
| 13 | + | worker: Worker; | |
| 14 | + | ready: Promise<void>; | |
| 15 | + | pending: number; | |
| 16 | + | handlers: Map< | |
| 17 | + | number, | |
| 18 | + | { resolve: (v: unknown) => void; reject: (e: unknown) => void } | |
| 19 | + | >; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | let nextId = 0; | |
| 23 | + | ||
| 24 | + | function createHandle(): WorkerHandle { | |
| 25 | + | const handlers = new Map< | |
| 26 | + | number, | |
| 27 | + | { resolve: (v: unknown) => void; reject: (e: unknown) => void } | |
| 28 | + | >(); | |
| 29 | + | const handle: WorkerHandle = { | |
| 30 | + | worker: null!, | |
| 31 | + | ready: null!, | |
| 32 | + | pending: 0, | |
| 33 | + | handlers, | |
| 34 | + | }; | |
| 35 | + | handle.worker = new Worker( | |
| 36 | + | new URL("../workers/highlight.worker.ts", import.meta.url).href, | |
| 37 | + | ); | |
| 38 | + | handle.ready = new Promise<void>((resolve) => { | |
| 39 | + | handle.worker.onmessage = (event: MessageEvent) => { | |
| 40 | + | if (event.data.type === "ready") { | |
| 41 | + | resolve(); | |
| 42 | + | handle.worker.onmessage = (e) => onMessage(handle, e); | |
| 43 | + | return; | |
| 44 | + | } | |
| 45 | + | onMessage(handle, event); | |
| 46 | + | }; | |
| 47 | + | }); | |
| 48 | + | return handle; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | function onMessage(handle: WorkerHandle, event: MessageEvent) { | |
| 52 | + | const { id, result, error } = event.data; | |
| 53 | + | const p = handle.handlers.get(id); | |
| 54 | + | if (!p) return; | |
| 55 | + | handle.handlers.delete(id); | |
| 56 | + | handle.pending--; | |
| 57 | + | if (error !== undefined) p.reject(new Error(error)); | |
| 58 | + | else p.resolve(result); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | const pool: WorkerHandle[] = Array.from( | |
| 62 | + | { length: HIGHLIGHT_WORKERS }, | |
| 63 | + | createHandle, | |
| 64 | + | ); | |
| 65 | + | ||
| 66 | + | function leastBusy(): WorkerHandle { | |
| 67 | + | return pool.reduce((a, b) => (a.pending <= b.pending ? a : b)); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | function request<T>(msg: Record<string, unknown>): Promise<T> { | |
| 71 | + | const handle = leastBusy(); | |
| 72 | + | const id = nextId++; | |
| 73 | + | handle.pending++; | |
| 74 | + | return new Promise<T>((resolve, reject) => { | |
| 75 | + | handle.handlers.set(id, { | |
| 76 | + | resolve: resolve as (v: unknown) => void, | |
| 77 | + | reject, | |
| 78 | + | }); | |
| 79 | + | handle.ready.then(() => handle.worker.postMessage({ id, ...msg })); | |
| 80 | + | }); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // ─── Diff cache (lives on main thread now that dispatch is per-file) ────────── | |
| 84 | + | ||
| 85 | + | const diffCache = new Map<string, RenderedDiffFile[]>(); | |
| 86 | + | ||
| 87 | + | // ─── Public API ─────────────────────────────────────────────────────────────── | |
| 88 | + | ||
| 89 | + | export function serveFile( | |
| 90 | + | content: Buffer, | |
| 91 | + | filename: string, | |
| 92 | + | cacheKey: string, | |
| 93 | + | ): Promise<FileView> { | |
| 94 | + | return request({ | |
| 95 | + | type: "serveFile", | |
| 96 | + | content: content.buffer, | |
| 97 | + | filename, | |
| 98 | + | cacheKey, | |
| 99 | + | }); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | export async function prepareDiff( | |
| 103 | + | rawDiff: string, | |
| 104 | + | cacheKey: string, | |
| 105 | + | ): Promise<RenderedDiffFile[]> { | |
| 106 | + | const cached = diffCache.get(cacheKey); | |
| 107 | + | if (cached) return cached; | |
| 108 | + | ||
| 109 | + | const parsed = parseDiff(rawDiff); | |
| 110 | + | const result = await Promise.all( | |
| 111 | + | parsed.map((file: ParsedFile) => | |
| 112 | + | request<RenderedDiffFile>({ type: "highlightFile", file }), | |
| 113 | + | ), | |
| 114 | + | ); | |
| 115 | + | ||
| 116 | + | if (cacheKey) { | |
| 117 | + | if (diffCache.size >= MAX_DIFF_CACHE) | |
| 118 | + | diffCache.delete(diffCache.keys().next().value!); | |
| 119 | + | diffCache.set(cacheKey, result); | |
| 120 | + | } | |
| 121 | + | return result; | |
| 122 | + | } | |
Asrc/services/markdown.ts
| @@ -0,0 +1,139 @@ | |||
|---|---|---|---|
| 1 | + | import DOMPurify from "isomorphic-dompurify"; | |
| 2 | + | import { Marked, marked, type Tokens } from "marked"; | |
| 3 | + | import { MAX_MD_CACHE } from "../constants.ts"; | |
| 4 | + | ||
| 5 | + | marked.setOptions({ gfm: true }); | |
| 6 | + | ||
| 7 | + | const mdCache = new Map<string, string>(); | |
| 8 | + | ||
| 9 | + | export function renderMarkdown(md: string, cacheKey?: string): string { | |
| 10 | + | if (cacheKey) { | |
| 11 | + | const cached = mdCache.get(cacheKey); | |
| 12 | + | if (cached) return cached; | |
| 13 | + | } | |
| 14 | + | const raw = marked(md) as string; | |
| 15 | + | const result = DOMPurify.sanitize(raw, { | |
| 16 | + | ADD_TAGS: ["details", "summary"], | |
| 17 | + | ADD_ATTR: ["class"], | |
| 18 | + | }); | |
| 19 | + | if (cacheKey) { | |
| 20 | + | if (mdCache.size >= MAX_MD_CACHE) | |
| 21 | + | mdCache.delete(mdCache.keys().next().value!); | |
| 22 | + | mdCache.set(cacheKey, result); | |
| 23 | + | } | |
| 24 | + | return result; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | const _plaintextMarked = new Marked({ gfm: true }); | |
| 28 | + | _plaintextMarked.use({ | |
| 29 | + | renderer: { | |
| 30 | + | // Block renderers | |
| 31 | + | heading({ tokens }) { | |
| 32 | + | return `${String(this.parser.parseInline(tokens))}\n\n`; | |
| 33 | + | }, | |
| 34 | + | paragraph({ tokens }) { | |
| 35 | + | return `${String(this.parser.parseInline(tokens))}\n\n`; | |
| 36 | + | }, | |
| 37 | + | blockquote({ tokens }) { | |
| 38 | + | return `${String(this.parser.parse(tokens)) | |
| 39 | + | .trim() | |
| 40 | + | .split("\n") | |
| 41 | + | .map((l) => `> ${l}`) | |
| 42 | + | .join("\n")}\n\n`; | |
| 43 | + | }, | |
| 44 | + | code({ text }) { | |
| 45 | + | return `${text}\n\n`; | |
| 46 | + | }, | |
| 47 | + | list(token) { | |
| 48 | + | const start = typeof token.start === "number" ? token.start : 1; | |
| 49 | + | let body = ""; | |
| 50 | + | for (let i = 0; i < token.items.length; i++) { | |
| 51 | + | const item = token.items[i]!; | |
| 52 | + | const prefix = token.ordered ? `${start + i}. ` : "- "; | |
| 53 | + | const checkedPrefix = item.task | |
| 54 | + | ? item.checked | |
| 55 | + | ? "[x] " | |
| 56 | + | : "[ ] " | |
| 57 | + | : ""; | |
| 58 | + | const content = String(this.parser.parse(item.tokens)) | |
| 59 | + | .trim() | |
| 60 | + | .replace(/\n+/g, " "); | |
| 61 | + | body += `${prefix + checkedPrefix + content}\n`; | |
| 62 | + | } | |
| 63 | + | return `${body}\n`; | |
| 64 | + | }, | |
| 65 | + | listitem() { | |
| 66 | + | // Handled entirely inside list() | |
| 67 | + | return ""; | |
| 68 | + | }, | |
| 69 | + | table(token: Tokens.Table) { | |
| 70 | + | const cells = (row: Tokens.TableCell[]) => | |
| 71 | + | row | |
| 72 | + | .map((c) => String(this.parser.parseInline(c.tokens))) | |
| 73 | + | .join(" | "); | |
| 74 | + | let out = `${cells(token.header)}\n`; | |
| 75 | + | for (const row of token.rows) { | |
| 76 | + | out += `${cells(row)}\n`; | |
| 77 | + | } | |
| 78 | + | return `${out}\n`; | |
| 79 | + | }, | |
| 80 | + | hr() { | |
| 81 | + | return "---\n\n"; | |
| 82 | + | }, | |
| 83 | + | html() { | |
| 84 | + | return ""; | |
| 85 | + | }, | |
| 86 | + | // Inline renderers | |
| 87 | + | strong({ tokens }) { | |
| 88 | + | return String(this.parser.parseInline(tokens)); | |
| 89 | + | }, | |
| 90 | + | em({ tokens }) { | |
| 91 | + | return String(this.parser.parseInline(tokens)); | |
| 92 | + | }, | |
| 93 | + | del({ tokens }) { | |
| 94 | + | return String(this.parser.parseInline(tokens)); | |
| 95 | + | }, | |
| 96 | + | codespan({ text }) { | |
| 97 | + | return `\`${text}\``; | |
| 98 | + | }, | |
| 99 | + | link({ tokens }) { | |
| 100 | + | return String(this.parser.parseInline(tokens)); | |
| 101 | + | }, | |
| 102 | + | image({ text, title }) { | |
| 103 | + | const label = (title || text || "").trim(); | |
| 104 | + | return label ? `[Image: ${label}]` : "[Image]"; | |
| 105 | + | }, | |
| 106 | + | br() { | |
| 107 | + | return "\n"; | |
| 108 | + | }, | |
| 109 | + | text(token) { | |
| 110 | + | if ("tokens" in token && token.tokens) { | |
| 111 | + | return String(this.parser.parseInline(token.tokens)); | |
| 112 | + | } | |
| 113 | + | return token.text; | |
| 114 | + | }, | |
| 115 | + | }, | |
| 116 | + | }); | |
| 117 | + | ||
| 118 | + | /** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */ | |
| 119 | + | export function markdownToPlaintext(md: string): string { | |
| 120 | + | return (_plaintextMarked.parse(md) as string) | |
| 121 | + | .replace(/\n{3,}/g, "\n\n") | |
| 122 | + | .trim(); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /** | |
| 126 | + | * Returns a short single-line preview of a plaintext string: | |
| 127 | + | * the first paragraph/heading line, truncated to maxLen chars. | |
| 128 | + | */ | |
| 129 | + | export function plaintextPreview(text: string, maxLen = 180): string { | |
| 130 | + | const firstBlock = text.split("\n\n")[0]?.trim() ?? ""; | |
| 131 | + | const firstLine = firstBlock.split("\n")[0] ?? ""; | |
| 132 | + | if (firstLine.length <= maxLen) return firstLine; | |
| 133 | + | const truncated = firstLine.slice(0, maxLen); | |
| 134 | + | const lastSpace = truncated.lastIndexOf(" "); | |
| 135 | + | return ( | |
| 136 | + | (lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated) + | |
| 137 | + | "…" | |
| 138 | + | ); | |
| 139 | + | } | |
Asrc/services/patchCache.ts
| @@ -0,0 +1,15 @@ | |||
|---|---|---|---|
| 1 | + | import { MAX_PATCH_CACHE } from "../constants.ts"; | |
| 2 | + | ||
| 3 | + | export type ApplyResult = { status: "clean" | "conflict"; output: string }; | |
| 4 | + | const cache = new Map<number, ApplyResult>(); | |
| 5 | + | ||
| 6 | + | export const patchCache = { | |
| 7 | + | get: (id: number): ApplyResult | undefined => cache.get(id), | |
| 8 | + | set: (id: number, result: ApplyResult) => { | |
| 9 | + | if (cache.size >= MAX_PATCH_CACHE) { | |
| 10 | + | cache.delete(cache.keys().next().value!); | |
| 11 | + | } | |
| 12 | + | cache.set(id, result); | |
| 13 | + | }, | |
| 14 | + | invalidate: (id: number) => cache.delete(id), | |
| 15 | + | }; | |
Asrc/services/reactions.ts
| @@ -0,0 +1,24 @@ | |||
|---|---|---|---|
| 1 | + | interface ReactionRow { | |
| 2 | + | emoji: string; | |
| 3 | + | comment_id: number | null; | |
| 4 | + | user_id: number; | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | export function buildReactionCounts( | |
| 8 | + | reactions: ReactionRow[], | |
| 9 | + | commentId: number | null, | |
| 10 | + | userId: number | undefined, | |
| 11 | + | ): { emoji: string; count: number; userReacted: boolean }[] { | |
| 12 | + | const filtered = reactions.filter((r) => | |
| 13 | + | commentId === null ? r.comment_id === null : r.comment_id === commentId, | |
| 14 | + | ); | |
| 15 | + | const map = new Map<string, { count: number; userReacted: boolean }>(); | |
| 16 | + | for (const r of filtered) { | |
| 17 | + | const entry = map.get(r.emoji) ?? { count: 0, userReacted: false }; | |
| 18 | + | entry.count++; | |
| 19 | + | if (userId !== undefined && r.user_id === userId) | |
| 20 | + | entry.userReacted = true; | |
| 21 | + | map.set(r.emoji, entry); | |
| 22 | + | } | |
| 23 | + | return [...map.entries()].map(([emoji, d]) => ({ emoji, ...d })); | |
| 24 | + | } | |
Asrc/services/repoSync.ts
| @@ -0,0 +1,211 @@ | |||
|---|---|---|---|
| 1 | + | import { | |
| 2 | + | type Dirent, | |
| 3 | + | existsSync, | |
| 4 | + | readdirSync, | |
| 5 | + | renameSync, | |
| 6 | + | rmSync, | |
| 7 | + | } from "node:fs"; | |
| 8 | + | import path from "node:path"; | |
| 9 | + | import { $ } from "bun"; | |
| 10 | + | import { RELEASES_DIR, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 11 | + | import type { RepositoryRow } from "../db/index.ts"; | |
| 12 | + | import { db } from "../db/index.ts"; | |
| 13 | + | import { git, repoPath } from "../services/git.ts"; | |
| 14 | + | ||
| 15 | + | // Converts a non-bare repo to bare in-place by extracting the .git directory. | |
| 16 | + | // Case 1: "myrepo/" — move myrepo/.git → myrepo.git/, delete myrepo/ | |
| 17 | + | // Case 2: "myrepo.git/" — move .git/ to a temp dir, delete myrepo.git/, rename temp → myrepo.git/ | |
| 18 | + | async function convertNonBareRepo( | |
| 19 | + | entryName: string, | |
| 20 | + | entryPath: string, | |
| 21 | + | ): Promise<void> { | |
| 22 | + | const dotGitPath = path.join(entryPath, ".git"); | |
| 23 | + | const hasGitSuffix = entryName.endsWith(".git"); | |
| 24 | + | const baseName = hasGitSuffix ? entryName : `${entryName}.git`; | |
| 25 | + | const targetPath = path.join(REPOS_DIR, baseName); | |
| 26 | + | ||
| 27 | + | try { | |
| 28 | + | if (hasGitSuffix) { | |
| 29 | + | // Case 2: source and target path are the same dir — use a temp location. | |
| 30 | + | const tmpPath = path.join(REPOS_DIR, `.${entryName}.bare_tmp`); | |
| 31 | + | renameSync(dotGitPath, tmpPath); | |
| 32 | + | rmSync(entryPath, { recursive: true, force: true }); | |
| 33 | + | renameSync(tmpPath, targetPath); | |
| 34 | + | } else { | |
| 35 | + | // Case 1: simple move. | |
| 36 | + | renameSync(dotGitPath, targetPath); | |
| 37 | + | rmSync(entryPath, { recursive: true, force: true }); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | const worktreesPath = path.join(targetPath, "worktrees"); | |
| 41 | + | if (existsSync(worktreesPath)) { | |
| 42 | + | rmSync(worktreesPath, { recursive: true, force: true }); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | console.log(`Converted non-bare repo to bare: ${baseName}`); | |
| 46 | + | } catch (err) { | |
| 47 | + | console.error(`Failed to convert non-bare repo ${entryName}:`, err); | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Scans REPOS_DIR for non-bare repos and converts them before the main sync. | |
| 52 | + | async function convertNonBareRepos(): Promise<void> { | |
| 53 | + | let entries: Dirent[]; | |
| 54 | + | try { | |
| 55 | + | entries = readdirSync(REPOS_DIR, { withFileTypes: true }); | |
| 56 | + | } catch { | |
| 57 | + | return; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | const conversions: Promise<void>[] = []; | |
| 61 | + | for (const entry of entries) { | |
| 62 | + | if (!entry.isDirectory()) continue; | |
| 63 | + | const entryPath = path.join(REPOS_DIR, entry.name); | |
| 64 | + | if (existsSync(path.join(entryPath, ".git"))) { | |
| 65 | + | conversions.push(convertNonBareRepo(entry.name, entryPath)); | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | await Promise.all(conversions); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | export function listDiskRepoNames(): string[] { | |
| 72 | + | try { | |
| 73 | + | return readdirSync(REPOS_DIR, { withFileTypes: true }) | |
| 74 | + | .filter((e) => e.isDirectory() && e.name.endsWith(".git")) | |
| 75 | + | .map((e) => e.name.slice(0, -4)); | |
| 76 | + | } catch { | |
| 77 | + | return []; | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | export function repoDiskExists(name: string): boolean { | |
| 82 | + | return existsSync(repoPath(name)); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | export async function ensureRepoRecord(name: string): Promise<RepositoryRow> { | |
| 86 | + | const existing = await db | |
| 87 | + | .selectFrom("repositories") | |
| 88 | + | .selectAll() | |
| 89 | + | .where("name", "=", name) | |
| 90 | + | .executeTakeFirst(); | |
| 91 | + | if (existing) return existing; | |
| 92 | + | ||
| 93 | + | await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`; | |
| 94 | + | ||
| 95 | + | const branch = await git.defaultBranch(name); | |
| 96 | + | const now = new Date().toISOString(); | |
| 97 | + | return await db | |
| 98 | + | .insertInto("repositories") | |
| 99 | + | .values({ | |
| 100 | + | name, | |
| 101 | + | description: null, | |
| 102 | + | is_private: 0, | |
| 103 | + | default_branch: branch, | |
| 104 | + | created_at: now, | |
| 105 | + | }) | |
| 106 | + | .returningAll() | |
| 107 | + | .executeTakeFirstOrThrow(); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | export async function syncStartup(): Promise<void> { | |
| 111 | + | await convertNonBareRepos(); | |
| 112 | + | const diskNames = new Set(listDiskRepoNames()); | |
| 113 | + | const dbRepos = await db | |
| 114 | + | .selectFrom("repositories") | |
| 115 | + | .select(["id", "name"]) | |
| 116 | + | .execute(); | |
| 117 | + | // Ensure all on-disk repos have DB records (e.g. repos pushed externally). | |
| 118 | + | // Skip names that fail validation — they can't be served anyway. | |
| 119 | + | const validDiskNames = [...diskNames].filter((n) => | |
| 120 | + | VALID_REPO_NAME_RE.test(n), | |
| 121 | + | ); | |
| 122 | + | await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name))); | |
| 123 | + | ||
| 124 | + | const stale = dbRepos.filter((r) => !diskNames.has(r.name)); | |
| 125 | + | if (stale.length > 0) { | |
| 126 | + | await db | |
| 127 | + | .deleteFrom("repositories") | |
| 128 | + | .where( | |
| 129 | + | "id", | |
| 130 | + | "in", | |
| 131 | + | stale.map((r) => r.id), | |
| 132 | + | ) | |
| 133 | + | .execute(); | |
| 134 | + | console.log( | |
| 135 | + | `Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`, | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Release cleanup: sync DB records against on-disk release directories. | |
| 140 | + | // Orphaned directories (no DB record) arise when the server crashes after | |
| 141 | + | // files are written but before the transaction commits. Stale DB records | |
| 142 | + | // (directory missing) arise when the server crashes after rmSync but before | |
| 143 | + | // the DB delete during release deletion. | |
| 144 | + | // | |
| 145 | + | // Note: releases with no source code and no assets have no on-disk | |
| 146 | + | // directory, so we only apply the stale-record check to releases that | |
| 147 | + | // should have a directory (include_source_code=1 or has release_assets). | |
| 148 | + | const allReleaseIds = new Set( | |
| 149 | + | (await db.selectFrom("releases").select("id").execute()).map( | |
| 150 | + | (r) => r.id, | |
| 151 | + | ), | |
| 152 | + | ); | |
| 153 | + | ||
| 154 | + | try { | |
| 155 | + | for (const entry of readdirSync(RELEASES_DIR, { | |
| 156 | + | withFileTypes: true, | |
| 157 | + | })) { | |
| 158 | + | if (!entry.isDirectory()) continue; | |
| 159 | + | const id = Number(entry.name); | |
| 160 | + | if (!Number.isNaN(id) && !allReleaseIds.has(id)) { | |
| 161 | + | rmSync(path.join(RELEASES_DIR, entry.name), { | |
| 162 | + | recursive: true, | |
| 163 | + | force: true, | |
| 164 | + | }); | |
| 165 | + | console.log( | |
| 166 | + | `Removed orphaned release directory: ${entry.name}`, | |
| 167 | + | ); | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } catch { | |
| 171 | + | // RELEASES_DIR may not exist yet on first run | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Only check for missing dirs on releases that should have one. | |
| 175 | + | const releasesWithDirs = await db | |
| 176 | + | .selectFrom("releases") | |
| 177 | + | .select("releases.id") | |
| 178 | + | .where((eb) => | |
| 179 | + | eb.or([ | |
| 180 | + | eb("releases.include_source_code", "=", 1), | |
| 181 | + | eb.exists( | |
| 182 | + | eb | |
| 183 | + | .selectFrom("release_assets") | |
| 184 | + | .select("release_assets.id") | |
| 185 | + | .whereRef( | |
| 186 | + | "release_assets.release_id", | |
| 187 | + | "=", | |
| 188 | + | "releases.id", | |
| 189 | + | ), | |
| 190 | + | ), | |
| 191 | + | ]), | |
| 192 | + | ) | |
| 193 | + | .execute(); | |
| 194 | + | ||
| 195 | + | const staleReleases = releasesWithDirs.filter( | |
| 196 | + | (r) => !existsSync(path.join(RELEASES_DIR, String(r.id))), | |
| 197 | + | ); | |
| 198 | + | if (staleReleases.length > 0) { | |
| 199 | + | await db | |
| 200 | + | .deleteFrom("releases") | |
| 201 | + | .where( | |
| 202 | + | "id", | |
| 203 | + | "in", | |
| 204 | + | staleReleases.map((r) => r.id), | |
| 205 | + | ) | |
| 206 | + | .execute(); | |
| 207 | + | console.log( | |
| 208 | + | `Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`, | |
| 209 | + | ); | |
| 210 | + | } | |
| 211 | + | } | |
Asrc/services/sshServer.ts
| @@ -0,0 +1,160 @@ | |||
|---|---|---|---|
| 1 | + | import { type ChildProcess, spawn } from "node:child_process"; | |
| 2 | + | import { createHash } from "node:crypto"; | |
| 3 | + | import { existsSync, readFileSync } from "node:fs"; | |
| 4 | + | import path from "node:path"; | |
| 5 | + | import { Server, utils } from "ssh2"; | |
| 6 | + | import { SSH_PORT } from "../config.ts"; | |
| 7 | + | import { ADMIN_USERNAME, REPOS_DIR, SSH_HOST_KEY_PATH } from "../constants.ts"; | |
| 8 | + | import { db } from "../db/index.ts"; | |
| 9 | + | ||
| 10 | + | /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */ | |
| 11 | + | function fingerprintFromBytes(keyBytes: Buffer): string { | |
| 12 | + | const hash = createHash("sha256") | |
| 13 | + | .update(keyBytes) | |
| 14 | + | .digest("base64") | |
| 15 | + | .replace(/=+$/, ""); | |
| 16 | + | return `SHA256:${hash}`; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** | |
| 20 | + | * Compute fingerprint from a full public key line | |
| 21 | + | * (e.g. "ssh-ed25519 AAAA... comment"). | |
| 22 | + | * Returns null if the line is malformed. | |
| 23 | + | */ | |
| 24 | + | export function fingerprintFromLine(pubkeyLine: string): string | null { | |
| 25 | + | const parts = pubkeyLine.trim().split(/\s+/); | |
| 26 | + | if (parts.length < 2) return null; | |
| 27 | + | try { | |
| 28 | + | const keyBytes = Buffer.from(parts[1] ?? "", "base64"); | |
| 29 | + | return fingerprintFromBytes(keyBytes); | |
| 30 | + | } catch { | |
| 31 | + | return null; | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | export async function startSshServer() { | |
| 36 | + | if (!existsSync(SSH_HOST_KEY_PATH)) { | |
| 37 | + | await Bun.spawn([ | |
| 38 | + | "ssh-keygen", | |
| 39 | + | "-t", | |
| 40 | + | "ed25519", | |
| 41 | + | "-N", | |
| 42 | + | "", | |
| 43 | + | "-f", | |
| 44 | + | SSH_HOST_KEY_PATH, | |
| 45 | + | "-C", | |
| 46 | + | "hearthforge-host", | |
| 47 | + | ]).exited; | |
| 48 | + | console.log("Generated SSH host key at", SSH_HOST_KEY_PATH); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | const hostKey = readFileSync(SSH_HOST_KEY_PATH); | |
| 52 | + | ||
| 53 | + | const server = new Server({ hostKeys: [hostKey] }, (client) => { | |
| 54 | + | let authedUser: { id: number; username: string } | null = null; | |
| 55 | + | ||
| 56 | + | client.on("authentication", async (ctx) => { | |
| 57 | + | if (ctx.method !== "publickey") { | |
| 58 | + | return ctx.reject(["publickey"]); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Probe phase: accept so the client proceeds to send a signature | |
| 62 | + | if (!ctx.signature) return ctx.accept(); | |
| 63 | + | ||
| 64 | + | // Signature phase: look up the stored key by fingerprint | |
| 65 | + | const fingerprint = fingerprintFromBytes(ctx.key.data); | |
| 66 | + | const sshKey = await db | |
| 67 | + | .selectFrom("ssh_keys") | |
| 68 | + | .innerJoin("users", "users.id", "ssh_keys.user_id") | |
| 69 | + | .select([ | |
| 70 | + | "users.id as userId", | |
| 71 | + | "users.username", | |
| 72 | + | "ssh_keys.public_key", | |
| 73 | + | ]) | |
| 74 | + | .where("ssh_keys.fingerprint", "=", fingerprint) | |
| 75 | + | .executeTakeFirst(); | |
| 76 | + | ||
| 77 | + | if (!sshKey) return ctx.reject(); | |
| 78 | + | ||
| 79 | + | // Verify signature using the stored public key text (parseKey needs key file format, not raw bytes) | |
| 80 | + | const parsed = utils.parseKey(sshKey.public_key); | |
| 81 | + | if (parsed instanceof Error || Array.isArray(parsed)) | |
| 82 | + | return ctx.reject(); | |
| 83 | + | ||
| 84 | + | const verifyResult = parsed.verify(ctx.blob!, ctx.signature); | |
| 85 | + | if (verifyResult !== true) return ctx.reject(); | |
| 86 | + | ||
| 87 | + | authedUser = { id: sshKey.userId, username: sshKey.username }; | |
| 88 | + | ctx.accept(); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | client.on("ready", () => { | |
| 92 | + | client.on("session", (accept) => { | |
| 93 | + | const session = accept(); | |
| 94 | + | ||
| 95 | + | session.on("exec", async (accept, reject, info) => { | |
| 96 | + | // git sends: git-upload-pack '/reponame.git' | |
| 97 | + | const match = info.command.match( | |
| 98 | + | /^(git-upload-pack|git-receive-pack)\s+'?\/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$/, | |
| 99 | + | ); | |
| 100 | + | if (!match) return reject(); | |
| 101 | + | ||
| 102 | + | const command = match[1]!; | |
| 103 | + | const repoName = match[2]!; | |
| 104 | + | ||
| 105 | + | const repo = await db | |
| 106 | + | .selectFrom("repositories") | |
| 107 | + | .select(["name", "is_private"]) | |
| 108 | + | .where("name", "=", repoName) | |
| 109 | + | .executeTakeFirst(); | |
| 110 | + | if (!repo) return reject(); | |
| 111 | + | ||
| 112 | + | const repoPath = path.join(REPOS_DIR, `${repo.name}.git`); | |
| 113 | + | const stream = accept(); | |
| 114 | + | ||
| 115 | + | if (command === "git-receive-pack") { | |
| 116 | + | if ( | |
| 117 | + | !authedUser || | |
| 118 | + | authedUser.username !== ADMIN_USERNAME | |
| 119 | + | ) { | |
| 120 | + | stream.stderr.write("error: push access denied\n"); | |
| 121 | + | stream.exit(128); | |
| 122 | + | stream.end(); | |
| 123 | + | return; | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | if (repo.is_private && !authedUser) { | |
| 128 | + | stream.stderr.write( | |
| 129 | + | "error: repository access denied\n", | |
| 130 | + | ); | |
| 131 | + | stream.exit(128); | |
| 132 | + | stream.end(); | |
| 133 | + | return; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | const proc: ChildProcess = spawn(command, [repoPath]); | |
| 137 | + | stream.pipe(proc.stdin!); | |
| 138 | + | proc.stdout?.pipe(stream, { end: false }); | |
| 139 | + | proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, { | |
| 140 | + | end: false, | |
| 141 | + | }); | |
| 142 | + | ||
| 143 | + | proc.on("close", (code: number | null) => { | |
| 144 | + | stream.exit(code ?? 0); | |
| 145 | + | stream.end(); | |
| 146 | + | }); | |
| 147 | + | stream.on("close", () => proc.kill()); | |
| 148 | + | }); | |
| 149 | + | }); | |
| 150 | + | }); | |
| 151 | + | ||
| 152 | + | client.on("error", () => { | |
| 153 | + | /* absorb ECONNRESET etc. */ | |
| 154 | + | }); | |
| 155 | + | }); | |
| 156 | + | ||
| 157 | + | server.listen(SSH_PORT, "0.0.0.0", () => { | |
| 158 | + | console.log(`SSH server listening on port ${SSH_PORT}`); | |
| 159 | + | }); | |
| 160 | + | } | |
Asrc/views/Avatar.tsx
| @@ -0,0 +1,42 @@ | |||
|---|---|---|---|
| 1 | + | // 1×1 transparent GIF — used as img fallback so the browser never enters | |
| 2 | + | // broken-image state while waiting for the JXL polyfill to decode the source. | |
| 3 | + | const TRANSPARENT = | |
| 4 | + | "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; | |
| 5 | + | ||
| 6 | + | export function Avatar({ | |
| 7 | + | userId, | |
| 8 | + | version, | |
| 9 | + | size = 32, | |
| 10 | + | }: { | |
| 11 | + | userId: number | null; | |
| 12 | + | version?: number | null; | |
| 13 | + | size?: number; | |
| 14 | + | }) { | |
| 15 | + | const style = `width:${size}px;height:${size}px`; | |
| 16 | + | if (!userId) { | |
| 17 | + | return ( | |
| 18 | + | <span | |
| 19 | + | class="avatar avatar-placeholder" | |
| 20 | + | style={`${style};font-size:${Math.round(size * 0.45)}px`} | |
| 21 | + | > | |
| 22 | + | ? | |
| 23 | + | </span> | |
| 24 | + | ); | |
| 25 | + | } | |
| 26 | + | const src = | |
| 27 | + | version != null | |
| 28 | + | ? `/avatars/${userId}.jxl?v=${version}` | |
| 29 | + | : `/avatars/${userId}.jxl`; | |
| 30 | + | return ( | |
| 31 | + | <picture class="avatar" style={style}> | |
| 32 | + | <source srcset={src} type="image/jxl" /> | |
| 33 | + | <img | |
| 34 | + | src={TRANSPARENT} | |
| 35 | + | alt="" | |
| 36 | + | width={String(size)} | |
| 37 | + | height={String(size)} | |
| 38 | + | class="avatar-img" | |
| 39 | + | /> | |
| 40 | + | </picture> | |
| 41 | + | ); | |
| 42 | + | } | |
Asrc/views/DateWithEdited.tsx
| @@ -0,0 +1,19 @@ | |||
|---|---|---|---|
| 1 | + | export function DateWithEdited({ | |
| 2 | + | date, | |
| 3 | + | editedAt, | |
| 4 | + | }: { | |
| 5 | + | date: string; | |
| 6 | + | editedAt: string | null; | |
| 7 | + | }) { | |
| 8 | + | const label = new Date(date).toLocaleDateString(); | |
| 9 | + | if (!editedAt) return <time datetime={date}>{label}</time>; | |
| 10 | + | return ( | |
| 11 | + | <time | |
| 12 | + | datetime={date} | |
| 13 | + | class="edited-indicator" | |
| 14 | + | title={`Edited ${new Date(editedAt).toLocaleString()}`} | |
| 15 | + | > | |
| 16 | + | *{label} | |
| 17 | + | </time> | |
| 18 | + | ); | |
| 19 | + | } | |
Asrc/views/DiffView.tsx
| @@ -0,0 +1,287 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../db/index.ts"; | |
| 2 | + | import type { RenderedDiffFile } from "../services/diffHighlight.ts"; | |
| 3 | + | ||
| 4 | + | export function slugify(s: string): string { | |
| 5 | + | return `diff-${s.replace(/[^a-zA-Z0-9]/g, "-")}`; | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | export function statusLetter(status: string): string { | |
| 9 | + | return ( | |
| 10 | + | ( | |
| 11 | + | { | |
| 12 | + | added: "A", | |
| 13 | + | deleted: "D", | |
| 14 | + | modified: "M", | |
| 15 | + | renamed: "R", | |
| 16 | + | copied: "C", | |
| 17 | + | } as Record<string, string> | |
| 18 | + | )[status] ?? "M" | |
| 19 | + | ); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export type FileTreeNode = | |
| 23 | + | | { type: "file"; file: RenderedDiffFile } | |
| 24 | + | | { type: "dir"; children: Map<string, FileTreeNode> }; | |
| 25 | + | ||
| 26 | + | export function buildFileTree( | |
| 27 | + | files: RenderedDiffFile[], | |
| 28 | + | ): Map<string, FileTreeNode> { | |
| 29 | + | const root = new Map<string, FileTreeNode>(); | |
| 30 | + | for (const f of files) { | |
| 31 | + | const p = f.newPath || f.oldPath; | |
| 32 | + | const parts = p.split("/"); | |
| 33 | + | let current = root; | |
| 34 | + | for (let i = 0; i < parts.length - 1; i++) { | |
| 35 | + | const part = parts[i]!; | |
| 36 | + | if (!current.has(part)) { | |
| 37 | + | current.set(part, { type: "dir", children: new Map() }); | |
| 38 | + | } | |
| 39 | + | const node = current.get(part)!; | |
| 40 | + | if (node.type === "dir") current = node.children; | |
| 41 | + | } | |
| 42 | + | current.set(parts[parts.length - 1]!, { type: "file", file: f }); | |
| 43 | + | } | |
| 44 | + | return root; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element { | |
| 48 | + | return ( | |
| 49 | + | <ul class="file-nav-list"> | |
| 50 | + | {[...tree.entries()] | |
| 51 | + | .sort(([, a], [, b]) => | |
| 52 | + | a.type === b.type ? 0 : a.type === "dir" ? -1 : 1, | |
| 53 | + | ) | |
| 54 | + | .map(([name, node]) => | |
| 55 | + | node.type === "dir" ? ( | |
| 56 | + | <li class="file-nav-dir"> | |
| 57 | + | <details open> | |
| 58 | + | <summary class="file-nav-dir-toggle"> | |
| 59 | + | <span class="file-nav-toggle-icon">▾</span> | |
| 60 | + | {name}/ | |
| 61 | + | </summary> | |
| 62 | + | {renderFileTree(node.children)} | |
| 63 | + | </details> | |
| 64 | + | </li> | |
| 65 | + | ) : ( | |
| 66 | + | <li> | |
| 67 | + | <a | |
| 68 | + | href={`#${slugify(node.file.newPath || node.file.oldPath)}`} | |
| 69 | + | class="file-nav-item" | |
| 70 | + | title={node.file.newPath || node.file.oldPath} | |
| 71 | + | > | |
| 72 | + | <span | |
| 73 | + | class={`file-nav-status file-status-${node.file.status}`} | |
| 74 | + | > | |
| 75 | + | {statusLetter(node.file.status)} | |
| 76 | + | </span> | |
| 77 | + | <span class="file-nav-name">{name}</span> | |
| 78 | + | <span class="file-nav-stat"> | |
| 79 | + | {node.file.added > 0 && ( | |
| 80 | + | <span class="nav-add"> | |
| 81 | + | +{node.file.added} | |
| 82 | + | </span> | |
| 83 | + | )} | |
| 84 | + | {node.file.removed > 0 && ( | |
| 85 | + | <span class="nav-del"> | |
| 86 | + | -{node.file.removed} | |
| 87 | + | </span> | |
| 88 | + | )} | |
| 89 | + | </span> | |
| 90 | + | </a> | |
| 91 | + | </li> | |
| 92 | + | ), | |
| 93 | + | )} | |
| 94 | + | </ul> | |
| 95 | + | ); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | interface DiffViewProps { | |
| 99 | + | files: RenderedDiffFile[]; | |
| 100 | + | repo: RepositoryRow; | |
| 101 | + | /** If provided, an extra "view at sha" link is shown in each file header. */ | |
| 102 | + | sha?: string; | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | export function DiffView({ files, repo, sha }: DiffViewProps) { | |
| 106 | + | const totalAdded = files.reduce((s, f) => s + f.added, 0); | |
| 107 | + | const totalRemoved = files.reduce((s, f) => s + f.removed, 0); | |
| 108 | + | ||
| 109 | + | const changedStr = [ | |
| 110 | + | `${files.length} file${files.length !== 1 ? "s" : ""} changed`, | |
| 111 | + | totalAdded > 0 | |
| 112 | + | ? `${totalAdded} insertion${totalAdded !== 1 ? "s" : ""}(+)` | |
| 113 | + | : "", | |
| 114 | + | totalRemoved > 0 | |
| 115 | + | ? `${totalRemoved} deletion${totalRemoved !== 1 ? "s" : ""}(-)` | |
| 116 | + | : "", | |
| 117 | + | ] | |
| 118 | + | .filter(Boolean) | |
| 119 | + | .join(", "); | |
| 120 | + | ||
| 121 | + | return ( | |
| 122 | + | <> | |
| 123 | + | {files.length > 0 && ( | |
| 124 | + | <div class="commit-stats-bar"> | |
| 125 | + | <span class="commit-stats-text">{changedStr}</span> | |
| 126 | + | </div> | |
| 127 | + | )} | |
| 128 | + | ||
| 129 | + | {files.length === 0 ? ( | |
| 130 | + | <p class="text-muted" style="margin-top: var(--space-6)"> | |
| 131 | + | No diff available. | |
| 132 | + | </p> | |
| 133 | + | ) : ( | |
| 134 | + | <div class="commit-layout"> | |
| 135 | + | <aside class="commit-file-nav"> | |
| 136 | + | <details class="file-nav-details" open> | |
| 137 | + | <summary class="file-nav-toggle"> | |
| 138 | + | <span class="file-nav-toggle-icon">▾</span> | |
| 139 | + | <span>Files changed ({files.length})</span> | |
| 140 | + | </summary> | |
| 141 | + | {renderFileTree(buildFileTree(files))} | |
| 142 | + | </details> | |
| 143 | + | </aside> | |
| 144 | + | ||
| 145 | + | <div class="commit-diffs"> | |
| 146 | + | {files.map((f) => { | |
| 147 | + | const displayPath = f.newPath || f.oldPath; | |
| 148 | + | const sl = statusLetter(f.status); | |
| 149 | + | return ( | |
| 150 | + | <details | |
| 151 | + | class="diff-file" | |
| 152 | + | id={slugify(displayPath)} | |
| 153 | + | open | |
| 154 | + | > | |
| 155 | + | <summary class="diff-file-header"> | |
| 156 | + | <div class="diff-file-header-left"> | |
| 157 | + | <span class="diff-file-toggle-icon"> | |
| 158 | + | ▾ | |
| 159 | + | </span> | |
| 160 | + | <span | |
| 161 | + | class={`diff-status-badge diff-status-${f.status}`} | |
| 162 | + | > | |
| 163 | + | {sl} | |
| 164 | + | </span> | |
| 165 | + | <span class="diff-file-path mono"> | |
| 166 | + | {displayPath} | |
| 167 | + | </span> | |
| 168 | + | {f.status === "renamed" && | |
| 169 | + | f.oldPath !== f.newPath && ( | |
| 170 | + | <span class="diff-rename-arrow"> | |
| 171 | + | ← {f.oldPath} | |
| 172 | + | </span> | |
| 173 | + | )} | |
| 174 | + | </div> | |
| 175 | + | <div class="diff-file-header-right"> | |
| 176 | + | {f.added > 0 && ( | |
| 177 | + | <span class="diff-stat-add"> | |
| 178 | + | +{f.added} | |
| 179 | + | </span> | |
| 180 | + | )} | |
| 181 | + | {f.removed > 0 && ( | |
| 182 | + | <span class="diff-stat-del"> | |
| 183 | + | -{f.removed} | |
| 184 | + | </span> | |
| 185 | + | )} | |
| 186 | + | {!f.isBinary && | |
| 187 | + | f.status !== "deleted" && ( | |
| 188 | + | <> | |
| 189 | + | {sha && ( | |
| 190 | + | <a | |
| 191 | + | href={`/${repo.name}/blob/${sha}/${displayPath}`} | |
| 192 | + | class="btn btn-xs btn-secondary" | |
| 193 | + | title={`View file at ${sha.slice(0, 7)}`} | |
| 194 | + | > | |
| 195 | + | @{" "} | |
| 196 | + | {sha.slice( | |
| 197 | + | 0, | |
| 198 | + | 7, | |
| 199 | + | )} | |
| 200 | + | </a> | |
| 201 | + | )} | |
| 202 | + | <a | |
| 203 | + | href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`} | |
| 204 | + | class="btn btn-xs btn-secondary" | |
| 205 | + | title={`View file at ${repo.default_branch}`} | |
| 206 | + | > | |
| 207 | + | @{" "} | |
| 208 | + | { | |
| 209 | + | repo.default_branch | |
| 210 | + | } | |
| 211 | + | </a> | |
| 212 | + | </> | |
| 213 | + | )} | |
| 214 | + | </div> | |
| 215 | + | </summary> | |
| 216 | + | ||
| 217 | + | {f.isBinary ? ( | |
| 218 | + | <div class="diff-binary-notice"> | |
| 219 | + | Binary file — not shown | |
| 220 | + | </div> | |
| 221 | + | ) : f.hunks.length === 0 ? ( | |
| 222 | + | <div class="diff-binary-notice"> | |
| 223 | + | No textual changes. | |
| 224 | + | </div> | |
| 225 | + | ) : ( | |
| 226 | + | <div class="diff-file-body"> | |
| 227 | + | {f.hunks.map((hunk) => ( | |
| 228 | + | <div class="diff-hunk"> | |
| 229 | + | <table class="diff-table"> | |
| 230 | + | <thead> | |
| 231 | + | <tr> | |
| 232 | + | <th | |
| 233 | + | colspan="4" | |
| 234 | + | class="diff-hunk-header" | |
| 235 | + | > | |
| 236 | + | { | |
| 237 | + | hunk.header | |
| 238 | + | } | |
| 239 | + | </th> | |
| 240 | + | </tr> | |
| 241 | + | </thead> | |
| 242 | + | <tbody> | |
| 243 | + | {hunk.rows.map( | |
| 244 | + | (row) => ( | |
| 245 | + | <tr | |
| 246 | + | class={`diff-row diff-row-${row.type}`} | |
| 247 | + | > | |
| 248 | + | <td class="diff-ln diff-ln-old"> | |
| 249 | + | {row.oldLine ?? | |
| 250 | + | ""} | |
| 251 | + | </td> | |
| 252 | + | <td class="diff-ln diff-ln-new"> | |
| 253 | + | {row.newLine ?? | |
| 254 | + | ""} | |
| 255 | + | </td> | |
| 256 | + | <td class="diff-sign"> | |
| 257 | + | {row.type === | |
| 258 | + | "add" | |
| 259 | + | ? "+" | |
| 260 | + | : row.type === | |
| 261 | + | "del" | |
| 262 | + | ? "-" | |
| 263 | + | : " "} | |
| 264 | + | </td> | |
| 265 | + | <td class="diff-code"> | |
| 266 | + | { | |
| 267 | + | row.html | |
| 268 | + | } | |
| 269 | + | </td> | |
| 270 | + | </tr> | |
| 271 | + | ), | |
| 272 | + | )} | |
| 273 | + | </tbody> | |
| 274 | + | </table> | |
| 275 | + | </div> | |
| 276 | + | ))} | |
| 277 | + | </div> | |
| 278 | + | )} | |
| 279 | + | </details> | |
| 280 | + | ); | |
| 281 | + | })} | |
| 282 | + | </div> | |
| 283 | + | </div> | |
| 284 | + | )} | |
| 285 | + | </> | |
| 286 | + | ); | |
| 287 | + | } | |
Asrc/views/Pagination.tsx
| @@ -0,0 +1,40 @@ | |||
|---|---|---|---|
| 1 | + | export interface PaginationInfo { | |
| 2 | + | page: number; | |
| 3 | + | totalPages: number; | |
| 4 | + | /** URL template — use `{page}` as placeholder, e.g. "/repo/issues?status=open&page={page}" */ | |
| 5 | + | pageUrlTemplate: string; | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | interface PaginationProps extends PaginationInfo {} | |
| 9 | + | ||
| 10 | + | export function Pagination({ | |
| 11 | + | page, | |
| 12 | + | totalPages, | |
| 13 | + | pageUrlTemplate, | |
| 14 | + | }: PaginationProps) { | |
| 15 | + | if (totalPages <= 1) return null; | |
| 16 | + | ||
| 17 | + | const url = (p: number) => pageUrlTemplate.replace("{page}", String(p)); | |
| 18 | + | ||
| 19 | + | return ( | |
| 20 | + | <nav class="pagination" aria-label="Pagination"> | |
| 21 | + | <div class="pagination-prev"> | |
| 22 | + | {page > 1 && ( | |
| 23 | + | <a href={url(page - 1)} class="pagination-btn"> | |
| 24 | + | ← Previous | |
| 25 | + | </a> | |
| 26 | + | )} | |
| 27 | + | </div> | |
| 28 | + | <span class="pagination-info"> | |
| 29 | + | Page {page} of {totalPages} | |
| 30 | + | </span> | |
| 31 | + | <div class="pagination-next"> | |
| 32 | + | {page < totalPages && ( | |
| 33 | + | <a href={url(page + 1)} class="pagination-btn"> | |
| 34 | + | Next → | |
| 35 | + | </a> | |
| 36 | + | )} | |
| 37 | + | </div> | |
| 38 | + | </nav> | |
| 39 | + | ); | |
| 40 | + | } | |
Asrc/views/ReactionBar.tsx
| @@ -0,0 +1,84 @@ | |||
|---|---|---|---|
| 1 | + | import { ALLOWED_REACTIONS } from "../constants.ts"; | |
| 2 | + | import type { SessionUser } from "../middleware/session.ts"; | |
| 3 | + | ||
| 4 | + | export interface ReactionCount { | |
| 5 | + | emoji: string; | |
| 6 | + | count: number; | |
| 7 | + | userReacted: boolean; | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | interface ReactionBarProps { | |
| 11 | + | reactions: ReactionCount[]; | |
| 12 | + | postUrl: string; | |
| 13 | + | commentId?: number; | |
| 14 | + | user: SessionUser | null; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export function ReactionBar({ | |
| 18 | + | reactions, | |
| 19 | + | postUrl, | |
| 20 | + | commentId, | |
| 21 | + | user, | |
| 22 | + | }: ReactionBarProps) { | |
| 23 | + | if (!user && reactions.length === 0) return null; | |
| 24 | + | const existing = new Set(reactions.map((r) => r.emoji)); | |
| 25 | + | const all = [...ALLOWED_REACTIONS]; | |
| 26 | + | return ( | |
| 27 | + | <div class="reaction-bar"> | |
| 28 | + | {reactions.map((r) => ( | |
| 29 | + | <form method="POST" action={postUrl} class="reaction-form"> | |
| 30 | + | {commentId != null && ( | |
| 31 | + | <input | |
| 32 | + | type="hidden" | |
| 33 | + | name="comment_id" | |
| 34 | + | value={String(commentId)} | |
| 35 | + | /> | |
| 36 | + | )} | |
| 37 | + | <input type="hidden" name="emoji" value={r.emoji} /> | |
| 38 | + | <button | |
| 39 | + | type="submit" | |
| 40 | + | class={`reaction-btn${r.userReacted ? " reacted" : ""}`} | |
| 41 | + | disabled={!user} | |
| 42 | + | > | |
| 43 | + | {r.emoji} {r.count} | |
| 44 | + | </button> | |
| 45 | + | </form> | |
| 46 | + | ))} | |
| 47 | + | {user && ( | |
| 48 | + | <details class="reaction-picker"> | |
| 49 | + | <summary class="reaction-add-btn">+</summary> | |
| 50 | + | <div class="reaction-picker-dropdown"> | |
| 51 | + | {all | |
| 52 | + | .filter((e) => !existing.has(e)) | |
| 53 | + | .map((e) => ( | |
| 54 | + | <form | |
| 55 | + | method="POST" | |
| 56 | + | action={postUrl} | |
| 57 | + | class="reaction-form" | |
| 58 | + | > | |
| 59 | + | {commentId != null && ( | |
| 60 | + | <input | |
| 61 | + | type="hidden" | |
| 62 | + | name="comment_id" | |
| 63 | + | value={String(commentId)} | |
| 64 | + | /> | |
| 65 | + | )} | |
| 66 | + | <input | |
| 67 | + | type="hidden" | |
| 68 | + | name="emoji" | |
| 69 | + | value={e} | |
| 70 | + | /> | |
| 71 | + | <button | |
| 72 | + | type="submit" | |
| 73 | + | class="reaction-picker-btn" | |
| 74 | + | > | |
| 75 | + | {e} | |
| 76 | + | </button> | |
| 77 | + | </form> | |
| 78 | + | ))} | |
| 79 | + | </div> | |
| 80 | + | </details> | |
| 81 | + | )} | |
| 82 | + | </div> | |
| 83 | + | ); | |
| 84 | + | } | |
Asrc/views/Settings.tsx
| @@ -0,0 +1,469 @@ | |||
|---|---|---|---|
| 1 | + | import type { SessionUser } from "../middleware/session.ts"; | |
| 2 | + | import { Avatar } from "./Avatar.tsx"; | |
| 3 | + | import { Layout } from "./layout.tsx"; | |
| 4 | + | ||
| 5 | + | interface SettingsProps { | |
| 6 | + | user: SessionUser; | |
| 7 | + | hasPassword: boolean; | |
| 8 | + | passkeys: { id: number; created_at: string }[]; | |
| 9 | + | sshKeys: { | |
| 10 | + | id: number; | |
| 11 | + | name: string; | |
| 12 | + | fingerprint: string; | |
| 13 | + | created_at: string; | |
| 14 | + | }[]; | |
| 15 | + | theme: string; | |
| 16 | + | success: string | null; | |
| 17 | + | error: string | null; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | const successMessages: Record<string, string> = { | |
| 21 | + | password: "Password updated.", | |
| 22 | + | password_removed: "Password removed.", | |
| 23 | + | passkey_revoked: "Passkey revoked.", | |
| 24 | + | theme: "Theme preference saved.", | |
| 25 | + | user_created: "Account created.", | |
| 26 | + | user_deleted: "Account deleted.", | |
| 27 | + | ssh_key_added: "SSH key added.", | |
| 28 | + | ssh_key_deleted: "SSH key removed.", | |
| 29 | + | }; | |
| 30 | + | ||
| 31 | + | export function Settings({ | |
| 32 | + | user, | |
| 33 | + | hasPassword, | |
| 34 | + | passkeys, | |
| 35 | + | sshKeys, | |
| 36 | + | theme, | |
| 37 | + | success, | |
| 38 | + | error, | |
| 39 | + | }: SettingsProps) { | |
| 40 | + | const successMsg = success ? (successMessages[success] ?? null) : null; | |
| 41 | + | ||
| 42 | + | return ( | |
| 43 | + | <Layout user={user} title="Settings"> | |
| 44 | + | <div class="container container-narrow"> | |
| 45 | + | <h1 class="page-title">Settings</h1> | |
| 46 | + | ||
| 47 | + | {successMsg && <p class="form-success">{successMsg}</p>} | |
| 48 | + | {error && <p class="form-error">{error}</p>} | |
| 49 | + | ||
| 50 | + | {/* Avatar */} | |
| 51 | + | <div class="form-card"> | |
| 52 | + | <h2 class="section-title">Avatar</h2> | |
| 53 | + | <div class="avatar-settings"> | |
| 54 | + | <Avatar | |
| 55 | + | userId={user.id} | |
| 56 | + | version={user.avatar_version} | |
| 57 | + | size={80} | |
| 58 | + | /> | |
| 59 | + | <div class="avatar-actions"> | |
| 60 | + | <form | |
| 61 | + | method="POST" | |
| 62 | + | action="/settings/avatar" | |
| 63 | + | enctype="multipart/form-data" | |
| 64 | + | > | |
| 65 | + | <div class="form-group"> | |
| 66 | + | <input | |
| 67 | + | type="file" | |
| 68 | + | name="avatar" | |
| 69 | + | accept="image/*" | |
| 70 | + | class="form-input" | |
| 71 | + | required | |
| 72 | + | /> | |
| 73 | + | </div> | |
| 74 | + | <div class="form-actions"> | |
| 75 | + | <button | |
| 76 | + | type="submit" | |
| 77 | + | class="btn btn-sm btn-primary" | |
| 78 | + | > | |
| 79 | + | Upload avatar | |
| 80 | + | </button> | |
| 81 | + | </div> | |
| 82 | + | </form> | |
| 83 | + | <form | |
| 84 | + | method="POST" | |
| 85 | + | action="/settings/avatar/delete" | |
| 86 | + | class="inline-form" | |
| 87 | + | > | |
| 88 | + | <button | |
| 89 | + | type="submit" | |
| 90 | + | class="btn btn-sm btn-ghost" | |
| 91 | + | > | |
| 92 | + | Remove avatar | |
| 93 | + | </button> | |
| 94 | + | </form> | |
| 95 | + | </div> | |
| 96 | + | </div> | |
| 97 | + | </div> | |
| 98 | + | ||
| 99 | + | {/* Appearance */} | |
| 100 | + | <div class="form-card"> | |
| 101 | + | <h2 class="section-title">Appearance</h2> | |
| 102 | + | <form method="POST" action="/settings/theme"> | |
| 103 | + | <div class="theme-options"> | |
| 104 | + | <label class="theme-option"> | |
| 105 | + | <input | |
| 106 | + | type="radio" | |
| 107 | + | name="theme" | |
| 108 | + | value="auto" | |
| 109 | + | checked={ | |
| 110 | + | theme === "auto" ? true : undefined | |
| 111 | + | } | |
| 112 | + | /> | |
| 113 | + | Auto | |
| 114 | + | </label> | |
| 115 | + | <label class="theme-option"> | |
| 116 | + | <input | |
| 117 | + | type="radio" | |
| 118 | + | name="theme" | |
| 119 | + | value="light" | |
| 120 | + | checked={ | |
| 121 | + | theme === "light" ? true : undefined | |
| 122 | + | } | |
| 123 | + | /> | |
| 124 | + | Light | |
| 125 | + | </label> | |
| 126 | + | <label class="theme-option"> | |
| 127 | + | <input | |
| 128 | + | type="radio" | |
| 129 | + | name="theme" | |
| 130 | + | value="dark" | |
| 131 | + | checked={ | |
| 132 | + | theme === "dark" ? true : undefined | |
| 133 | + | } | |
| 134 | + | /> | |
| 135 | + | Dark | |
| 136 | + | </label> | |
| 137 | + | </div> | |
| 138 | + | <div class="form-actions"> | |
| 139 | + | <button class="btn btn-primary" type="submit"> | |
| 140 | + | Save | |
| 141 | + | </button> | |
| 142 | + | </div> | |
| 143 | + | </form> | |
| 144 | + | </div> | |
| 145 | + | ||
| 146 | + | {/* Password */} | |
| 147 | + | <div class="form-card"> | |
| 148 | + | <h2 class="section-title">Password</h2> | |
| 149 | + | <p class="text-muted"> | |
| 150 | + | {hasPassword ? "Password is set." : "No password set."} | |
| 151 | + | </p> | |
| 152 | + | <form | |
| 153 | + | method="POST" | |
| 154 | + | action="/settings/password" | |
| 155 | + | class="settings-form" | |
| 156 | + | > | |
| 157 | + | {hasPassword && ( | |
| 158 | + | <div class="form-group"> | |
| 159 | + | <label | |
| 160 | + | class="form-label" | |
| 161 | + | for="current_password" | |
| 162 | + | > | |
| 163 | + | Current password | |
| 164 | + | </label> | |
| 165 | + | <input | |
| 166 | + | class="form-input" | |
| 167 | + | type="password" | |
| 168 | + | id="current_password" | |
| 169 | + | name="current_password" | |
| 170 | + | autocomplete="current-password" | |
| 171 | + | /> | |
| 172 | + | </div> | |
| 173 | + | )} | |
| 174 | + | <div class="form-group"> | |
| 175 | + | <label class="form-label" for="new_password"> | |
| 176 | + | {hasPassword ? "New password" : "Password"} | |
| 177 | + | </label> | |
| 178 | + | <input | |
| 179 | + | class="form-input" | |
| 180 | + | type="password" | |
| 181 | + | id="new_password" | |
| 182 | + | name="new_password" | |
| 183 | + | autocomplete="new-password" | |
| 184 | + | /> | |
| 185 | + | </div> | |
| 186 | + | <div class="form-group"> | |
| 187 | + | <label class="form-label" for="confirm_password"> | |
| 188 | + | Confirm password | |
| 189 | + | </label> | |
| 190 | + | <input | |
| 191 | + | class="form-input" | |
| 192 | + | type="password" | |
| 193 | + | id="confirm_password" | |
| 194 | + | name="confirm_password" | |
| 195 | + | autocomplete="new-password" | |
| 196 | + | /> | |
| 197 | + | </div> | |
| 198 | + | <div class="form-actions"> | |
| 199 | + | <button class="btn btn-primary" type="submit"> | |
| 200 | + | {hasPassword | |
| 201 | + | ? "Change password" | |
| 202 | + | : "Set password"} | |
| 203 | + | </button> | |
| 204 | + | </div> | |
| 205 | + | </form> | |
| 206 | + | {hasPassword && passkeys.length > 0 && ( | |
| 207 | + | <form | |
| 208 | + | method="POST" | |
| 209 | + | action="/settings/password/remove" | |
| 210 | + | style="margin-top: var(--space-4)" | |
| 211 | + | > | |
| 212 | + | <button | |
| 213 | + | class="btn btn-danger btn-sm" | |
| 214 | + | type="submit" | |
| 215 | + | onclick="return confirm('Remove password? You will need a passkey to sign in.')" | |
| 216 | + | > | |
| 217 | + | Remove password | |
| 218 | + | </button> | |
| 219 | + | </form> | |
| 220 | + | )} | |
| 221 | + | </div> | |
| 222 | + | ||
| 223 | + | {/* Passkeys */} | |
| 224 | + | <div class="form-card"> | |
| 225 | + | <h2 class="section-title">Passkeys</h2> | |
| 226 | + | {passkeys.length > 0 && ( | |
| 227 | + | <div class="passkey-list"> | |
| 228 | + | {passkeys.map((pk) => ( | |
| 229 | + | <div class="passkey-item"> | |
| 230 | + | <span class="passkey-date"> | |
| 231 | + | Added{" "} | |
| 232 | + | {new Date( | |
| 233 | + | pk.created_at, | |
| 234 | + | ).toLocaleDateString()} | |
| 235 | + | </span> | |
| 236 | + | <form | |
| 237 | + | method="POST" | |
| 238 | + | action="/settings/passkey/revoke" | |
| 239 | + | > | |
| 240 | + | <input | |
| 241 | + | type="hidden" | |
| 242 | + | name="id" | |
| 243 | + | value={String(pk.id)} | |
| 244 | + | /> | |
| 245 | + | <button | |
| 246 | + | class="btn btn-danger btn-sm" | |
| 247 | + | type="submit" | |
| 248 | + | disabled={ | |
| 249 | + | !hasPassword && | |
| 250 | + | passkeys.length <= 1 | |
| 251 | + | ? true | |
| 252 | + | : undefined | |
| 253 | + | } | |
| 254 | + | title={ | |
| 255 | + | !hasPassword && | |
| 256 | + | passkeys.length <= 1 | |
| 257 | + | ? "Register another auth method first" | |
| 258 | + | : undefined | |
| 259 | + | } | |
| 260 | + | > | |
| 261 | + | Revoke | |
| 262 | + | </button> | |
| 263 | + | </form> | |
| 264 | + | </div> | |
| 265 | + | ))} | |
| 266 | + | </div> | |
| 267 | + | )} | |
| 268 | + | <div id="passkey-section" style="display:none"> | |
| 269 | + | <button | |
| 270 | + | id="add-passkey-btn" | |
| 271 | + | class="btn btn-secondary" | |
| 272 | + | type="button" | |
| 273 | + | > | |
| 274 | + | Add passkey | |
| 275 | + | </button> | |
| 276 | + | <p id="passkey-status" class="text-muted"></p> | |
| 277 | + | </div> | |
| 278 | + | <noscript> | |
| 279 | + | <p class="text-muted"> | |
| 280 | + | Enable JavaScript to register or add passkeys. | |
| 281 | + | </p> | |
| 282 | + | </noscript> | |
| 283 | + | </div> | |
| 284 | + | ||
| 285 | + | {/* SSH Keys */} | |
| 286 | + | <div class="form-card"> | |
| 287 | + | <h2 class="section-title">SSH Keys</h2> | |
| 288 | + | {sshKeys.length > 0 && ( | |
| 289 | + | <div class="passkey-list"> | |
| 290 | + | {sshKeys.map((key) => ( | |
| 291 | + | <div class="passkey-item"> | |
| 292 | + | <div class="ssh-key-info"> | |
| 293 | + | <span class="ssh-key-name"> | |
| 294 | + | {key.name} | |
| 295 | + | </span> | |
| 296 | + | <span class="passkey-date ssh-key-fingerprint"> | |
| 297 | + | {key.fingerprint} | |
| 298 | + | </span> | |
| 299 | + | <span class="passkey-date"> | |
| 300 | + | Added{" "} | |
| 301 | + | {new Date( | |
| 302 | + | key.created_at, | |
| 303 | + | ).toLocaleDateString()} | |
| 304 | + | </span> | |
| 305 | + | </div> | |
| 306 | + | <form | |
| 307 | + | method="POST" | |
| 308 | + | action="/settings/ssh-keys/delete" | |
| 309 | + | > | |
| 310 | + | <input | |
| 311 | + | type="hidden" | |
| 312 | + | name="id" | |
| 313 | + | value={String(key.id)} | |
| 314 | + | /> | |
| 315 | + | <button | |
| 316 | + | class="btn btn-danger btn-sm" | |
| 317 | + | type="submit" | |
| 318 | + | > | |
| 319 | + | Remove | |
| 320 | + | </button> | |
| 321 | + | </form> | |
| 322 | + | </div> | |
| 323 | + | ))} | |
| 324 | + | </div> | |
| 325 | + | )} | |
| 326 | + | <form | |
| 327 | + | method="POST" | |
| 328 | + | action="/settings/ssh-keys" | |
| 329 | + | class="settings-form" | |
| 330 | + | style="margin-top: var(--space-4)" | |
| 331 | + | > | |
| 332 | + | <div class="form-group"> | |
| 333 | + | <label class="form-label" for="ssh_key_name"> | |
| 334 | + | Name | |
| 335 | + | </label> | |
| 336 | + | <input | |
| 337 | + | class="form-input" | |
| 338 | + | type="text" | |
| 339 | + | id="ssh_key_name" | |
| 340 | + | name="name" | |
| 341 | + | placeholder="e.g. My laptop" | |
| 342 | + | autocomplete="off" | |
| 343 | + | /> | |
| 344 | + | </div> | |
| 345 | + | <div class="form-group"> | |
| 346 | + | <label class="form-label" for="ssh_public_key"> | |
| 347 | + | Public key | |
| 348 | + | </label> | |
| 349 | + | <textarea | |
| 350 | + | class="form-input form-textarea" | |
| 351 | + | id="ssh_public_key" | |
| 352 | + | name="public_key" | |
| 353 | + | placeholder="ssh-ed25519 AAAA..." | |
| 354 | + | rows="3" | |
| 355 | + | required | |
| 356 | + | ></textarea> | |
| 357 | + | </div> | |
| 358 | + | <div class="form-actions"> | |
| 359 | + | <button class="btn btn-primary" type="submit"> | |
| 360 | + | Add SSH key | |
| 361 | + | </button> | |
| 362 | + | </div> | |
| 363 | + | </form> | |
| 364 | + | </div> | |
| 365 | + | ||
| 366 | + | {/* Admin: User Management */} | |
| 367 | + | {user.isAdmin && ( | |
| 368 | + | <div class="form-card"> | |
| 369 | + | <h2 class="section-title">User Management</h2> | |
| 370 | + | <h3>Create account</h3> | |
| 371 | + | <form | |
| 372 | + | method="POST" | |
| 373 | + | action="/admin/users" | |
| 374 | + | class="settings-form" | |
| 375 | + | style="margin-top: var(--space-4)" | |
| 376 | + | > | |
| 377 | + | <div class="form-group"> | |
| 378 | + | <label class="form-label" for="new_username"> | |
| 379 | + | Username | |
| 380 | + | </label> | |
| 381 | + | <input | |
| 382 | + | class="form-input" | |
| 383 | + | type="text" | |
| 384 | + | id="new_username" | |
| 385 | + | name="username" | |
| 386 | + | autocomplete="off" | |
| 387 | + | /> | |
| 388 | + | </div> | |
| 389 | + | <div class="form-group"> | |
| 390 | + | <label | |
| 391 | + | class="form-label" | |
| 392 | + | for="new_user_password" | |
| 393 | + | > | |
| 394 | + | Password | |
| 395 | + | </label> | |
| 396 | + | <input | |
| 397 | + | class="form-input" | |
| 398 | + | type="password" | |
| 399 | + | id="new_user_password" | |
| 400 | + | name="password" | |
| 401 | + | autocomplete="new-password" | |
| 402 | + | /> | |
| 403 | + | </div> | |
| 404 | + | <div class="form-actions"> | |
| 405 | + | <button class="btn btn-primary" type="submit"> | |
| 406 | + | Create account | |
| 407 | + | </button> | |
| 408 | + | </div> | |
| 409 | + | </form> | |
| 410 | + | <h3 style="margin-top: var(--space-6)"> | |
| 411 | + | Delete account | |
| 412 | + | </h3> | |
| 413 | + | <form | |
| 414 | + | method="POST" | |
| 415 | + | action="/admin/users/delete" | |
| 416 | + | class="settings-form" | |
| 417 | + | style="margin-top: var(--space-4)" | |
| 418 | + | > | |
| 419 | + | <div class="form-group"> | |
| 420 | + | <label class="form-label" for="del_username"> | |
| 421 | + | Username | |
| 422 | + | </label> | |
| 423 | + | <input | |
| 424 | + | class="form-input" | |
| 425 | + | type="text" | |
| 426 | + | id="del_username" | |
| 427 | + | name="username" | |
| 428 | + | autocomplete="off" | |
| 429 | + | /> | |
| 430 | + | </div> | |
| 431 | + | <div class="form-actions"> | |
| 432 | + | <button class="btn btn-danger" type="submit"> | |
| 433 | + | Delete account | |
| 434 | + | </button> | |
| 435 | + | </div> | |
| 436 | + | </form> | |
| 437 | + | </div> | |
| 438 | + | )} | |
| 439 | + | </div> | |
| 440 | + | ||
| 441 | + | <script type="module">{` | |
| 442 | + | import { startRegistration } from '/assets/simplewebauthn-browser.js'; | |
| 443 | + | document.getElementById('passkey-section').style.display = 'block'; | |
| 444 | + | document.getElementById('add-passkey-btn').addEventListener('click', async () => { | |
| 445 | + | const status = document.getElementById('passkey-status'); | |
| 446 | + | try { | |
| 447 | + | status.textContent = 'Starting...'; | |
| 448 | + | const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' }); | |
| 449 | + | const opts = await optsResp.json(); | |
| 450 | + | const result = await startRegistration({ optionsJSON: opts }); | |
| 451 | + | const verResp = await fetch('/auth/passkey/register/verify', { | |
| 452 | + | method: 'POST', | |
| 453 | + | headers: { 'Content-Type': 'application/json' }, | |
| 454 | + | body: JSON.stringify(result), | |
| 455 | + | }); | |
| 456 | + | if (verResp.ok) { | |
| 457 | + | window.location.reload(); | |
| 458 | + | } else { | |
| 459 | + | const err = await verResp.json(); | |
| 460 | + | status.textContent = 'Error: ' + (err.error ?? 'Registration failed'); | |
| 461 | + | } | |
| 462 | + | } catch (e) { | |
| 463 | + | status.textContent = 'Error: ' + e.message; | |
| 464 | + | } | |
| 465 | + | }); | |
| 466 | + | `}</script> | |
| 467 | + | </Layout> | |
| 468 | + | ); | |
| 469 | + | } | |
Asrc/views/auth/Login.tsx
| @@ -0,0 +1,80 @@ | |||
|---|---|---|---|
| 1 | + | import { Layout } from "../layout.tsx"; | |
| 2 | + | ||
| 3 | + | interface LoginProps { | |
| 4 | + | error?: string; | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | export function Login({ error }: LoginProps) { | |
| 8 | + | return ( | |
| 9 | + | <Layout user={null} title="Sign in"> | |
| 10 | + | <div class="auth-container"> | |
| 11 | + | <h1 class="page-title">Sign in</h1> | |
| 12 | + | {error && <p class="form-error">{error}</p>} | |
| 13 | + | <form method="POST" action="/login" class="auth-form"> | |
| 14 | + | <div class="form-group"> | |
| 15 | + | <label for="username">Username</label> | |
| 16 | + | <input | |
| 17 | + | id="username" | |
| 18 | + | name="username" | |
| 19 | + | type="text" | |
| 20 | + | required | |
| 21 | + | autocomplete="username" | |
| 22 | + | /> | |
| 23 | + | </div> | |
| 24 | + | <div id="passkey-section" style="display:none"> | |
| 25 | + | <button | |
| 26 | + | id="passkey-btn" | |
| 27 | + | class="btn btn-secondary btn-block" | |
| 28 | + | type="button" | |
| 29 | + | > | |
| 30 | + | Sign in with passkey | |
| 31 | + | </button> | |
| 32 | + | <div class="auth-divider"> | |
| 33 | + | <span>or</span> | |
| 34 | + | </div> | |
| 35 | + | </div> | |
| 36 | + | <div class="form-group"> | |
| 37 | + | <label for="password">Password</label> | |
| 38 | + | <input | |
| 39 | + | id="password" | |
| 40 | + | name="password" | |
| 41 | + | type="password" | |
| 42 | + | required | |
| 43 | + | autocomplete="current-password" | |
| 44 | + | /> | |
| 45 | + | </div> | |
| 46 | + | <button type="submit" class="btn btn-primary btn-block"> | |
| 47 | + | Sign in with password | |
| 48 | + | </button> | |
| 49 | + | </form> | |
| 50 | + | <p class="auth-footer"> | |
| 51 | + | Don't have an account? <a href="/register">Register</a> | |
| 52 | + | </p> | |
| 53 | + | </div> | |
| 54 | + | <script type="module">{` | |
| 55 | + | import { startAuthentication } from '/assets/simplewebauthn-browser.js'; | |
| 56 | + | document.getElementById('passkey-section').style.display = 'block'; | |
| 57 | + | document.getElementById('passkey-btn').addEventListener('click', async () => { | |
| 58 | + | try { | |
| 59 | + | const optsResp = await fetch('/auth/passkey/login/options', { method: 'POST' }); | |
| 60 | + | const opts = await optsResp.json(); | |
| 61 | + | const result = await startAuthentication({ optionsJSON: opts }); | |
| 62 | + | const verResp = await fetch('/auth/passkey/login/verify', { | |
| 63 | + | method: 'POST', | |
| 64 | + | headers: { 'Content-Type': 'application/json' }, | |
| 65 | + | body: JSON.stringify(result), | |
| 66 | + | }); | |
| 67 | + | if (verResp.ok) { | |
| 68 | + | window.location.href = '/'; | |
| 69 | + | } else { | |
| 70 | + | const err = await verResp.json(); | |
| 71 | + | alert(err.error ?? 'Passkey sign in failed'); | |
| 72 | + | } | |
| 73 | + | } catch (e) { | |
| 74 | + | alert('Passkey sign in failed: ' + e.message); | |
| 75 | + | } | |
| 76 | + | }); | |
| 77 | + | `}</script> | |
| 78 | + | </Layout> | |
| 79 | + | ); | |
| 80 | + | } | |
Asrc/views/auth/Register.tsx
| @@ -0,0 +1,110 @@ | |||
|---|---|---|---|
| 1 | + | import { Layout } from "../layout.tsx"; | |
| 2 | + | ||
| 3 | + | interface RegisterProps { | |
| 4 | + | error?: string; | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | export function Register({ error }: RegisterProps) { | |
| 8 | + | return ( | |
| 9 | + | <Layout user={null} title="Register"> | |
| 10 | + | <div class="auth-container"> | |
| 11 | + | <h1 class="page-title">Create account</h1> | |
| 12 | + | {error && <p class="form-error">{error}</p>} | |
| 13 | + | <form method="POST" action="/register" class="auth-form"> | |
| 14 | + | <div class="form-group"> | |
| 15 | + | <label for="username">Username</label> | |
| 16 | + | <input | |
| 17 | + | id="username" | |
| 18 | + | name="username" | |
| 19 | + | type="text" | |
| 20 | + | required | |
| 21 | + | autocomplete="username" | |
| 22 | + | pattern="[a-zA-Z0-9_-]+" | |
| 23 | + | title="Letters, numbers, hyphens and underscores only" | |
| 24 | + | /> | |
| 25 | + | </div> | |
| 26 | + | <div id="passkey-section" style="display:none"> | |
| 27 | + | <button | |
| 28 | + | id="passkey-register-btn" | |
| 29 | + | class="btn btn-secondary btn-block" | |
| 30 | + | type="button" | |
| 31 | + | > | |
| 32 | + | Register with passkey | |
| 33 | + | </button> | |
| 34 | + | <div class="auth-divider"> | |
| 35 | + | <span>or</span> | |
| 36 | + | </div> | |
| 37 | + | </div> | |
| 38 | + | <div class="form-group"> | |
| 39 | + | <label for="password">Password</label> | |
| 40 | + | <input | |
| 41 | + | id="password" | |
| 42 | + | name="password" | |
| 43 | + | type="password" | |
| 44 | + | autocomplete="new-password" | |
| 45 | + | minlength="8" | |
| 46 | + | /> | |
| 47 | + | </div> | |
| 48 | + | <div class="form-group"> | |
| 49 | + | <label for="password2">Confirm password</label> | |
| 50 | + | <input | |
| 51 | + | id="password2" | |
| 52 | + | name="password2" | |
| 53 | + | type="password" | |
| 54 | + | autocomplete="new-password" | |
| 55 | + | minlength="8" | |
| 56 | + | /> | |
| 57 | + | </div> | |
| 58 | + | <button type="submit" class="btn btn-primary btn-block"> | |
| 59 | + | Register with password | |
| 60 | + | </button> | |
| 61 | + | </form> | |
| 62 | + | <p class="auth-footer"> | |
| 63 | + | Already have an account? <a href="/login">Sign in</a> | |
| 64 | + | </p> | |
| 65 | + | </div> | |
| 66 | + | <script type="module">{` | |
| 67 | + | import { startRegistration } from '/assets/simplewebauthn-browser.js'; | |
| 68 | + | const usernameInput = document.getElementById('username'); | |
| 69 | + | document.getElementById('passkey-section').style.display = 'block'; | |
| 70 | + | document.getElementById('passkey-register-btn').addEventListener('click', async () => { | |
| 71 | + | const username = usernameInput.value.trim(); | |
| 72 | + | if (!username) { usernameInput.focus(); return; } | |
| 73 | + | if (!/^[a-zA-Z0-9_-]+$/.test(username)) { | |
| 74 | + | alert('Username may only contain letters, numbers, hyphens, and underscores'); | |
| 75 | + | return; | |
| 76 | + | } | |
| 77 | + | try { | |
| 78 | + | const createResp = await fetch('/auth/passkey/create-user', { | |
| 79 | + | method: 'POST', | |
| 80 | + | headers: { 'Content-Type': 'application/json' }, | |
| 81 | + | body: JSON.stringify({ username }), | |
| 82 | + | }); | |
| 83 | + | if (!createResp.ok) { | |
| 84 | + | const err = await createResp.json(); | |
| 85 | + | alert(err.error ?? 'Failed to create account'); | |
| 86 | + | return; | |
| 87 | + | } | |
| 88 | + | await createResp.json(); | |
| 89 | + | const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' }); | |
| 90 | + | const opts = await optsResp.json(); | |
| 91 | + | const result = await startRegistration({ optionsJSON: opts }); | |
| 92 | + | const verResp = await fetch('/auth/passkey/register/verify', { | |
| 93 | + | method: 'POST', | |
| 94 | + | headers: { 'Content-Type': 'application/json' }, | |
| 95 | + | body: JSON.stringify(result), | |
| 96 | + | }); | |
| 97 | + | if (verResp.ok) { | |
| 98 | + | window.location.href = '/'; | |
| 99 | + | } else { | |
| 100 | + | const err = await verResp.json(); | |
| 101 | + | alert(err.error ?? 'Passkey registration failed'); | |
| 102 | + | } | |
| 103 | + | } catch (e) { | |
| 104 | + | alert('Passkey registration failed: ' + e.message); | |
| 105 | + | } | |
| 106 | + | }); | |
| 107 | + | `}</script> | |
| 108 | + | </Layout> | |
| 109 | + | ); | |
| 110 | + | } | |
Asrc/views/issues/IssueDetail.tsx
| @@ -0,0 +1,297 @@ | |||
|---|---|---|---|
| 1 | + | import type { | |
| 2 | + | IssueCommentRow, | |
| 3 | + | IssueRow, | |
| 4 | + | RepositoryRow, | |
| 5 | + | } from "../../db/index.ts"; | |
| 6 | + | import { displayName } from "../../lib/users.ts"; | |
| 7 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 8 | + | import { Avatar } from "../Avatar.tsx"; | |
| 9 | + | import { DateWithEdited } from "../DateWithEdited.tsx"; | |
| 10 | + | import { Layout } from "../layout.tsx"; | |
| 11 | + | import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx"; | |
| 12 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 13 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 14 | + | ||
| 15 | + | interface IssueDetailProps { | |
| 16 | + | user: SessionUser | null; | |
| 17 | + | repo: RepositoryRow; | |
| 18 | + | issue: IssueRow & { | |
| 19 | + | author_username: string; | |
| 20 | + | author_avatar_version: number | null; | |
| 21 | + | }; | |
| 22 | + | bodyHtml: string; | |
| 23 | + | comments: (IssueCommentRow & { | |
| 24 | + | author_username: string; | |
| 25 | + | author_avatar_version: number | null; | |
| 26 | + | bodyHtml: string; | |
| 27 | + | })[]; | |
| 28 | + | reactions: ReactionCount[]; | |
| 29 | + | commentReactions: Map<number, ReactionCount[]>; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function IssueDetail({ | |
| 33 | + | user, | |
| 34 | + | repo, | |
| 35 | + | issue, | |
| 36 | + | bodyHtml, | |
| 37 | + | comments, | |
| 38 | + | reactions, | |
| 39 | + | commentReactions, | |
| 40 | + | }: IssueDetailProps) { | |
| 41 | + | const canEditIssue = | |
| 42 | + | user != null && (user.isAdmin || user.id === issue.author_id); | |
| 43 | + | const canEditComment = (c: IssueCommentRow) => | |
| 44 | + | user != null && (user.isAdmin || user.id === c.author_id); | |
| 45 | + | return ( | |
| 46 | + | <Layout user={user} title={`${issue.title} — ${repo.name}`}> | |
| 47 | + | <div class="container"> | |
| 48 | + | <RepoHeader repo={repo} /> | |
| 49 | + | <RepoNav repo={repo} active="issues" user={user} /> | |
| 50 | + | <div class="issue-detail"> | |
| 51 | + | <div class="issue-detail-header"> | |
| 52 | + | <span class="issue-number">#{issue.number}</span> | |
| 53 | + | <h2 class="issue-detail-title">{issue.title}</h2> | |
| 54 | + | <span class={`issue-badge ${issue.status}`}> | |
| 55 | + | {issue.status} | |
| 56 | + | </span> | |
| 57 | + | {canEditIssue && ( | |
| 58 | + | <div class="issue-detail-meta-actions"> | |
| 59 | + | {user?.isAdmin && ( | |
| 60 | + | <form | |
| 61 | + | method="POST" | |
| 62 | + | action={`/${repo.name}/issues/${issue.number}/close`} | |
| 63 | + | class="inline-form" | |
| 64 | + | > | |
| 65 | + | <button | |
| 66 | + | type="submit" | |
| 67 | + | class="btn btn-sm" | |
| 68 | + | > | |
| 69 | + | {issue.status === "open" | |
| 70 | + | ? "Close issue" | |
| 71 | + | : "Reopen issue"} | |
| 72 | + | </button> | |
| 73 | + | </form> | |
| 74 | + | )} | |
| 75 | + | <details class="confirm-details"> | |
| 76 | + | <summary class="btn btn-sm btn-danger"> | |
| 77 | + | Delete | |
| 78 | + | </summary> | |
| 79 | + | <div class="confirm-popup"> | |
| 80 | + | Permanently delete this issue? | |
| 81 | + | <form | |
| 82 | + | method="POST" | |
| 83 | + | action={`/${repo.name}/issues/${issue.number}/delete`} | |
| 84 | + | class="inline-form" | |
| 85 | + | > | |
| 86 | + | <button | |
| 87 | + | type="submit" | |
| 88 | + | class="btn btn-sm btn-danger" | |
| 89 | + | > | |
| 90 | + | Yes, delete | |
| 91 | + | </button> | |
| 92 | + | </form> | |
| 93 | + | </div> | |
| 94 | + | </details> | |
| 95 | + | </div> | |
| 96 | + | )} | |
| 97 | + | </div> | |
| 98 | + | ||
| 99 | + | <div class="timeline-item"> | |
| 100 | + | <div class="timeline-author"> | |
| 101 | + | <Avatar | |
| 102 | + | userId={issue.author_id} | |
| 103 | + | version={issue.author_avatar_version} | |
| 104 | + | size={24} | |
| 105 | + | /> | |
| 106 | + | <strong> | |
| 107 | + | {displayName(issue.author_username)} | |
| 108 | + | </strong> | |
| 109 | + | <div class="timeline-author-right"> | |
| 110 | + | {canEditIssue && ( | |
| 111 | + | <details class="inline-edit-details"> | |
| 112 | + | <summary class="btn btn-xs btn-ghost"> | |
| 113 | + | <span class="when-closed"> | |
| 114 | + | Edit | |
| 115 | + | </span> | |
| 116 | + | <span class="when-open"> | |
| 117 | + | Stop editing | |
| 118 | + | </span> | |
| 119 | + | </summary> | |
| 120 | + | </details> | |
| 121 | + | )} | |
| 122 | + | <DateWithEdited | |
| 123 | + | date={issue.created_at} | |
| 124 | + | editedAt={issue.edited_at} | |
| 125 | + | /> | |
| 126 | + | </div> | |
| 127 | + | </div> | |
| 128 | + | {canEditIssue && ( | |
| 129 | + | <div class="inline-edit-form-area"> | |
| 130 | + | <form | |
| 131 | + | method="POST" | |
| 132 | + | action={`/${repo.name}/issues/${issue.number}/edit`} | |
| 133 | + | class="inline-edit-form" | |
| 134 | + | > | |
| 135 | + | <div class="form-group"> | |
| 136 | + | <label | |
| 137 | + | class="form-label" | |
| 138 | + | for="edit-issue-title" | |
| 139 | + | > | |
| 140 | + | Title | |
| 141 | + | </label> | |
| 142 | + | <input | |
| 143 | + | class="form-input" | |
| 144 | + | id="edit-issue-title" | |
| 145 | + | name="title" | |
| 146 | + | value={issue.title} | |
| 147 | + | required | |
| 148 | + | /> | |
| 149 | + | </div> | |
| 150 | + | <div class="form-group"> | |
| 151 | + | <label | |
| 152 | + | class="form-label" | |
| 153 | + | for="edit-issue-body" | |
| 154 | + | > | |
| 155 | + | Description | |
| 156 | + | </label> | |
| 157 | + | <textarea | |
| 158 | + | class="form-input" | |
| 159 | + | id="edit-issue-body" | |
| 160 | + | name="edit_body" | |
| 161 | + | rows="6" | |
| 162 | + | > | |
| 163 | + | {issue.body} | |
| 164 | + | </textarea> | |
| 165 | + | </div> | |
| 166 | + | <div class="form-actions"> | |
| 167 | + | <button | |
| 168 | + | type="submit" | |
| 169 | + | class="btn btn-sm btn-primary" | |
| 170 | + | > | |
| 171 | + | Save | |
| 172 | + | </button> | |
| 173 | + | </div> | |
| 174 | + | </form> | |
| 175 | + | </div> | |
| 176 | + | )} | |
| 177 | + | <div class="timeline-body markdown-body"> | |
| 178 | + | {bodyHtml || ( | |
| 179 | + | <em class="text-muted"> | |
| 180 | + | No description provided. | |
| 181 | + | </em> | |
| 182 | + | )} | |
| 183 | + | </div> | |
| 184 | + | <ReactionBar | |
| 185 | + | reactions={reactions} | |
| 186 | + | postUrl={`/${repo.name}/issues/${issue.number}/react`} | |
| 187 | + | user={user} | |
| 188 | + | /> | |
| 189 | + | </div> | |
| 190 | + | ||
| 191 | + | {comments.map((comment) => ( | |
| 192 | + | <div class="timeline-item"> | |
| 193 | + | <div class="timeline-author"> | |
| 194 | + | <Avatar | |
| 195 | + | userId={comment.author_id} | |
| 196 | + | version={comment.author_avatar_version} | |
| 197 | + | size={24} | |
| 198 | + | /> | |
| 199 | + | <strong> | |
| 200 | + | {displayName(comment.author_username)} | |
| 201 | + | </strong> | |
| 202 | + | <div class="timeline-author-right"> | |
| 203 | + | {canEditComment(comment) && ( | |
| 204 | + | <details class="inline-edit-details"> | |
| 205 | + | <summary class="btn btn-xs btn-ghost"> | |
| 206 | + | <span class="when-closed"> | |
| 207 | + | Edit | |
| 208 | + | </span> | |
| 209 | + | <span class="when-open"> | |
| 210 | + | Stop editing | |
| 211 | + | </span> | |
| 212 | + | </summary> | |
| 213 | + | </details> | |
| 214 | + | )} | |
| 215 | + | <DateWithEdited | |
| 216 | + | date={comment.created_at} | |
| 217 | + | editedAt={comment.edited_at} | |
| 218 | + | /> | |
| 219 | + | </div> | |
| 220 | + | </div> | |
| 221 | + | {canEditComment(comment) && ( | |
| 222 | + | <div class="inline-edit-form-area"> | |
| 223 | + | <form | |
| 224 | + | method="POST" | |
| 225 | + | action={`/${repo.name}/issues/${issue.number}/comments/${comment.id}/edit`} | |
| 226 | + | class="inline-edit-form" | |
| 227 | + | > | |
| 228 | + | <div class="form-group"> | |
| 229 | + | <textarea | |
| 230 | + | class="form-input" | |
| 231 | + | name="edit_body" | |
| 232 | + | rows="6" | |
| 233 | + | > | |
| 234 | + | {comment.body} | |
| 235 | + | </textarea> | |
| 236 | + | </div> | |
| 237 | + | <div class="form-actions"> | |
| 238 | + | <button | |
| 239 | + | type="submit" | |
| 240 | + | class="btn btn-sm btn-primary" | |
| 241 | + | > | |
| 242 | + | Save | |
| 243 | + | </button> | |
| 244 | + | </div> | |
| 245 | + | </form> | |
| 246 | + | </div> | |
| 247 | + | )} | |
| 248 | + | <div class="timeline-body markdown-body"> | |
| 249 | + | {comment.bodyHtml} | |
| 250 | + | </div> | |
| 251 | + | <ReactionBar | |
| 252 | + | reactions={ | |
| 253 | + | commentReactions.get(comment.id) ?? [] | |
| 254 | + | } | |
| 255 | + | postUrl={`/${repo.name}/issues/${issue.number}/react`} | |
| 256 | + | commentId={comment.id} | |
| 257 | + | user={user} | |
| 258 | + | /> | |
| 259 | + | </div> | |
| 260 | + | ))} | |
| 261 | + | ||
| 262 | + | {user && ( | |
| 263 | + | <div class="timeline-item timeline-item-new"> | |
| 264 | + | <h3 class="section-title">Add a comment</h3> | |
| 265 | + | <form | |
| 266 | + | method="POST" | |
| 267 | + | action={`/${repo.name}/issues/${issue.number}/comments`} | |
| 268 | + | > | |
| 269 | + | <div class="form-group"> | |
| 270 | + | <textarea | |
| 271 | + | name="body" | |
| 272 | + | rows="6" | |
| 273 | + | placeholder="Leave a comment (Markdown supported)" | |
| 274 | + | required | |
| 275 | + | /> | |
| 276 | + | </div> | |
| 277 | + | <div class="form-actions"> | |
| 278 | + | <button | |
| 279 | + | type="submit" | |
| 280 | + | class="btn btn-primary" | |
| 281 | + | > | |
| 282 | + | Comment | |
| 283 | + | </button> | |
| 284 | + | </div> | |
| 285 | + | </form> | |
| 286 | + | </div> | |
| 287 | + | )} | |
| 288 | + | {!user && ( | |
| 289 | + | <p class="text-muted"> | |
| 290 | + | <a href="/login">Sign in</a> to leave a comment. | |
| 291 | + | </p> | |
| 292 | + | )} | |
| 293 | + | </div> | |
| 294 | + | </div> | |
| 295 | + | </Layout> | |
| 296 | + | ); | |
| 297 | + | } | |
Asrc/views/issues/IssueList.tsx
| @@ -0,0 +1,102 @@ | |||
|---|---|---|---|
| 1 | + | import type { IssueRow, RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import { displayName } from "../../lib/users.ts"; | |
| 3 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | + | import { Layout } from "../layout.tsx"; | |
| 5 | + | import { Pagination, type PaginationInfo } from "../Pagination.tsx"; | |
| 6 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 7 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 8 | + | ||
| 9 | + | interface IssueListProps { | |
| 10 | + | user: SessionUser | null; | |
| 11 | + | repo: RepositoryRow; | |
| 12 | + | issues: (IssueRow & { author_username: string })[]; | |
| 13 | + | status: "open" | "closed"; | |
| 14 | + | counts: Record<string, number>; | |
| 15 | + | pagination: PaginationInfo; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export function IssueList({ | |
| 19 | + | user, | |
| 20 | + | repo, | |
| 21 | + | issues, | |
| 22 | + | status, | |
| 23 | + | counts, | |
| 24 | + | pagination, | |
| 25 | + | }: IssueListProps) { | |
| 26 | + | return ( | |
| 27 | + | <Layout user={user} title={`Issues — ${repo.name}`}> | |
| 28 | + | <div class="container"> | |
| 29 | + | <RepoHeader repo={repo} /> | |
| 30 | + | <RepoNav repo={repo} active="issues" user={user} /> | |
| 31 | + | <div class="list-header"> | |
| 32 | + | <div class="list-header-tabs"> | |
| 33 | + | <a | |
| 34 | + | href={`/${repo.name}/issues?status=open`} | |
| 35 | + | class={`list-tab${status === "open" ? " active" : ""}`} | |
| 36 | + | > | |
| 37 | + | Open{" "} | |
| 38 | + | {(counts.open ?? 0) > 0 && ( | |
| 39 | + | <span class="tab-count">{counts.open}</span> | |
| 40 | + | )} | |
| 41 | + | </a> | |
| 42 | + | <a | |
| 43 | + | href={`/${repo.name}/issues?status=closed`} | |
| 44 | + | class={`list-tab${status === "closed" ? " active" : ""}`} | |
| 45 | + | > | |
| 46 | + | Closed{" "} | |
| 47 | + | {(counts.closed ?? 0) > 0 && ( | |
| 48 | + | <span class="tab-count">{counts.closed}</span> | |
| 49 | + | )} | |
| 50 | + | </a> | |
| 51 | + | </div> | |
| 52 | + | {user && ( | |
| 53 | + | <a | |
| 54 | + | href={`/${repo.name}/issues/new`} | |
| 55 | + | class="btn btn-primary btn-sm" | |
| 56 | + | > | |
| 57 | + | New issue | |
| 58 | + | </a> | |
| 59 | + | )} | |
| 60 | + | </div> | |
| 61 | + | {issues.length === 0 ? ( | |
| 62 | + | <div class="empty-state"> | |
| 63 | + | <p>No {status} issues.</p> | |
| 64 | + | </div> | |
| 65 | + | ) : ( | |
| 66 | + | <ul class="issue-list"> | |
| 67 | + | {issues.map((issue) => ( | |
| 68 | + | <li class="issue-item"> | |
| 69 | + | <div class="issue-main"> | |
| 70 | + | <span | |
| 71 | + | class={`issue-status-dot ${issue.status}`} | |
| 72 | + | /> | |
| 73 | + | <a | |
| 74 | + | href={`/${repo.name}/issues/${issue.number}`} | |
| 75 | + | class="issue-title" | |
| 76 | + | > | |
| 77 | + | {issue.title} | |
| 78 | + | </a> | |
| 79 | + | <span class="issue-number"> | |
| 80 | + | #{issue.number} | |
| 81 | + | </span> | |
| 82 | + | </div> | |
| 83 | + | <div class="issue-meta"> | |
| 84 | + | <span class="issue-author"> | |
| 85 | + | opened by{" "} | |
| 86 | + | {displayName(issue.author_username)} | |
| 87 | + | </span> | |
| 88 | + | <time datetime={issue.created_at}> | |
| 89 | + | {new Date( | |
| 90 | + | issue.created_at, | |
| 91 | + | ).toLocaleDateString()} | |
| 92 | + | </time> | |
| 93 | + | </div> | |
| 94 | + | </li> | |
| 95 | + | ))} | |
| 96 | + | </ul> | |
| 97 | + | )} | |
| 98 | + | <Pagination {...pagination} /> | |
| 99 | + | </div> | |
| 100 | + | </Layout> | |
| 101 | + | ); | |
| 102 | + | } | |
Asrc/views/issues/NewIssue.tsx
| @@ -0,0 +1,60 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { Layout } from "../layout.tsx"; | |
| 4 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 5 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 6 | + | ||
| 7 | + | interface NewIssueProps { | |
| 8 | + | user: SessionUser; | |
| 9 | + | repo: RepositoryRow; | |
| 10 | + | error?: string; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export function NewIssue({ user, repo, error }: NewIssueProps) { | |
| 14 | + | return ( | |
| 15 | + | <Layout user={user} title={`New issue — ${repo.name}`}> | |
| 16 | + | <div class="container container-narrow"> | |
| 17 | + | <RepoHeader repo={repo} /> | |
| 18 | + | <RepoNav repo={repo} active="issues" user={user} /> | |
| 19 | + | <h2 class="section-title">New issue</h2> | |
| 20 | + | {error && <p class="form-error">{error}</p>} | |
| 21 | + | <form | |
| 22 | + | method="POST" | |
| 23 | + | action={`/${repo.name}/issues`} | |
| 24 | + | class="form-card" | |
| 25 | + | > | |
| 26 | + | <div class="form-group"> | |
| 27 | + | <label for="title">Title</label> | |
| 28 | + | <input | |
| 29 | + | id="title" | |
| 30 | + | name="title" | |
| 31 | + | type="text" | |
| 32 | + | required | |
| 33 | + | placeholder="Short, descriptive title" | |
| 34 | + | /> | |
| 35 | + | </div> | |
| 36 | + | <div class="form-group"> | |
| 37 | + | <label for="body"> | |
| 38 | + | Description{" "} | |
| 39 | + | <span class="text-muted">(Markdown supported)</span> | |
| 40 | + | </label> | |
| 41 | + | <textarea | |
| 42 | + | id="body" | |
| 43 | + | name="body" | |
| 44 | + | rows="10" | |
| 45 | + | placeholder="Describe the issue..." | |
| 46 | + | /> | |
| 47 | + | </div> | |
| 48 | + | <div class="form-actions"> | |
| 49 | + | <button type="submit" class="btn btn-primary"> | |
| 50 | + | Submit issue | |
| 51 | + | </button> | |
| 52 | + | <a href={`/${repo.name}/issues`} class="btn btn-ghost"> | |
| 53 | + | Cancel | |
| 54 | + | </a> | |
| 55 | + | </div> | |
| 56 | + | </form> | |
| 57 | + | </div> | |
| 58 | + | </Layout> | |
| 59 | + | ); | |
| 60 | + | } | |
Asrc/views/layout.tsx
| @@ -0,0 +1,91 @@ | |||
|---|---|---|---|
| 1 | + | import { OWNER_DISPLAY_NAME } from "../config.ts"; | |
| 2 | + | import { displayName } from "../lib/users.ts"; | |
| 3 | + | import type { SessionUser } from "../middleware/session.ts"; | |
| 4 | + | import { Avatar } from "./Avatar.tsx"; | |
| 5 | + | ||
| 6 | + | interface LayoutProps { | |
| 7 | + | user: SessionUser | null; | |
| 8 | + | title?: string; | |
| 9 | + | children?: JSX.Element | JSX.Element[] | string | null; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export function Layout({ user, title, children }: LayoutProps) { | |
| 13 | + | const pageTitle = title ? `${title} — Hearthforge` : "Hearthforge"; | |
| 14 | + | return ( | |
| 15 | + | <html lang="en"> | |
| 16 | + | <head> | |
| 17 | + | <meta charset="UTF-8" /> | |
| 18 | + | <meta name="viewport" content="width=500" /> | |
| 19 | + | <title>{pageTitle}</title> | |
| 20 | + | <script src="/assets/theme.js"></script> | |
| 21 | + | <link | |
| 22 | + | rel="icon" | |
| 23 | + | type="image/svg+xml" | |
| 24 | + | href="/assets/favicon.svg" | |
| 25 | + | /> | |
| 26 | + | <link rel="stylesheet" href="/assets/main.css" /> | |
| 27 | + | <script src="/assets/jxl-polyfill.js" defer></script> | |
| 28 | + | </head> | |
| 29 | + | <body> | |
| 30 | + | <header class="site-header"> | |
| 31 | + | <nav class="site-nav"> | |
| 32 | + | <a href="/" class="site-logo"> | |
| 33 | + | <img | |
| 34 | + | src="/assets/favicon.svg" | |
| 35 | + | class="logo-icon" | |
| 36 | + | alt="" | |
| 37 | + | aria-hidden="true" | |
| 38 | + | /> | |
| 39 | + | <span class="logo-text">Hearthforge</span> | |
| 40 | + | </a> | |
| 41 | + | <div class="nav-links"> | |
| 42 | + | {user ? ( | |
| 43 | + | <> | |
| 44 | + | <a href="/settings" class="nav-user"> | |
| 45 | + | <Avatar | |
| 46 | + | userId={user.id} | |
| 47 | + | version={user.avatar_version} | |
| 48 | + | size={24} | |
| 49 | + | /> | |
| 50 | + | {displayName(user.username)} | |
| 51 | + | </a> | |
| 52 | + | <form | |
| 53 | + | method="POST" | |
| 54 | + | action="/logout" | |
| 55 | + | class="inline-form" | |
| 56 | + | > | |
| 57 | + | <button | |
| 58 | + | type="submit" | |
| 59 | + | class="btn btn-sm btn-ghost" | |
| 60 | + | > | |
| 61 | + | Sign out | |
| 62 | + | </button> | |
| 63 | + | </form> | |
| 64 | + | </> | |
| 65 | + | ) : ( | |
| 66 | + | <> | |
| 67 | + | <a | |
| 68 | + | href="/login" | |
| 69 | + | class="btn btn-sm btn-ghost" | |
| 70 | + | > | |
| 71 | + | Sign in | |
| 72 | + | </a> | |
| 73 | + | <a | |
| 74 | + | href="/register" | |
| 75 | + | class="btn btn-sm btn-primary" | |
| 76 | + | > | |
| 77 | + | Register | |
| 78 | + | </a> | |
| 79 | + | </> | |
| 80 | + | )} | |
| 81 | + | </div> | |
| 82 | + | </nav> | |
| 83 | + | </header> | |
| 84 | + | <main class="site-main">{children}</main> | |
| 85 | + | <footer class="site-footer"> | |
| 86 | + | <p>Hearthforge — hosted by {OWNER_DISPLAY_NAME}</p> | |
| 87 | + | </footer> | |
| 88 | + | </body> | |
| 89 | + | </html> | |
| 90 | + | ); | |
| 91 | + | } | |
Asrc/views/patches/NewPatch.tsx
| @@ -0,0 +1,75 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { Layout } from "../layout.tsx"; | |
| 4 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 5 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 6 | + | ||
| 7 | + | interface NewPatchProps { | |
| 8 | + | user: SessionUser; | |
| 9 | + | repo: RepositoryRow; | |
| 10 | + | error?: string; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export function NewPatch({ user, repo, error }: NewPatchProps) { | |
| 14 | + | return ( | |
| 15 | + | <Layout user={user} title={`New patch — ${repo.name}`}> | |
| 16 | + | <div class="container container-narrow"> | |
| 17 | + | <RepoHeader repo={repo} /> | |
| 18 | + | <RepoNav repo={repo} active="patches" user={user} /> | |
| 19 | + | <h2 class="section-title">Upload patch</h2> | |
| 20 | + | {error && <p class="form-error">{error}</p>} | |
| 21 | + | <form | |
| 22 | + | method="POST" | |
| 23 | + | action={`/${repo.name}/patches`} | |
| 24 | + | enctype="multipart/form-data" | |
| 25 | + | class="form-card" | |
| 26 | + | > | |
| 27 | + | <div class="form-group"> | |
| 28 | + | <label for="title">Title</label> | |
| 29 | + | <input | |
| 30 | + | id="title" | |
| 31 | + | name="title" | |
| 32 | + | type="text" | |
| 33 | + | required | |
| 34 | + | placeholder="What does this patch do?" | |
| 35 | + | /> | |
| 36 | + | </div> | |
| 37 | + | <div class="form-group"> | |
| 38 | + | <label for="description"> | |
| 39 | + | Description{" "} | |
| 40 | + | <span class="text-muted"> | |
| 41 | + | (Markdown supported, optional) | |
| 42 | + | </span> | |
| 43 | + | </label> | |
| 44 | + | <textarea | |
| 45 | + | id="description" | |
| 46 | + | name="description" | |
| 47 | + | rows="5" | |
| 48 | + | /> | |
| 49 | + | </div> | |
| 50 | + | <div class="form-group"> | |
| 51 | + | <label for="patch_file"> | |
| 52 | + | Patch file{" "} | |
| 53 | + | <span class="text-muted">(.patch or .diff)</span> | |
| 54 | + | </label> | |
| 55 | + | <input | |
| 56 | + | id="patch_file" | |
| 57 | + | name="patch_file" | |
| 58 | + | type="file" | |
| 59 | + | accept=".patch,.diff,text/plain" | |
| 60 | + | required | |
| 61 | + | /> | |
| 62 | + | </div> | |
| 63 | + | <div class="form-actions"> | |
| 64 | + | <button type="submit" class="btn btn-primary"> | |
| 65 | + | Upload patch | |
| 66 | + | </button> | |
| 67 | + | <a href={`/${repo.name}/patches`} class="btn btn-ghost"> | |
| 68 | + | Cancel | |
| 69 | + | </a> | |
| 70 | + | </div> | |
| 71 | + | </form> | |
| 72 | + | </div> | |
| 73 | + | </Layout> | |
| 74 | + | ); | |
| 75 | + | } | |
Asrc/views/patches/PatchDetail.tsx
| @@ -0,0 +1,382 @@ | |||
|---|---|---|---|
| 1 | + | import type { | |
| 2 | + | PatchCommentRow, | |
| 3 | + | PatchRow, | |
| 4 | + | RepositoryRow, | |
| 5 | + | } from "../../db/index.ts"; | |
| 6 | + | import { displayName } from "../../lib/users.ts"; | |
| 7 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 8 | + | import type { RenderedDiffFile } from "../../services/diffHighlight.ts"; | |
| 9 | + | import type { ApplyResult } from "../../services/patchCache.ts"; | |
| 10 | + | import { Avatar } from "../Avatar.tsx"; | |
| 11 | + | import { DateWithEdited } from "../DateWithEdited.tsx"; | |
| 12 | + | import { DiffView } from "../DiffView.tsx"; | |
| 13 | + | import { Layout } from "../layout.tsx"; | |
| 14 | + | import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx"; | |
| 15 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 16 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 17 | + | ||
| 18 | + | interface PatchDetailProps { | |
| 19 | + | user: SessionUser | null; | |
| 20 | + | repo: RepositoryRow; | |
| 21 | + | patch: PatchRow & { | |
| 22 | + | author_username: string; | |
| 23 | + | author_avatar_version: number | null; | |
| 24 | + | }; | |
| 25 | + | descriptionHtml: string; | |
| 26 | + | applyResult: ApplyResult | null; | |
| 27 | + | files: RenderedDiffFile[]; | |
| 28 | + | tab: "conversation" | "changes"; | |
| 29 | + | comments: (PatchCommentRow & { | |
| 30 | + | author_username: string; | |
| 31 | + | author_avatar_version: number | null; | |
| 32 | + | bodyHtml: string; | |
| 33 | + | })[]; | |
| 34 | + | reactions: ReactionCount[]; | |
| 35 | + | commentReactions: Map<number, ReactionCount[]>; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | export function PatchDetail({ | |
| 39 | + | user, | |
| 40 | + | repo, | |
| 41 | + | patch, | |
| 42 | + | descriptionHtml, | |
| 43 | + | applyResult, | |
| 44 | + | files, | |
| 45 | + | tab, | |
| 46 | + | comments, | |
| 47 | + | reactions, | |
| 48 | + | commentReactions, | |
| 49 | + | }: PatchDetailProps) { | |
| 50 | + | const canEdit = | |
| 51 | + | user != null && (user.isAdmin || user.id === patch.author_id); | |
| 52 | + | const canEditComment = (c: PatchCommentRow) => | |
| 53 | + | user != null && (user.isAdmin || user.id === c.author_id); | |
| 54 | + | ||
| 55 | + | const baseUrl = `/${repo.name}/patches/${patch.number}`; | |
| 56 | + | const reactUrl = `${baseUrl}/react`; | |
| 57 | + | ||
| 58 | + | return ( | |
| 59 | + | <Layout user={user} title={`${patch.title} — ${repo.name}`}> | |
| 60 | + | <div class="container"> | |
| 61 | + | <RepoHeader repo={repo} /> | |
| 62 | + | <RepoNav repo={repo} active="patches" user={user} /> | |
| 63 | + | ||
| 64 | + | <div class="issue-detail"> | |
| 65 | + | <div class="issue-detail-header"> | |
| 66 | + | <span class="issue-number">#{patch.number}</span> | |
| 67 | + | <h2 class="issue-detail-title">{patch.title}</h2> | |
| 68 | + | <span class={`patch-badge ${patch.status}`}> | |
| 69 | + | {patch.status} | |
| 70 | + | </span> | |
| 71 | + | {canEdit && ( | |
| 72 | + | <div class="issue-detail-meta-actions"> | |
| 73 | + | {user?.isAdmin && | |
| 74 | + | patch.status === "open" && | |
| 75 | + | applyResult?.status === "clean" && ( | |
| 76 | + | <form | |
| 77 | + | method="POST" | |
| 78 | + | action={`${baseUrl}/merge`} | |
| 79 | + | class="inline-form" | |
| 80 | + | > | |
| 81 | + | <button | |
| 82 | + | type="submit" | |
| 83 | + | class="btn btn-sm btn-primary" | |
| 84 | + | > | |
| 85 | + | Merge patch | |
| 86 | + | </button> | |
| 87 | + | </form> | |
| 88 | + | )} | |
| 89 | + | {user?.isAdmin && patch.status !== "merged" && ( | |
| 90 | + | <form | |
| 91 | + | method="POST" | |
| 92 | + | action={`${baseUrl}/close`} | |
| 93 | + | class="inline-form" | |
| 94 | + | > | |
| 95 | + | <button | |
| 96 | + | type="submit" | |
| 97 | + | class="btn btn-sm" | |
| 98 | + | > | |
| 99 | + | {patch.status === "open" | |
| 100 | + | ? "Close patch" | |
| 101 | + | : "Reopen patch"} | |
| 102 | + | </button> | |
| 103 | + | </form> | |
| 104 | + | )} | |
| 105 | + | <details class="confirm-details"> | |
| 106 | + | <summary class="btn btn-sm btn-danger"> | |
| 107 | + | Delete | |
| 108 | + | </summary> | |
| 109 | + | <div class="confirm-popup"> | |
| 110 | + | Permanently delete this patch? | |
| 111 | + | <form | |
| 112 | + | method="POST" | |
| 113 | + | action={`${baseUrl}/delete`} | |
| 114 | + | class="inline-form" | |
| 115 | + | > | |
| 116 | + | <button | |
| 117 | + | type="submit" | |
| 118 | + | class="btn btn-sm btn-danger" | |
| 119 | + | > | |
| 120 | + | Yes, delete | |
| 121 | + | </button> | |
| 122 | + | </form> | |
| 123 | + | </div> | |
| 124 | + | </details> | |
| 125 | + | </div> | |
| 126 | + | )} | |
| 127 | + | </div> | |
| 128 | + | </div> | |
| 129 | + | ||
| 130 | + | {/* Subview tabs */} | |
| 131 | + | <div class="patch-tab-nav"> | |
| 132 | + | <a | |
| 133 | + | href={baseUrl} | |
| 134 | + | class={`repo-tab${tab === "conversation" ? " active" : ""}`} | |
| 135 | + | > | |
| 136 | + | Conversation{" "} | |
| 137 | + | {comments.length > 0 && ( | |
| 138 | + | <span class="tab-count">{comments.length}</span> | |
| 139 | + | )} | |
| 140 | + | </a> | |
| 141 | + | <a | |
| 142 | + | href={`${baseUrl}?tab=changes`} | |
| 143 | + | class={`repo-tab${tab === "changes" ? " active" : ""}`} | |
| 144 | + | > | |
| 145 | + | Changes{" "} | |
| 146 | + | {files.length > 0 && ( | |
| 147 | + | <span class="tab-count">{files.length}</span> | |
| 148 | + | )} | |
| 149 | + | </a> | |
| 150 | + | </div> | |
| 151 | + | ||
| 152 | + | {tab === "conversation" ? ( | |
| 153 | + | <div class="issue-detail"> | |
| 154 | + | {/* Patch description */} | |
| 155 | + | <div class="timeline-item"> | |
| 156 | + | <div class="timeline-author"> | |
| 157 | + | <Avatar | |
| 158 | + | userId={patch.author_id} | |
| 159 | + | version={patch.author_avatar_version} | |
| 160 | + | size={24} | |
| 161 | + | /> | |
| 162 | + | <strong> | |
| 163 | + | {displayName(patch.author_username)} | |
| 164 | + | </strong> | |
| 165 | + | <div class="timeline-author-right"> | |
| 166 | + | {canEdit && ( | |
| 167 | + | <details class="inline-edit-details"> | |
| 168 | + | <summary class="btn btn-xs btn-ghost"> | |
| 169 | + | <span class="when-closed"> | |
| 170 | + | Edit | |
| 171 | + | </span> | |
| 172 | + | <span class="when-open"> | |
| 173 | + | Stop editing | |
| 174 | + | </span> | |
| 175 | + | </summary> | |
| 176 | + | </details> | |
| 177 | + | )} | |
| 178 | + | <DateWithEdited | |
| 179 | + | date={patch.created_at} | |
| 180 | + | editedAt={patch.edited_at} | |
| 181 | + | /> | |
| 182 | + | </div> | |
| 183 | + | </div> | |
| 184 | + | {canEdit && ( | |
| 185 | + | <div class="inline-edit-form-area"> | |
| 186 | + | <form | |
| 187 | + | method="POST" | |
| 188 | + | action={`${baseUrl}/edit`} | |
| 189 | + | class="inline-edit-form" | |
| 190 | + | > | |
| 191 | + | <div class="form-group"> | |
| 192 | + | <label | |
| 193 | + | class="form-label" | |
| 194 | + | for="edit-patch-title" | |
| 195 | + | > | |
| 196 | + | Title | |
| 197 | + | </label> | |
| 198 | + | <input | |
| 199 | + | class="form-input" | |
| 200 | + | id="edit-patch-title" | |
| 201 | + | name="title" | |
| 202 | + | value={patch.title} | |
| 203 | + | required | |
| 204 | + | /> | |
| 205 | + | </div> | |
| 206 | + | <div class="form-group"> | |
| 207 | + | <label | |
| 208 | + | class="form-label" | |
| 209 | + | for="edit-patch-desc" | |
| 210 | + | > | |
| 211 | + | Description | |
| 212 | + | </label> | |
| 213 | + | <textarea | |
| 214 | + | class="form-input" | |
| 215 | + | id="edit-patch-desc" | |
| 216 | + | name="edit_description" | |
| 217 | + | rows="6" | |
| 218 | + | > | |
| 219 | + | {patch.description} | |
| 220 | + | </textarea> | |
| 221 | + | </div> | |
| 222 | + | <div class="form-actions"> | |
| 223 | + | <button | |
| 224 | + | type="submit" | |
| 225 | + | class="btn btn-sm btn-primary" | |
| 226 | + | > | |
| 227 | + | Save | |
| 228 | + | </button> | |
| 229 | + | </div> | |
| 230 | + | </form> | |
| 231 | + | </div> | |
| 232 | + | )} | |
| 233 | + | <div class="timeline-body markdown-body"> | |
| 234 | + | {descriptionHtml || ( | |
| 235 | + | <em class="text-muted"> | |
| 236 | + | No description provided. | |
| 237 | + | </em> | |
| 238 | + | )} | |
| 239 | + | </div> | |
| 240 | + | <ReactionBar | |
| 241 | + | reactions={reactions} | |
| 242 | + | postUrl={reactUrl} | |
| 243 | + | user={user} | |
| 244 | + | /> | |
| 245 | + | </div> | |
| 246 | + | ||
| 247 | + | {/* Apply status */} | |
| 248 | + | {applyResult && ( | |
| 249 | + | <div class="timeline-item"> | |
| 250 | + | <div | |
| 251 | + | class={`apply-result apply-${applyResult.status}`} | |
| 252 | + | > | |
| 253 | + | <span class="apply-icon"> | |
| 254 | + | {applyResult.status === "clean" | |
| 255 | + | ? "✓" | |
| 256 | + | : "✗"} | |
| 257 | + | </span> | |
| 258 | + | <span> | |
| 259 | + | {applyResult.status === "clean" | |
| 260 | + | ? "Applies cleanly" | |
| 261 | + | : "Has conflicts"} | |
| 262 | + | </span> | |
| 263 | + | {applyResult.output && ( | |
| 264 | + | <pre class="apply-output"> | |
| 265 | + | {applyResult.output} | |
| 266 | + | </pre> | |
| 267 | + | )} | |
| 268 | + | </div> | |
| 269 | + | </div> | |
| 270 | + | )} | |
| 271 | + | ||
| 272 | + | {/* Comments */} | |
| 273 | + | {comments.map((comment) => ( | |
| 274 | + | <div class="timeline-item"> | |
| 275 | + | <div class="timeline-author"> | |
| 276 | + | <Avatar | |
| 277 | + | userId={comment.author_id} | |
| 278 | + | version={comment.author_avatar_version} | |
| 279 | + | size={24} | |
| 280 | + | /> | |
| 281 | + | <strong> | |
| 282 | + | {displayName(comment.author_username)} | |
| 283 | + | </strong> | |
| 284 | + | <div class="timeline-author-right"> | |
| 285 | + | {canEditComment(comment) && ( | |
| 286 | + | <details class="inline-edit-details"> | |
| 287 | + | <summary class="btn btn-xs btn-ghost"> | |
| 288 | + | <span class="when-closed"> | |
| 289 | + | Edit | |
| 290 | + | </span> | |
| 291 | + | <span class="when-open"> | |
| 292 | + | Stop editing | |
| 293 | + | </span> | |
| 294 | + | </summary> | |
| 295 | + | </details> | |
| 296 | + | )} | |
| 297 | + | <DateWithEdited | |
| 298 | + | date={comment.created_at} | |
| 299 | + | editedAt={comment.edited_at} | |
| 300 | + | /> | |
| 301 | + | </div> | |
| 302 | + | </div> | |
| 303 | + | {canEditComment(comment) && ( | |
| 304 | + | <div class="inline-edit-form-area"> | |
| 305 | + | <form | |
| 306 | + | method="POST" | |
| 307 | + | action={`${baseUrl}/comments/${comment.id}/edit`} | |
| 308 | + | class="inline-edit-form" | |
| 309 | + | > | |
| 310 | + | <div class="form-group"> | |
| 311 | + | <textarea | |
| 312 | + | class="form-input" | |
| 313 | + | name="edit_body" | |
| 314 | + | rows="6" | |
| 315 | + | > | |
| 316 | + | {comment.body} | |
| 317 | + | </textarea> | |
| 318 | + | </div> | |
| 319 | + | <div class="form-actions"> | |
| 320 | + | <button | |
| 321 | + | type="submit" | |
| 322 | + | class="btn btn-sm btn-primary" | |
| 323 | + | > | |
| 324 | + | Save | |
| 325 | + | </button> | |
| 326 | + | </div> | |
| 327 | + | </form> | |
| 328 | + | </div> | |
| 329 | + | )} | |
| 330 | + | <div class="timeline-body markdown-body"> | |
| 331 | + | {comment.bodyHtml} | |
| 332 | + | </div> | |
| 333 | + | <ReactionBar | |
| 334 | + | reactions={ | |
| 335 | + | commentReactions.get(comment.id) ?? [] | |
| 336 | + | } | |
| 337 | + | postUrl={reactUrl} | |
| 338 | + | commentId={comment.id} | |
| 339 | + | user={user} | |
| 340 | + | /> | |
| 341 | + | </div> | |
| 342 | + | ))} | |
| 343 | + | ||
| 344 | + | {user && ( | |
| 345 | + | <div class="timeline-item timeline-item-new"> | |
| 346 | + | <h3 class="section-title">Add a comment</h3> | |
| 347 | + | <form | |
| 348 | + | method="POST" | |
| 349 | + | action={`${baseUrl}/comments`} | |
| 350 | + | > | |
| 351 | + | <div class="form-group"> | |
| 352 | + | <textarea | |
| 353 | + | name="body" | |
| 354 | + | rows="6" | |
| 355 | + | placeholder="Leave a comment (Markdown supported)" | |
| 356 | + | required | |
| 357 | + | /> | |
| 358 | + | </div> | |
| 359 | + | <div class="form-actions"> | |
| 360 | + | <button | |
| 361 | + | type="submit" | |
| 362 | + | class="btn btn-primary" | |
| 363 | + | > | |
| 364 | + | Comment | |
| 365 | + | </button> | |
| 366 | + | </div> | |
| 367 | + | </form> | |
| 368 | + | </div> | |
| 369 | + | )} | |
| 370 | + | {!user && ( | |
| 371 | + | <p class="text-muted"> | |
| 372 | + | <a href="/login">Sign in</a> to leave a comment. | |
| 373 | + | </p> | |
| 374 | + | )} | |
| 375 | + | </div> | |
| 376 | + | ) : ( | |
| 377 | + | <DiffView files={files} repo={repo} /> | |
| 378 | + | )} | |
| 379 | + | </div> | |
| 380 | + | </Layout> | |
| 381 | + | ); | |
| 382 | + | } | |
Asrc/views/patches/PatchList.tsx
| @@ -0,0 +1,110 @@ | |||
|---|---|---|---|
| 1 | + | import type { PatchRow, RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import { displayName } from "../../lib/users.ts"; | |
| 3 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | + | import { Layout } from "../layout.tsx"; | |
| 5 | + | import { Pagination, type PaginationInfo } from "../Pagination.tsx"; | |
| 6 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 7 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 8 | + | ||
| 9 | + | interface PatchListProps { | |
| 10 | + | user: SessionUser | null; | |
| 11 | + | repo: RepositoryRow; | |
| 12 | + | patches: (PatchRow & { author_username: string })[]; | |
| 13 | + | status: string; | |
| 14 | + | counts: Record<string, number>; | |
| 15 | + | pagination: PaginationInfo; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export function PatchList({ | |
| 19 | + | user, | |
| 20 | + | repo, | |
| 21 | + | patches, | |
| 22 | + | status, | |
| 23 | + | counts, | |
| 24 | + | pagination, | |
| 25 | + | }: PatchListProps) { | |
| 26 | + | return ( | |
| 27 | + | <Layout user={user} title={`Patches — ${repo.name}`}> | |
| 28 | + | <div class="container"> | |
| 29 | + | <RepoHeader repo={repo} /> | |
| 30 | + | <RepoNav repo={repo} active="patches" user={user} /> | |
| 31 | + | <div class="list-header"> | |
| 32 | + | <div class="list-header-tabs"> | |
| 33 | + | <a | |
| 34 | + | href={`/${repo.name}/patches?status=open`} | |
| 35 | + | class={`list-tab${status === "open" ? " active" : ""}`} | |
| 36 | + | > | |
| 37 | + | Open{" "} | |
| 38 | + | {(counts.open ?? 0) > 0 && ( | |
| 39 | + | <span class="tab-count">{counts.open}</span> | |
| 40 | + | )} | |
| 41 | + | </a> | |
| 42 | + | <a | |
| 43 | + | href={`/${repo.name}/patches?status=merged`} | |
| 44 | + | class={`list-tab${status === "merged" ? " active" : ""}`} | |
| 45 | + | > | |
| 46 | + | Merged{" "} | |
| 47 | + | {(counts.merged ?? 0) > 0 && ( | |
| 48 | + | <span class="tab-count">{counts.merged}</span> | |
| 49 | + | )} | |
| 50 | + | </a> | |
| 51 | + | <a | |
| 52 | + | href={`/${repo.name}/patches?status=closed`} | |
| 53 | + | class={`list-tab${status === "closed" ? " active" : ""}`} | |
| 54 | + | > | |
| 55 | + | Closed{" "} | |
| 56 | + | {(counts.closed ?? 0) > 0 && ( | |
| 57 | + | <span class="tab-count">{counts.closed}</span> | |
| 58 | + | )} | |
| 59 | + | </a> | |
| 60 | + | </div> | |
| 61 | + | {user && ( | |
| 62 | + | <a | |
| 63 | + | href={`/${repo.name}/patches/new`} | |
| 64 | + | class="btn btn-primary btn-sm" | |
| 65 | + | > | |
| 66 | + | Upload patch | |
| 67 | + | </a> | |
| 68 | + | )} | |
| 69 | + | </div> | |
| 70 | + | {patches.length === 0 ? ( | |
| 71 | + | <div class="empty-state"> | |
| 72 | + | <p>No {status} patches.</p> | |
| 73 | + | </div> | |
| 74 | + | ) : ( | |
| 75 | + | <ul class="issue-list"> | |
| 76 | + | {patches.map((patch) => ( | |
| 77 | + | <li class="issue-item"> | |
| 78 | + | <div class="issue-main"> | |
| 79 | + | <span | |
| 80 | + | class={`patch-status-dot ${patch.status}`} | |
| 81 | + | /> | |
| 82 | + | <a | |
| 83 | + | href={`/${repo.name}/patches/${patch.number}`} | |
| 84 | + | class="issue-title" | |
| 85 | + | > | |
| 86 | + | {patch.title} | |
| 87 | + | </a> | |
| 88 | + | <span class="issue-number"> | |
| 89 | + | #{patch.number} | |
| 90 | + | </span> | |
| 91 | + | </div> | |
| 92 | + | <div class="issue-meta"> | |
| 93 | + | <span> | |
| 94 | + | by {displayName(patch.author_username)} | |
| 95 | + | </span> | |
| 96 | + | <time datetime={patch.created_at}> | |
| 97 | + | {new Date( | |
| 98 | + | patch.created_at, | |
| 99 | + | ).toLocaleDateString()} | |
| 100 | + | </time> | |
| 101 | + | </div> | |
| 102 | + | </li> | |
| 103 | + | ))} | |
| 104 | + | </ul> | |
| 105 | + | )} | |
| 106 | + | <Pagination {...pagination} /> | |
| 107 | + | </div> | |
| 108 | + | </Layout> | |
| 109 | + | ); | |
| 110 | + | } | |
Asrc/views/releases/NewRelease.tsx
| @@ -0,0 +1,136 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { Layout } from "../layout.tsx"; | |
| 4 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 5 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 6 | + | ||
| 7 | + | interface NewReleaseProps { | |
| 8 | + | user: SessionUser; | |
| 9 | + | repo: RepositoryRow; | |
| 10 | + | error?: string; | |
| 11 | + | values?: { | |
| 12 | + | tag_name?: string; | |
| 13 | + | name?: string; | |
| 14 | + | notes?: string; | |
| 15 | + | commit_hash?: string; | |
| 16 | + | include_source_code?: boolean; | |
| 17 | + | }; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export function NewRelease({ user, repo, error, values }: NewReleaseProps) { | |
| 21 | + | return ( | |
| 22 | + | <Layout user={user} title={`New Release — ${repo.name}`}> | |
| 23 | + | <div class="container"> | |
| 24 | + | <RepoHeader repo={repo} /> | |
| 25 | + | <RepoNav repo={repo} active="releases" user={user} /> | |
| 26 | + | <div class="form-page"> | |
| 27 | + | <h2 class="page-title">New Release</h2> | |
| 28 | + | {error && <div class="flash flash-error">{error}</div>} | |
| 29 | + | <form | |
| 30 | + | method="post" | |
| 31 | + | action={`/${repo.name}/releases`} | |
| 32 | + | enctype="multipart/form-data" | |
| 33 | + | class="form" | |
| 34 | + | > | |
| 35 | + | <div class="form-group"> | |
| 36 | + | <label class="form-label" for="tag_name"> | |
| 37 | + | Tag name <span class="form-required">*</span> | |
| 38 | + | </label> | |
| 39 | + | <input | |
| 40 | + | type="text" | |
| 41 | + | id="tag_name" | |
| 42 | + | name="tag_name" | |
| 43 | + | class="form-input" | |
| 44 | + | required | |
| 45 | + | value={values?.tag_name ?? ""} | |
| 46 | + | placeholder="v1.0.0" | |
| 47 | + | /> | |
| 48 | + | </div> | |
| 49 | + | <div class="form-group"> | |
| 50 | + | <label class="form-label" for="name"> | |
| 51 | + | Release title | |
| 52 | + | </label> | |
| 53 | + | <input | |
| 54 | + | type="text" | |
| 55 | + | id="name" | |
| 56 | + | name="name" | |
| 57 | + | class="form-input" | |
| 58 | + | value={values?.name ?? ""} | |
| 59 | + | placeholder="Optional display name" | |
| 60 | + | /> | |
| 61 | + | </div> | |
| 62 | + | <div class="form-group"> | |
| 63 | + | <label class="form-label" for="commit_hash"> | |
| 64 | + | Commit hash <span class="form-required">*</span> | |
| 65 | + | </label> | |
| 66 | + | <input | |
| 67 | + | type="text" | |
| 68 | + | id="commit_hash" | |
| 69 | + | name="commit_hash" | |
| 70 | + | class="form-input monospace" | |
| 71 | + | required | |
| 72 | + | value={values?.commit_hash ?? ""} | |
| 73 | + | placeholder="Full or abbreviated commit SHA" | |
| 74 | + | /> | |
| 75 | + | </div> | |
| 76 | + | <div class="form-group"> | |
| 77 | + | <label class="form-label" for="notes"> | |
| 78 | + | Release notes | |
| 79 | + | </label> | |
| 80 | + | <textarea | |
| 81 | + | id="notes" | |
| 82 | + | name="notes" | |
| 83 | + | class="form-input form-textarea" | |
| 84 | + | rows="8" | |
| 85 | + | > | |
| 86 | + | {values?.notes ?? ""} | |
| 87 | + | </textarea> | |
| 88 | + | </div> | |
| 89 | + | <div class="form-group"> | |
| 90 | + | <label class="checkbox-label"> | |
| 91 | + | <input | |
| 92 | + | type="checkbox" | |
| 93 | + | name="include_source_code" | |
| 94 | + | value="on" | |
| 95 | + | checked={ | |
| 96 | + | values?.include_source_code ?? false | |
| 97 | + | } | |
| 98 | + | /> | |
| 99 | + | <span> | |
| 100 | + | Include source code archives (zip, tar.gz, | |
| 101 | + | tar.zst) | |
| 102 | + | </span> | |
| 103 | + | </label> | |
| 104 | + | </div> | |
| 105 | + | <div class="form-group"> | |
| 106 | + | <label class="form-label" for="files"> | |
| 107 | + | Attach files | |
| 108 | + | </label> | |
| 109 | + | <input | |
| 110 | + | type="file" | |
| 111 | + | id="files" | |
| 112 | + | name="files" | |
| 113 | + | class="form-input" | |
| 114 | + | multiple | |
| 115 | + | /> | |
| 116 | + | <p class="form-hint"> | |
| 117 | + | You can attach multiple files to this release. | |
| 118 | + | </p> | |
| 119 | + | </div> | |
| 120 | + | <div class="form-actions"> | |
| 121 | + | <button type="submit" class="btn btn-primary"> | |
| 122 | + | Create release | |
| 123 | + | </button> | |
| 124 | + | <a | |
| 125 | + | href={`/${repo.name}/releases`} | |
| 126 | + | class="btn btn-secondary" | |
| 127 | + | > | |
| 128 | + | Cancel | |
| 129 | + | </a> | |
| 130 | + | </div> | |
| 131 | + | </form> | |
| 132 | + | </div> | |
| 133 | + | </div> | |
| 134 | + | </Layout> | |
| 135 | + | ); | |
| 136 | + | } | |
Asrc/views/releases/ReleaseDetail.tsx
| @@ -0,0 +1,179 @@ | |||
|---|---|---|---|
| 1 | + | import type { | |
| 2 | + | ReleaseAssetRow, | |
| 3 | + | ReleaseRow, | |
| 4 | + | RepositoryRow, | |
| 5 | + | } from "../../db/index.ts"; | |
| 6 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 7 | + | import { Layout } from "../layout.tsx"; | |
| 8 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 9 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 10 | + | ||
| 11 | + | function formatBytes(bytes: number): string { | |
| 12 | + | if (bytes < 1024) return `${bytes} B`; | |
| 13 | + | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| 14 | + | return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | interface SourceArchive { | |
| 18 | + | format: string; | |
| 19 | + | filename: string; | |
| 20 | + | size: number; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | interface ReleaseDetailProps { | |
| 24 | + | user: SessionUser | null; | |
| 25 | + | repo: RepositoryRow; | |
| 26 | + | release: ReleaseRow; | |
| 27 | + | notesHtml: string; | |
| 28 | + | assets: ReleaseAssetRow[]; | |
| 29 | + | sourceArchives: SourceArchive[]; | |
| 30 | + | sourceArchivesPending?: boolean; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export function ReleaseDetail({ | |
| 34 | + | user, | |
| 35 | + | repo, | |
| 36 | + | release, | |
| 37 | + | notesHtml, | |
| 38 | + | assets, | |
| 39 | + | sourceArchives, | |
| 40 | + | sourceArchivesPending, | |
| 41 | + | }: ReleaseDetailProps) { | |
| 42 | + | const hasDownloads = | |
| 43 | + | sourceArchives.length > 0 || assets.length > 0 || sourceArchivesPending; | |
| 44 | + | ||
| 45 | + | return ( | |
| 46 | + | <Layout | |
| 47 | + | user={user} | |
| 48 | + | title={`${release.name || release.tag_name} — ${repo.name}`} | |
| 49 | + | > | |
| 50 | + | <div class="container"> | |
| 51 | + | <RepoHeader repo={repo} /> | |
| 52 | + | <RepoNav repo={repo} active="releases" user={user} /> | |
| 53 | + | <div class="release-detail"> | |
| 54 | + | <div class="release-header"> | |
| 55 | + | <div class="release-item-header"> | |
| 56 | + | <div class="release-item-main"> | |
| 57 | + | <h2 | |
| 58 | + | class="page-title" | |
| 59 | + | style={`view-transition-name: release-title-${release.id}`} | |
| 60 | + | > | |
| 61 | + | {release.name || release.tag_name} | |
| 62 | + | </h2> | |
| 63 | + | <div class="release-item-meta"> | |
| 64 | + | <a | |
| 65 | + | href={`/${repo.name}/tree/${release.commit_hash}`} | |
| 66 | + | class="monospace" | |
| 67 | + | > | |
| 68 | + | {release.commit_hash.slice(0, 8)} | |
| 69 | + | </a> | |
| 70 | + | </div> | |
| 71 | + | </div> | |
| 72 | + | <div class="release-item-date"> | |
| 73 | + | <span class="badge">{release.tag_name}</span> | |
| 74 | + | <time datetime={release.created_at}> | |
| 75 | + | {new Date( | |
| 76 | + | release.created_at, | |
| 77 | + | ).toLocaleDateString()} | |
| 78 | + | </time> | |
| 79 | + | </div> | |
| 80 | + | </div> | |
| 81 | + | </div> | |
| 82 | + | ||
| 83 | + | {notesHtml && ( | |
| 84 | + | <div class="markdown-body release-notes"> | |
| 85 | + | {notesHtml} | |
| 86 | + | </div> | |
| 87 | + | )} | |
| 88 | + | ||
| 89 | + | {hasDownloads && ( | |
| 90 | + | <div class="release-assets"> | |
| 91 | + | <h3 class="release-assets-heading">Downloads</h3> | |
| 92 | + | <ul class="asset-list"> | |
| 93 | + | {assets.map((asset) => ( | |
| 94 | + | <li class="asset-item"> | |
| 95 | + | <a | |
| 96 | + | href={`/${repo.name}/releases/${release.id}/assets/${asset.filename}`} | |
| 97 | + | class="asset-name" | |
| 98 | + | > | |
| 99 | + | {asset.filename} | |
| 100 | + | </a> | |
| 101 | + | <span class="asset-size"> | |
| 102 | + | {formatBytes(asset.size)} | |
| 103 | + | </span> | |
| 104 | + | </li> | |
| 105 | + | ))} | |
| 106 | + | {sourceArchives.map((archive) => ( | |
| 107 | + | <li class="asset-item"> | |
| 108 | + | <a | |
| 109 | + | href={`/${repo.name}/releases/${release.id}/source/${archive.filename}`} | |
| 110 | + | class="asset-name" | |
| 111 | + | > | |
| 112 | + | {archive.filename} | |
| 113 | + | </a> | |
| 114 | + | <span class="asset-meta"> | |
| 115 | + | Source code ({archive.format}) | |
| 116 | + | </span> | |
| 117 | + | <span class="asset-size"> | |
| 118 | + | {formatBytes(archive.size)} | |
| 119 | + | </span> | |
| 120 | + | </li> | |
| 121 | + | ))} | |
| 122 | + | {sourceArchivesPending && | |
| 123 | + | ( | |
| 124 | + | [ | |
| 125 | + | ["zip", ".zip"], | |
| 126 | + | ["tar.gz", ".tar.gz"], | |
| 127 | + | ["tar.zst", ".tar.zst"], | |
| 128 | + | ] as const | |
| 129 | + | ).map(([format, ext]) => ( | |
| 130 | + | <li class="asset-item asset-item-pending"> | |
| 131 | + | <span class="asset-name asset-name-pending"> | |
| 132 | + | {`${repo.name}-${release.commit_hash.slice(0, 8)}${ext}`} | |
| 133 | + | </span> | |
| 134 | + | <span class="asset-meta"> | |
| 135 | + | Source code ({format}) — | |
| 136 | + | generating… | |
| 137 | + | </span> | |
| 138 | + | </li> | |
| 139 | + | ))} | |
| 140 | + | </ul> | |
| 141 | + | </div> | |
| 142 | + | )} | |
| 143 | + | ||
| 144 | + | <div class="release-back"> | |
| 145 | + | <a | |
| 146 | + | href={`/${repo.name}/releases`} | |
| 147 | + | class="btn btn-secondary btn-sm" | |
| 148 | + | > | |
| 149 | + | ← All releases | |
| 150 | + | </a> | |
| 151 | + | {user?.isAdmin && ( | |
| 152 | + | <details class="confirm-details"> | |
| 153 | + | <summary class="btn btn-sm btn-danger"> | |
| 154 | + | Delete release | |
| 155 | + | </summary> | |
| 156 | + | <div class="confirm-popup"> | |
| 157 | + | Permanently delete this release and all its | |
| 158 | + | files? | |
| 159 | + | <form | |
| 160 | + | method="POST" | |
| 161 | + | action={`/${repo.name}/releases/${release.id}/delete`} | |
| 162 | + | class="inline-form" | |
| 163 | + | > | |
| 164 | + | <button | |
| 165 | + | type="submit" | |
| 166 | + | class="btn btn-sm btn-danger" | |
| 167 | + | > | |
| 168 | + | Yes, delete | |
| 169 | + | </button> | |
| 170 | + | </form> | |
| 171 | + | </div> | |
| 172 | + | </details> | |
| 173 | + | )} | |
| 174 | + | </div> | |
| 175 | + | </div> | |
| 176 | + | </div> | |
| 177 | + | </Layout> | |
| 178 | + | ); | |
| 179 | + | } | |
Asrc/views/releases/ReleaseList.tsx
| @@ -0,0 +1,122 @@ | |||
|---|---|---|---|
| 1 | + | import type { ReleaseRow, RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { | |
| 4 | + | markdownToPlaintext, | |
| 5 | + | renderMarkdown, | |
| 6 | + | } from "../../services/markdown.ts"; | |
| 7 | + | import { Layout } from "../layout.tsx"; | |
| 8 | + | import { Pagination, type PaginationInfo } from "../Pagination.tsx"; | |
| 9 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 10 | + | import { RepoNav } from "../repos/RepoNav.tsx"; | |
| 11 | + | ||
| 12 | + | interface ReleaseListProps { | |
| 13 | + | user: SessionUser | null; | |
| 14 | + | repo: RepositoryRow; | |
| 15 | + | releases: (ReleaseRow & { asset_count: number })[]; | |
| 16 | + | pagination: PaginationInfo; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | function NotesPreview({ notes }: { notes: string }) { | |
| 20 | + | const plaintext = markdownToPlaintext(notes); | |
| 21 | + | return ( | |
| 22 | + | <details class="notes-expand"> | |
| 23 | + | <summary class="notes-toggle"> | |
| 24 | + | <div class="notes-preview">{plaintext}</div> | |
| 25 | + | <span class="notes-toggle-label" /> | |
| 26 | + | </summary> | |
| 27 | + | <div class="notes-full markdown-body">{renderMarkdown(notes)}</div> | |
| 28 | + | </details> | |
| 29 | + | ); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function ReleaseList({ | |
| 33 | + | user, | |
| 34 | + | repo, | |
| 35 | + | releases, | |
| 36 | + | pagination, | |
| 37 | + | }: ReleaseListProps) { | |
| 38 | + | return ( | |
| 39 | + | <Layout user={user} title={`Releases — ${repo.name}`}> | |
| 40 | + | <div class="container"> | |
| 41 | + | <RepoHeader repo={repo} /> | |
| 42 | + | <RepoNav repo={repo} active="releases" user={user} /> | |
| 43 | + | <div class="list-header"> | |
| 44 | + | <h2 class="list-heading">Releases</h2> | |
| 45 | + | {user?.isAdmin && ( | |
| 46 | + | <a | |
| 47 | + | href={`/${repo.name}/releases/new`} | |
| 48 | + | class="btn btn-primary btn-sm" | |
| 49 | + | > | |
| 50 | + | New release | |
| 51 | + | </a> | |
| 52 | + | )} | |
| 53 | + | </div> | |
| 54 | + | {releases.length === 0 ? ( | |
| 55 | + | <div class="empty-state"> | |
| 56 | + | <p>No releases yet.</p> | |
| 57 | + | </div> | |
| 58 | + | ) : ( | |
| 59 | + | <ul class="issue-list"> | |
| 60 | + | {releases.map((release) => ( | |
| 61 | + | <li class="issue-item"> | |
| 62 | + | <div class="release-item-header"> | |
| 63 | + | <div class="release-item-main"> | |
| 64 | + | <a | |
| 65 | + | href={`/${repo.name}/releases/${release.id}`} | |
| 66 | + | class="release-item-title" | |
| 67 | + | style={`view-transition-name: release-title-${release.id}`} | |
| 68 | + | > | |
| 69 | + | {release.name || release.tag_name} | |
| 70 | + | </a> | |
| 71 | + | <div class="release-item-meta"> | |
| 72 | + | <a | |
| 73 | + | href={`/${repo.name}/tree/${release.commit_hash}`} | |
| 74 | + | class="monospace" | |
| 75 | + | > | |
| 76 | + | {release.commit_hash.slice( | |
| 77 | + | 0, | |
| 78 | + | 8, | |
| 79 | + | )} | |
| 80 | + | </a> | |
| 81 | + | {release.asset_count > 0 && ( | |
| 82 | + | <span> | |
| 83 | + | {release.asset_count} asset | |
| 84 | + | {release.asset_count !== 1 | |
| 85 | + | ? "s" | |
| 86 | + | : ""} | |
| 87 | + | </span> | |
| 88 | + | )} | |
| 89 | + | {release.include_source_code === | |
| 90 | + | 1 && ( | |
| 91 | + | <span>source archives</span> | |
| 92 | + | )} | |
| 93 | + | </div> | |
| 94 | + | </div> | |
| 95 | + | <div class="release-item-date"> | |
| 96 | + | <span class="badge"> | |
| 97 | + | {release.tag_name} | |
| 98 | + | </span> | |
| 99 | + | <time datetime={release.created_at}> | |
| 100 | + | {new Date( | |
| 101 | + | release.created_at, | |
| 102 | + | ).toLocaleDateString()} | |
| 103 | + | </time> | |
| 104 | + | </div> | |
| 105 | + | </div> | |
| 106 | + | {release.notes && ( | |
| 107 | + | <div class="release-notes-section"> | |
| 108 | + | <p class="release-notes-label"> | |
| 109 | + | Release Notes | |
| 110 | + | </p> | |
| 111 | + | <NotesPreview notes={release.notes} /> | |
| 112 | + | </div> | |
| 113 | + | )} | |
| 114 | + | </li> | |
| 115 | + | ))} | |
| 116 | + | </ul> | |
| 117 | + | )} | |
| 118 | + | <Pagination {...pagination} /> | |
| 119 | + | </div> | |
| 120 | + | </Layout> | |
| 121 | + | ); | |
| 122 | + | } | |
Asrc/views/render.tsx
| @@ -0,0 +1,8 @@ | |||
|---|---|---|---|
| 1 | + | export function html(node: JSX.Element): Response { | |
| 2 | + | return new Response(`<!DOCTYPE html>${node}`, { | |
| 3 | + | headers: { | |
| 4 | + | "Content-Type": "text/html; charset=utf-8", | |
| 5 | + | "Cache-Control": "no-store", | |
| 6 | + | }, | |
| 7 | + | }); | |
| 8 | + | } | |
Asrc/views/repos/BranchSelector.tsx
| @@ -0,0 +1,58 @@ | |||
|---|---|---|---|
| 1 | + | interface BranchSelectorProps { | |
| 2 | + | repoName: string; | |
| 3 | + | branches: string[]; | |
| 4 | + | currentRef: string; | |
| 5 | + | view: "tree" | "commits" | "blob"; | |
| 6 | + | /** subpath for tree, full file path for blob */ | |
| 7 | + | path?: string; | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | export function BranchSelector({ | |
| 11 | + | repoName, | |
| 12 | + | branches, | |
| 13 | + | currentRef, | |
| 14 | + | view, | |
| 15 | + | path, | |
| 16 | + | }: BranchSelectorProps) { | |
| 17 | + | if (branches.length === 0) return ""; | |
| 18 | + | const isDetached = !branches.includes(currentRef); | |
| 19 | + | const shortRef = | |
| 20 | + | isDetached && currentRef.length > 8 | |
| 21 | + | ? currentRef.slice(0, 8) | |
| 22 | + | : currentRef; | |
| 23 | + | return ( | |
| 24 | + | <form | |
| 25 | + | method="GET" | |
| 26 | + | action={`/${repoName}/branch-switch`} | |
| 27 | + | class="branch-selector" | |
| 28 | + | > | |
| 29 | + | <input type="hidden" name="view" value={view} /> | |
| 30 | + | {path && <input type="hidden" name="path" value={path} />} | |
| 31 | + | <span class="branch-selector-icon">⎇</span> | |
| 32 | + | <select | |
| 33 | + | name="ref" | |
| 34 | + | class="branch-select" | |
| 35 | + | onchange="this.form.submit()" | |
| 36 | + | > | |
| 37 | + | {isDetached && ( | |
| 38 | + | <option value={currentRef} selected> | |
| 39 | + | {shortRef} (commit) | |
| 40 | + | </option> | |
| 41 | + | )} | |
| 42 | + | {branches.map((b) => ( | |
| 43 | + | <option | |
| 44 | + | value={b} | |
| 45 | + | selected={b === currentRef ? true : undefined} | |
| 46 | + | > | |
| 47 | + | {b} | |
| 48 | + | </option> | |
| 49 | + | ))} | |
| 50 | + | </select> | |
| 51 | + | <noscript> | |
| 52 | + | <button type="submit" class="btn btn-sm btn-ghost"> | |
| 53 | + | Go | |
| 54 | + | </button> | |
| 55 | + | </noscript> | |
| 56 | + | </form> | |
| 57 | + | ); | |
| 58 | + | } | |
Asrc/views/repos/CommitDetail.tsx
| @@ -0,0 +1,111 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import type { RenderedDiffFile } from "../../services/diffHighlight.ts"; | |
| 4 | + | import type { CommitMeta } from "../../services/git.ts"; | |
| 5 | + | import { DiffView } from "../DiffView.tsx"; | |
| 6 | + | import { Layout } from "../layout.tsx"; | |
| 7 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 8 | + | import { RepoNav } from "./RepoNav.tsx"; | |
| 9 | + | ||
| 10 | + | interface CommitDetailProps { | |
| 11 | + | user: SessionUser | null; | |
| 12 | + | repo: RepositoryRow; | |
| 13 | + | sha: string; | |
| 14 | + | meta: CommitMeta; | |
| 15 | + | files: RenderedDiffFile[]; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | function formatDate(iso: string): string { | |
| 19 | + | try { | |
| 20 | + | return new Date(iso).toLocaleString(undefined, { | |
| 21 | + | dateStyle: "medium", | |
| 22 | + | timeStyle: "short", | |
| 23 | + | }); | |
| 24 | + | } catch { | |
| 25 | + | return iso; | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | export function CommitDetail({ | |
| 30 | + | user, | |
| 31 | + | repo, | |
| 32 | + | sha, | |
| 33 | + | meta, | |
| 34 | + | files, | |
| 35 | + | }: CommitDetailProps) { | |
| 36 | + | return ( | |
| 37 | + | <Layout user={user} title={`${sha.slice(0, 7)} — ${repo.name}`}> | |
| 38 | + | <div class="container"> | |
| 39 | + | <RepoHeader repo={repo} /> | |
| 40 | + | <RepoNav repo={repo} active="commits" user={user} /> | |
| 41 | + | ||
| 42 | + | {/* Back link */} | |
| 43 | + | <div class="commit-page-top"> | |
| 44 | + | <a | |
| 45 | + | href={`/${repo.name}/commits/${repo.default_branch}`} | |
| 46 | + | class="btn btn-ghost btn-sm" | |
| 47 | + | > | |
| 48 | + | ← Back to log | |
| 49 | + | </a> | |
| 50 | + | <a | |
| 51 | + | href={`/${repo.name}/tree/${sha}`} | |
| 52 | + | class="btn btn-secondary btn-sm" | |
| 53 | + | title={`Browse tree at ${sha.slice(0, 7)}`} | |
| 54 | + | > | |
| 55 | + | @ {sha.slice(0, 7)} | |
| 56 | + | </a> | |
| 57 | + | </div> | |
| 58 | + | ||
| 59 | + | {/* Commit metadata card */} | |
| 60 | + | <div class="commit-card"> | |
| 61 | + | <h2 class="commit-card-subject">{meta.subject}</h2> | |
| 62 | + | {meta.body && ( | |
| 63 | + | <pre class="commit-card-body">{meta.body}</pre> | |
| 64 | + | )} | |
| 65 | + | <div class="commit-card-meta"> | |
| 66 | + | <div class="commit-card-meta-row"> | |
| 67 | + | <span class="commit-meta-label">Author</span> | |
| 68 | + | <span class="commit-meta-value"> | |
| 69 | + | {meta.author} <{meta.email}> | |
| 70 | + | </span> | |
| 71 | + | </div> | |
| 72 | + | <div class="commit-card-meta-row"> | |
| 73 | + | <span class="commit-meta-label">Date</span> | |
| 74 | + | <time | |
| 75 | + | class="commit-meta-value" | |
| 76 | + | datetime={meta.date} | |
| 77 | + | > | |
| 78 | + | {formatDate(meta.date)} | |
| 79 | + | </time> | |
| 80 | + | </div> | |
| 81 | + | <div class="commit-card-meta-row"> | |
| 82 | + | <span class="commit-meta-label">Commit</span> | |
| 83 | + | <code class="commit-meta-value commit-sha-full mono"> | |
| 84 | + | {meta.hash} | |
| 85 | + | </code> | |
| 86 | + | </div> | |
| 87 | + | {meta.parents.length > 0 && ( | |
| 88 | + | <div class="commit-card-meta-row"> | |
| 89 | + | <span class="commit-meta-label"> | |
| 90 | + | Parent{meta.parents.length > 1 ? "s" : ""} | |
| 91 | + | </span> | |
| 92 | + | <span class="commit-meta-value"> | |
| 93 | + | {meta.parents.map((p) => ( | |
| 94 | + | <a | |
| 95 | + | href={`/${repo.name}/commit/${p}`} | |
| 96 | + | class="commit-hash mono" | |
| 97 | + | > | |
| 98 | + | {p.slice(0, 7)} | |
| 99 | + | </a> | |
| 100 | + | ))} | |
| 101 | + | </span> | |
| 102 | + | </div> | |
| 103 | + | )} | |
| 104 | + | </div> | |
| 105 | + | </div> | |
| 106 | + | ||
| 107 | + | <DiffView files={files} repo={repo} sha={sha} /> | |
| 108 | + | </div> | |
| 109 | + | </Layout> | |
| 110 | + | ); | |
| 111 | + | } | |
Asrc/views/repos/CommitLog.tsx
| @@ -0,0 +1,100 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import type { CommitEntry } from "../../services/git.ts"; | |
| 4 | + | import { Layout } from "../layout.tsx"; | |
| 5 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 6 | + | import { BranchSelector } from "./BranchSelector.tsx"; | |
| 7 | + | import { RepoNav } from "./RepoNav.tsx"; | |
| 8 | + | ||
| 9 | + | interface CommitLogProps { | |
| 10 | + | user: SessionUser | null; | |
| 11 | + | repo: RepositoryRow; | |
| 12 | + | ref: string; | |
| 13 | + | commits: CommitEntry[]; | |
| 14 | + | branches: string[]; | |
| 15 | + | /** URL for the next (older) page, or null if this is the last page. */ | |
| 16 | + | olderUrl: string | null; | |
| 17 | + | /** URL for the previous (newer) page, or null if this is the first page. */ | |
| 18 | + | newerUrl: string | null; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export function CommitLog({ | |
| 22 | + | user, | |
| 23 | + | repo, | |
| 24 | + | ref: logRef, | |
| 25 | + | commits, | |
| 26 | + | branches, | |
| 27 | + | olderUrl, | |
| 28 | + | newerUrl, | |
| 29 | + | }: CommitLogProps) { | |
| 30 | + | return ( | |
| 31 | + | <Layout user={user} title={`Commits — ${repo.name}`}> | |
| 32 | + | <div class="container"> | |
| 33 | + | <RepoHeader repo={repo} /> | |
| 34 | + | <RepoNav repo={repo} active="commits" user={user} /> | |
| 35 | + | <div class="commits-header"> | |
| 36 | + | <h2 class="section-title">Commits</h2> | |
| 37 | + | <BranchSelector | |
| 38 | + | repoName={repo.name} | |
| 39 | + | branches={branches} | |
| 40 | + | currentRef={logRef} | |
| 41 | + | view="commits" | |
| 42 | + | /> | |
| 43 | + | </div> | |
| 44 | + | {commits.length === 0 ? ( | |
| 45 | + | <p class="text-muted">No commits yet.</p> | |
| 46 | + | ) : ( | |
| 47 | + | <ul class="commit-list commit-log"> | |
| 48 | + | {commits.map((c) => ( | |
| 49 | + | <li class="commit-item"> | |
| 50 | + | <div class="commit-main"> | |
| 51 | + | <a | |
| 52 | + | href={`/${repo.name}/commit/${c.hash}`} | |
| 53 | + | class="commit-subject" | |
| 54 | + | > | |
| 55 | + | {c.subject} | |
| 56 | + | </a> | |
| 57 | + | </div> | |
| 58 | + | <div class="commit-meta"> | |
| 59 | + | <span class="commit-author"> | |
| 60 | + | {c.author} | |
| 61 | + | </span> | |
| 62 | + | <a | |
| 63 | + | href={`/${repo.name}/commit/${c.hash}`} | |
| 64 | + | class="commit-hash mono" | |
| 65 | + | > | |
| 66 | + | {c.hash.slice(0, 7)} | |
| 67 | + | </a> | |
| 68 | + | <time class="commit-date" datetime={c.date}> | |
| 69 | + | {new Date(c.date).toLocaleString()} | |
| 70 | + | </time> | |
| 71 | + | </div> | |
| 72 | + | </li> | |
| 73 | + | ))} | |
| 74 | + | </ul> | |
| 75 | + | )} | |
| 76 | + | {(newerUrl || olderUrl) && ( | |
| 77 | + | <nav | |
| 78 | + | class="commit-cursor-nav" | |
| 79 | + | aria-label="Commit history navigation" | |
| 80 | + | > | |
| 81 | + | <div class="commit-cursor-prev"> | |
| 82 | + | {newerUrl && ( | |
| 83 | + | <a href={newerUrl} class="pagination-btn"> | |
| 84 | + | ← Newer | |
| 85 | + | </a> | |
| 86 | + | )} | |
| 87 | + | </div> | |
| 88 | + | <div class="commit-cursor-next"> | |
| 89 | + | {olderUrl && ( | |
| 90 | + | <a href={olderUrl} class="pagination-btn"> | |
| 91 | + | Older → | |
| 92 | + | </a> | |
| 93 | + | )} | |
| 94 | + | </div> | |
| 95 | + | </nav> | |
| 96 | + | )} | |
| 97 | + | </div> | |
| 98 | + | </Layout> | |
| 99 | + | ); | |
| 100 | + | } | |
Asrc/views/repos/FileBlob.tsx
| @@ -0,0 +1,115 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import type { FileView } from "../../services/highlight.ts"; | |
| 4 | + | import { Layout } from "../layout.tsx"; | |
| 5 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 6 | + | import { BranchSelector } from "./BranchSelector.tsx"; | |
| 7 | + | import { RepoNav } from "./RepoNav.tsx"; | |
| 8 | + | ||
| 9 | + | interface FileBlobProps { | |
| 10 | + | user: SessionUser | null; | |
| 11 | + | repo: RepositoryRow; | |
| 12 | + | ref: string; | |
| 13 | + | filePath: string; | |
| 14 | + | view: FileView; | |
| 15 | + | branches: string[]; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export function FileBlob({ | |
| 19 | + | user, | |
| 20 | + | repo, | |
| 21 | + | ref: blobRef, | |
| 22 | + | filePath, | |
| 23 | + | view, | |
| 24 | + | branches, | |
| 25 | + | }: FileBlobProps) { | |
| 26 | + | const parts = filePath.split("/"); | |
| 27 | + | const filename = parts[parts.length - 1] ?? filePath; | |
| 28 | + | const dir = parts.slice(0, -1).join("/"); | |
| 29 | + | const _backHref = dir | |
| 30 | + | ? `/${repo.name}/tree/${blobRef}/${dir}` | |
| 31 | + | : `/${repo.name}/tree/${blobRef}`; | |
| 32 | + | return ( | |
| 33 | + | <Layout user={user} title={`${repo.name}/${filePath}`}> | |
| 34 | + | <div class="container"> | |
| 35 | + | <RepoHeader repo={repo} /> | |
| 36 | + | <RepoNav repo={repo} active="code" user={user} /> | |
| 37 | + | <div class="breadcrumb"> | |
| 38 | + | <a href={`/${repo.name}/tree/${blobRef}`}>{repo.name}</a> | |
| 39 | + | {parts.map((part, i) => { | |
| 40 | + | const partPath = parts.slice(0, i + 1).join("/"); | |
| 41 | + | const isLast = i === parts.length - 1; | |
| 42 | + | return ( | |
| 43 | + | <> | |
| 44 | + | <span class="breadcrumb-sep">/</span> | |
| 45 | + | {isLast ? ( | |
| 46 | + | <span class="breadcrumb-current"> | |
| 47 | + | {part} | |
| 48 | + | </span> | |
| 49 | + | ) : ( | |
| 50 | + | <a | |
| 51 | + | href={`/${repo.name}/tree/${blobRef}/${partPath}`} | |
| 52 | + | > | |
| 53 | + | {part} | |
| 54 | + | </a> | |
| 55 | + | )} | |
| 56 | + | </> | |
| 57 | + | ); | |
| 58 | + | })} | |
| 59 | + | </div> | |
| 60 | + | <div class="file-blob-header"> | |
| 61 | + | <span class="file-blob-name">{filename}</span> | |
| 62 | + | <div class="file-blob-actions"> | |
| 63 | + | <BranchSelector | |
| 64 | + | repoName={repo.name} | |
| 65 | + | branches={branches} | |
| 66 | + | currentRef={blobRef} | |
| 67 | + | view="blob" | |
| 68 | + | path={filePath} | |
| 69 | + | /> | |
| 70 | + | <a | |
| 71 | + | href={`/${repo.name}/raw/${blobRef}/${filePath}`} | |
| 72 | + | class="btn btn-sm btn-ghost" | |
| 73 | + | > | |
| 74 | + | Raw | |
| 75 | + | </a> | |
| 76 | + | </div> | |
| 77 | + | </div> | |
| 78 | + | <div class="file-blob-body"> | |
| 79 | + | {view.type === "inline" ? ( | |
| 80 | + | <div class="shiki-wrapper">{view.html}</div> | |
| 81 | + | ) : view.type === "binary" ? ( | |
| 82 | + | <div class="file-download-notice"> | |
| 83 | + | <p>Binary file ({formatSize(view.size)})</p> | |
| 84 | + | <a | |
| 85 | + | href={`/${repo.name}/raw/${blobRef}/${filePath}`} | |
| 86 | + | class="btn btn-primary" | |
| 87 | + | > | |
| 88 | + | Download | |
| 89 | + | </a> | |
| 90 | + | </div> | |
| 91 | + | ) : ( | |
| 92 | + | <div class="file-download-notice"> | |
| 93 | + | <p> | |
| 94 | + | File too large to display inline ( | |
| 95 | + | {formatSize(view.size)}) | |
| 96 | + | </p> | |
| 97 | + | <a | |
| 98 | + | href={`/${repo.name}/raw/${blobRef}/${filePath}`} | |
| 99 | + | class="btn btn-primary" | |
| 100 | + | > | |
| 101 | + | Download | |
| 102 | + | </a> | |
| 103 | + | </div> | |
| 104 | + | )} | |
| 105 | + | </div> | |
| 106 | + | </div> | |
| 107 | + | </Layout> | |
| 108 | + | ); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | function formatSize(bytes: number): string { | |
| 112 | + | if (bytes < 1024) return `${bytes} B`; | |
| 113 | + | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| 114 | + | return `${(bytes / 1024 / 1024).toFixed(1)} MB`; | |
| 115 | + | } | |
Asrc/views/repos/FileTree.tsx
| @@ -0,0 +1,82 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import type { TreeEntry } from "../../services/git.ts"; | |
| 4 | + | import { Layout } from "../layout.tsx"; | |
| 5 | + | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| 6 | + | import { BranchSelector } from "./BranchSelector.tsx"; | |
| 7 | + | import { FileTreeTable } from "./FileTreeTable.tsx"; | |
| 8 | + | import { RepoNav } from "./RepoNav.tsx"; | |
| 9 | + | ||
| 10 | + | interface FileTreeProps { | |
| 11 | + | user: SessionUser | null; | |
| 12 | + | repo: RepositoryRow; | |
| 13 | + | ref: string; | |
| 14 | + | subpath: string; | |
| 15 | + | entries: TreeEntry[]; | |
| 16 | + | branches: string[]; | |
| 17 | + | readmeHtml?: string | null; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export function FileTree({ | |
| 21 | + | user, | |
| 22 | + | repo, | |
| 23 | + | ref: treeRef, | |
| 24 | + | subpath, | |
| 25 | + | entries, | |
| 26 | + | branches, | |
| 27 | + | readmeHtml, | |
| 28 | + | }: FileTreeProps) { | |
| 29 | + | const parts = subpath ? subpath.split("/") : []; | |
| 30 | + | return ( | |
| 31 | + | <Layout | |
| 32 | + | user={user} | |
| 33 | + | title={`${repo.name}${subpath ? `/${subpath}` : ""}`} | |
| 34 | + | > | |
| 35 | + | <div class="container"> | |
| 36 | + | <RepoHeader repo={repo} /> | |
| 37 | + | <RepoNav repo={repo} active="code" user={user} /> | |
| 38 | + | <div class="tree-toolbar"> | |
| 39 | + | <BranchSelector | |
| 40 | + | repoName={repo.name} | |
| 41 | + | branches={branches} | |
| 42 | + | currentRef={treeRef} | |
| 43 | + | view="tree" | |
| 44 | + | path={subpath} | |
| 45 | + | /> | |
| 46 | + | </div> | |
| 47 | + | {subpath && ( | |
| 48 | + | <div class="breadcrumb"> | |
| 49 | + | <a href={`/${repo.name}/tree/${treeRef}`}> | |
| 50 | + | {repo.name} | |
| 51 | + | </a> | |
| 52 | + | {parts.map((part, i) => { | |
| 53 | + | const partPath = parts.slice(0, i + 1).join("/"); | |
| 54 | + | return ( | |
| 55 | + | <> | |
| 56 | + | <span class="breadcrumb-sep">/</span> | |
| 57 | + | <a | |
| 58 | + | href={`/${repo.name}/tree/${treeRef}/${partPath}`} | |
| 59 | + | > | |
| 60 | + | {part} | |
| 61 | + | </a> | |
| 62 | + | </> | |
| 63 | + | ); | |
| 64 | + | })} | |
| 65 | + | </div> | |
| 66 | + | )} | |
| 67 | + | <FileTreeTable | |
| 68 | + | repoName={repo.name} | |
| 69 | + | treeRef={treeRef} | |
| 70 | + | subpath={subpath} | |
| 71 | + | entries={entries} | |
| 72 | + | /> | |
| 73 | + | {readmeHtml && ( | |
| 74 | + | <div class="readme-section"> | |
| 75 | + | <div class="readme-header">README</div> | |
| 76 | + | <div class="markdown-body">{readmeHtml}</div> | |
| 77 | + | </div> | |
| 78 | + | )} | |
| 79 | + | </div> | |
| 80 | + | </Layout> | |
| 81 | + | ); | |
| 82 | + | } | |
Asrc/views/repos/FileTreeTable.tsx
| @@ -0,0 +1,90 @@ | |||
|---|---|---|---|
| 1 | + | import type { TreeEntry } from "../../services/git.ts"; | |
| 2 | + | ||
| 3 | + | interface FileTreeTableProps { | |
| 4 | + | repoName: string; | |
| 5 | + | treeRef: string; | |
| 6 | + | subpath: string; | |
| 7 | + | entries: TreeEntry[]; | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | export function FileTreeTable({ | |
| 11 | + | repoName, | |
| 12 | + | treeRef, | |
| 13 | + | subpath, | |
| 14 | + | entries, | |
| 15 | + | }: FileTreeTableProps) { | |
| 16 | + | const parts = subpath ? subpath.split("/") : []; | |
| 17 | + | const parentPath = parts.slice(0, -1).join("/"); | |
| 18 | + | const parentHref = parentPath | |
| 19 | + | ? `/${repoName}/tree/${treeRef}/${parentPath}` | |
| 20 | + | : `/${repoName}/tree/${treeRef}`; | |
| 21 | + | ||
| 22 | + | const sorted = [...entries].sort((a, b) => { | |
| 23 | + | if (a.type !== b.type) return a.type === "tree" ? -1 : 1; | |
| 24 | + | return a.name.localeCompare(b.name); | |
| 25 | + | }); | |
| 26 | + | ||
| 27 | + | return ( | |
| 28 | + | <table class="file-tree"> | |
| 29 | + | <tbody> | |
| 30 | + | {subpath && ( | |
| 31 | + | <tr class="file-tree-row file-tree-row-up"> | |
| 32 | + | <td class="file-icon file-icon-dir">{DirIcon()}</td> | |
| 33 | + | <td class="file-name" colspan="2"> | |
| 34 | + | <a href={parentHref}>..</a> | |
| 35 | + | </td> | |
| 36 | + | </tr> | |
| 37 | + | )} | |
| 38 | + | {sorted.map((entry) => { | |
| 39 | + | const entryPath = subpath | |
| 40 | + | ? `${subpath}/${entry.name}` | |
| 41 | + | : entry.name; | |
| 42 | + | const href = | |
| 43 | + | entry.type === "tree" | |
| 44 | + | ? `/${repoName}/tree/${treeRef}/${entryPath}` | |
| 45 | + | : `/${repoName}/blob/${treeRef}/${entryPath}`; | |
| 46 | + | return ( | |
| 47 | + | <tr class="file-tree-row"> | |
| 48 | + | <td | |
| 49 | + | class={`file-icon ${entry.type === "tree" ? "file-icon-dir" : "file-icon-file"}`} | |
| 50 | + | > | |
| 51 | + | {entry.type === "tree" ? DirIcon() : FileIcon()} | |
| 52 | + | </td> | |
| 53 | + | <td class="file-name"> | |
| 54 | + | <a | |
| 55 | + | href={href} | |
| 56 | + | class={ | |
| 57 | + | entry.type === "tree" | |
| 58 | + | ? "file-name-dir" | |
| 59 | + | : undefined | |
| 60 | + | } | |
| 61 | + | > | |
| 62 | + | {entry.name} | |
| 63 | + | </a> | |
| 64 | + | </td> | |
| 65 | + | <td class="file-size"> | |
| 66 | + | {entry.type === "blob" && entry.size !== "-" | |
| 67 | + | ? formatSize(parseInt(entry.size, 10)) | |
| 68 | + | : ""} | |
| 69 | + | </td> | |
| 70 | + | </tr> | |
| 71 | + | ); | |
| 72 | + | })} | |
| 73 | + | </tbody> | |
| 74 | + | </table> | |
| 75 | + | ); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | function formatSize(bytes: number): string { | |
| 79 | + | if (bytes < 1024) return `${bytes} B`; | |
| 80 | + | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| 81 | + | return `${(bytes / 1024 / 1024).toFixed(1)} MB`; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | function DirIcon(): JSX.Element { | |
| 85 | + | return `<svg class="tree-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>`; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | function FileIcon(): JSX.Element { | |
| 89 | + | return `<svg class="tree-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>`; | |
| 90 | + | } | |
Asrc/views/repos/NewRepo.tsx
| @@ -0,0 +1,67 @@ | |||
|---|---|---|---|
| 1 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 2 | + | import { Layout } from "../layout.tsx"; | |
| 3 | + | ||
| 4 | + | interface NewRepoProps { | |
| 5 | + | user: SessionUser; | |
| 6 | + | error?: string; | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | export function NewRepo({ user, error }: NewRepoProps) { | |
| 10 | + | return ( | |
| 11 | + | <Layout user={user} title="New repository"> | |
| 12 | + | <div class="container container-narrow"> | |
| 13 | + | <h1 class="page-title">Create new repository</h1> | |
| 14 | + | {error && <p class="form-error">{error}</p>} | |
| 15 | + | <form method="POST" action="/new" class="form-card"> | |
| 16 | + | <div class="form-group"> | |
| 17 | + | <label for="name">Repository name</label> | |
| 18 | + | <input | |
| 19 | + | id="name" | |
| 20 | + | name="name" | |
| 21 | + | type="text" | |
| 22 | + | required | |
| 23 | + | pattern="[a-zA-Z0-9._-]+" | |
| 24 | + | title="Letters, numbers, dots, hyphens, underscores" | |
| 25 | + | placeholder="my-project" | |
| 26 | + | /> | |
| 27 | + | </div> | |
| 28 | + | <div class="form-group"> | |
| 29 | + | <label for="description"> | |
| 30 | + | Description{" "} | |
| 31 | + | <span class="text-muted">(optional)</span> | |
| 32 | + | </label> | |
| 33 | + | <input | |
| 34 | + | id="description" | |
| 35 | + | name="description" | |
| 36 | + | type="text" | |
| 37 | + | placeholder="A short description" | |
| 38 | + | /> | |
| 39 | + | </div> | |
| 40 | + | <div class="form-group"> | |
| 41 | + | <label for="default_branch">Default branch</label> | |
| 42 | + | <input | |
| 43 | + | id="default_branch" | |
| 44 | + | name="default_branch" | |
| 45 | + | type="text" | |
| 46 | + | value="main" | |
| 47 | + | placeholder="main" | |
| 48 | + | /> | |
| 49 | + | </div> | |
| 50 | + | <div class="form-group"> | |
| 51 | + | <label class="checkbox-label"> | |
| 52 | + | <input | |
| 53 | + | type="checkbox" | |
| 54 | + | name="is_private" | |
| 55 | + | value="1" | |
| 56 | + | /> | |
| 57 | + | Private repository | |
| 58 | + | </label> | |
| 59 | + | </div> | |
| 60 | + | <button type="submit" class="btn btn-primary"> | |
| 61 | + | Create repository | |
| 62 | + | </button> | |
| 63 | + | </form> | |
| 64 | + | </div> | |
| 65 | + | </Layout> | |
| 66 | + | ); | |
| 67 | + | } | |
Asrc/views/repos/RepoHeader.tsx
| @@ -0,0 +1,20 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | ||
| 3 | + | interface RepoHeaderProps { | |
| 4 | + | repo: RepositoryRow; | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | export function RepoHeader({ repo }: RepoHeaderProps) { | |
| 8 | + | return ( | |
| 9 | + | <div class="repo-header"> | |
| 10 | + | <div class="repo-title-row"> | |
| 11 | + | <h1 class="page-title"> | |
| 12 | + | <a href={`/${repo.name}`}>{repo.name}</a> | |
| 13 | + | </h1> | |
| 14 | + | {repo.is_private ? ( | |
| 15 | + | <span class="badge badge-private">Private</span> | |
| 16 | + | ) : null} | |
| 17 | + | </div> | |
| 18 | + | </div> | |
| 19 | + | ); | |
| 20 | + | } | |
Asrc/views/repos/RepoHome.tsx
| @@ -0,0 +1,112 @@ | |||
|---|---|---|---|
| 1 | + | import { BASE_URL, SSH_PORT } from "../../config.ts"; | |
| 2 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 3 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 4 | + | import type { TreeEntry } from "../../services/git.ts"; | |
| 5 | + | import { Layout } from "../layout.tsx"; | |
| 6 | + | import { BranchSelector } from "./BranchSelector.tsx"; | |
| 7 | + | import { FileTreeTable } from "./FileTreeTable.tsx"; | |
| 8 | + | import { RepoHeader } from "./RepoHeader.tsx"; | |
| 9 | + | import { RepoNav } from "./RepoNav.tsx"; | |
| 10 | + | ||
| 11 | + | interface RepoHomeProps { | |
| 12 | + | user: SessionUser | null; | |
| 13 | + | repo: RepositoryRow; | |
| 14 | + | entries: TreeEntry[]; | |
| 15 | + | readmeHtml: string | null; | |
| 16 | + | hasContent: boolean; | |
| 17 | + | branches: string[]; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export function RepoHome({ | |
| 21 | + | user, | |
| 22 | + | repo, | |
| 23 | + | entries, | |
| 24 | + | readmeHtml, | |
| 25 | + | hasContent, | |
| 26 | + | branches, | |
| 27 | + | }: RepoHomeProps) { | |
| 28 | + | return ( | |
| 29 | + | <Layout user={user} title={repo.name}> | |
| 30 | + | <div class="container"> | |
| 31 | + | <RepoHeader repo={repo} /> | |
| 32 | + | {repo.description && ( | |
| 33 | + | <p class="repo-description">{repo.description}</p> | |
| 34 | + | )} | |
| 35 | + | <RepoNav repo={repo} active="code" user={user} /> | |
| 36 | + | {!hasContent ? ( | |
| 37 | + | <div class="empty-state"> | |
| 38 | + | <h2>This repository is empty.</h2> | |
| 39 | + | <p>Push your first commit to get started:</p> | |
| 40 | + | <pre class="code-setup"> | |
| 41 | + | <code>{`git clone ${sshUrl(repo.name)} | |
| 42 | + | cd ${repo.name} | |
| 43 | + | echo "# ${repo.name}" > README.md | |
| 44 | + | git add . | |
| 45 | + | git commit -m "Initial commit" | |
| 46 | + | git push origin main`}</code> | |
| 47 | + | </pre> | |
| 48 | + | </div> | |
| 49 | + | ) : ( | |
| 50 | + | <> | |
| 51 | + | <div class="tree-toolbar"> | |
| 52 | + | <BranchSelector | |
| 53 | + | repoName={repo.name} | |
| 54 | + | branches={branches} | |
| 55 | + | currentRef={repo.default_branch} | |
| 56 | + | view="tree" | |
| 57 | + | /> | |
| 58 | + | <details class="clone-popup-details"> | |
| 59 | + | <summary class="btn btn-sm btn-primary"> | |
| 60 | + | Clone | |
| 61 | + | </summary> | |
| 62 | + | <div class="clone-popup"> | |
| 63 | + | <div class="clone-url-row"> | |
| 64 | + | <span class="clone-url-label"> | |
| 65 | + | HTTP | |
| 66 | + | </span> | |
| 67 | + | <input | |
| 68 | + | type="text" | |
| 69 | + | readonly | |
| 70 | + | value={httpUrl(repo.name)} | |
| 71 | + | class="clone-url-input" | |
| 72 | + | /> | |
| 73 | + | </div> | |
| 74 | + | <div class="clone-url-row"> | |
| 75 | + | <span class="clone-url-label">SSH</span> | |
| 76 | + | <input | |
| 77 | + | type="text" | |
| 78 | + | readonly | |
| 79 | + | value={sshUrl(repo.name)} | |
| 80 | + | class="clone-url-input" | |
| 81 | + | /> | |
| 82 | + | </div> | |
| 83 | + | </div> | |
| 84 | + | </details> | |
| 85 | + | </div> | |
| 86 | + | <FileTreeTable | |
| 87 | + | repoName={repo.name} | |
| 88 | + | treeRef={repo.default_branch} | |
| 89 | + | subpath="" | |
| 90 | + | entries={entries} | |
| 91 | + | /> | |
| 92 | + | {readmeHtml && ( | |
| 93 | + | <div class="readme-section"> | |
| 94 | + | <div class="readme-header">README</div> | |
| 95 | + | <div class="markdown-body">{readmeHtml}</div> | |
| 96 | + | </div> | |
| 97 | + | )} | |
| 98 | + | </> | |
| 99 | + | )} | |
| 100 | + | </div> | |
| 101 | + | </Layout> | |
| 102 | + | ); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | function httpUrl(name: string) { | |
| 106 | + | return `${BASE_URL}/${name}.git`; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | function sshUrl(name: string) { | |
| 110 | + | const host = new URL(BASE_URL).hostname; | |
| 111 | + | return `ssh://git@${host}:${SSH_PORT}/${name}.git`; | |
| 112 | + | } | |
Asrc/views/repos/RepoList.tsx
| @@ -0,0 +1,99 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { Layout } from "../layout.tsx"; | |
| 4 | + | import { Pagination, type PaginationInfo } from "../Pagination.tsx"; | |
| 5 | + | ||
| 6 | + | interface RepoListProps { | |
| 7 | + | user: SessionUser | null; | |
| 8 | + | repos: RepositoryRow[]; | |
| 9 | + | search?: string; | |
| 10 | + | pagination: PaginationInfo; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | export function RepoList({ user, repos, search, pagination }: RepoListProps) { | |
| 14 | + | return ( | |
| 15 | + | <Layout user={user} title="Repositories"> | |
| 16 | + | <div class="container"> | |
| 17 | + | <div class="page-header"> | |
| 18 | + | <h1 class="page-title">Repositories</h1> | |
| 19 | + | {user?.isAdmin && ( | |
| 20 | + | <a href="/new" class="btn btn-primary"> | |
| 21 | + | New repository | |
| 22 | + | </a> | |
| 23 | + | )} | |
| 24 | + | </div> | |
| 25 | + | <form method="GET" action="/" class="search-form"> | |
| 26 | + | <div class="search-input-wrap"> | |
| 27 | + | <input | |
| 28 | + | type="search" | |
| 29 | + | name="q" | |
| 30 | + | value={search ?? ""} | |
| 31 | + | placeholder="Search repositories…" | |
| 32 | + | class="search-input" | |
| 33 | + | autocomplete="off" | |
| 34 | + | /> | |
| 35 | + | <button type="submit" class="btn btn-ghost btn-sm"> | |
| 36 | + | Search | |
| 37 | + | </button> | |
| 38 | + | </div> | |
| 39 | + | </form> | |
| 40 | + | {repos.length === 0 ? ( | |
| 41 | + | <div class="empty-state"> | |
| 42 | + | {search ? ( | |
| 43 | + | <p> | |
| 44 | + | No repositories match <strong>{search}</strong>. | |
| 45 | + | </p> | |
| 46 | + | ) : ( | |
| 47 | + | <> | |
| 48 | + | <p>No repositories yet.</p> | |
| 49 | + | {user?.isAdmin && ( | |
| 50 | + | <a href="/new" class="btn btn-primary"> | |
| 51 | + | Create your first repository | |
| 52 | + | </a> | |
| 53 | + | )} | |
| 54 | + | </> | |
| 55 | + | )} | |
| 56 | + | </div> | |
| 57 | + | ) : ( | |
| 58 | + | <ul class="repo-list"> | |
| 59 | + | {repos.map((repo) => ( | |
| 60 | + | <li class="repo-card"> | |
| 61 | + | <div class="repo-card-main"> | |
| 62 | + | <div class="repo-card-title"> | |
| 63 | + | <a | |
| 64 | + | href={`/${repo.name}`} | |
| 65 | + | class="repo-name" | |
| 66 | + | > | |
| 67 | + | {repo.name} | |
| 68 | + | </a> | |
| 69 | + | {repo.is_private ? ( | |
| 70 | + | <span class="badge badge-private"> | |
| 71 | + | Private | |
| 72 | + | </span> | |
| 73 | + | ) : null} | |
| 74 | + | </div> | |
| 75 | + | {repo.description && ( | |
| 76 | + | <p class="repo-description"> | |
| 77 | + | {repo.description} | |
| 78 | + | </p> | |
| 79 | + | )} | |
| 80 | + | </div> | |
| 81 | + | <div class="repo-card-meta"> | |
| 82 | + | <time | |
| 83 | + | class="repo-date" | |
| 84 | + | datetime={repo.created_at} | |
| 85 | + | > | |
| 86 | + | {new Date( | |
| 87 | + | repo.created_at, | |
| 88 | + | ).toLocaleDateString()} | |
| 89 | + | </time> | |
| 90 | + | </div> | |
| 91 | + | </li> | |
| 92 | + | ))} | |
| 93 | + | </ul> | |
| 94 | + | )} | |
| 95 | + | <Pagination {...pagination} /> | |
| 96 | + | </div> | |
| 97 | + | </Layout> | |
| 98 | + | ); | |
| 99 | + | } | |
Asrc/views/repos/RepoSettings.tsx
| @@ -0,0 +1,123 @@ | |||
|---|---|---|---|
| 1 | + | import type { RepositoryRow } from "../../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | + | import { Layout } from "../layout.tsx"; | |
| 4 | + | import { RepoHeader } from "./RepoHeader.tsx"; | |
| 5 | + | import { RepoNav } from "./RepoNav.tsx"; | |
| 6 | + | ||
| 7 | + | interface RepoSettingsProps { | |
| 8 | + | user: SessionUser; | |
| 9 | + | repo: RepositoryRow; | |
| 10 | + | branches: string[]; | |
| 11 | + | success?: string; | |
| 12 | + | error?: string; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | export function RepoSettings({ | |
| 16 | + | user, | |
| 17 | + | repo, | |
| 18 | + | branches, | |
| 19 | + | success, | |
| 20 | + | error, | |
| 21 | + | }: RepoSettingsProps) { | |
| 22 | + | return ( | |
| 23 | + | <Layout user={user} title={`Settings — ${repo.name}`}> | |
| 24 | + | <div class="container container-narrow"> | |
| 25 | + | <RepoHeader repo={repo} /> | |
| 26 | + | <RepoNav repo={repo} active="settings" user={user} /> | |
| 27 | + | {success && <p class="form-success">{success}</p>} | |
| 28 | + | {error && <p class="form-error">{error}</p>} | |
| 29 | + | <form | |
| 30 | + | method="POST" | |
| 31 | + | action={`/${repo.name}/settings`} | |
| 32 | + | class="form-card" | |
| 33 | + | > | |
| 34 | + | <div class="form-group"> | |
| 35 | + | <label for="description">Description</label> | |
| 36 | + | <input | |
| 37 | + | id="description" | |
| 38 | + | name="description" | |
| 39 | + | type="text" | |
| 40 | + | value={repo.description ?? ""} | |
| 41 | + | placeholder="A short description" | |
| 42 | + | /> | |
| 43 | + | </div> | |
| 44 | + | <div class="form-group"> | |
| 45 | + | <label for="default_branch">Default branch</label> | |
| 46 | + | {branches.length > 0 ? ( | |
| 47 | + | <select | |
| 48 | + | id="default_branch" | |
| 49 | + | name="default_branch" | |
| 50 | + | class="branch-select" | |
| 51 | + | > | |
| 52 | + | {branches.map((b) => ( | |
| 53 | + | <option | |
| 54 | + | value={b} | |
| 55 | + | selected={ | |
| 56 | + | b === repo.default_branch | |
| 57 | + | ? true | |
| 58 | + | : undefined | |
| 59 | + | } | |
| 60 | + | > | |
| 61 | + | {b} | |
| 62 | + | </option> | |
| 63 | + | ))} | |
| 64 | + | </select> | |
| 65 | + | ) : ( | |
| 66 | + | <p class="form-hint"> | |
| 67 | + | No branches yet — push your first commit to set | |
| 68 | + | the default branch. | |
| 69 | + | </p> | |
| 70 | + | )} | |
| 71 | + | </div> | |
| 72 | + | <div class="form-group"> | |
| 73 | + | <label class="checkbox-label"> | |
| 74 | + | <input | |
| 75 | + | type="checkbox" | |
| 76 | + | name="is_private" | |
| 77 | + | value="1" | |
| 78 | + | checked={repo.is_private === 1} | |
| 79 | + | /> | |
| 80 | + | Private repository | |
| 81 | + | </label> | |
| 82 | + | </div> | |
| 83 | + | <button type="submit" class="btn btn-primary"> | |
| 84 | + | Save settings | |
| 85 | + | </button> | |
| 86 | + | </form> | |
| 87 | + | <div class="danger-zone"> | |
| 88 | + | <h2 class="section-title danger-title">Danger zone</h2> | |
| 89 | + | <div class="form-card danger-card"> | |
| 90 | + | <div class="danger-item"> | |
| 91 | + | <div> | |
| 92 | + | <strong>Delete this repository</strong> | |
| 93 | + | <p class="text-muted"> | |
| 94 | + | Once deleted, there is no going back. | |
| 95 | + | </p> | |
| 96 | + | </div> | |
| 97 | + | <details class="confirm-details"> | |
| 98 | + | <summary class="btn btn-danger"> | |
| 99 | + | Delete repository | |
| 100 | + | </summary> | |
| 101 | + | <div class="confirm-popup"> | |
| 102 | + | Delete {repo.name}? This cannot be undone. | |
| 103 | + | <form | |
| 104 | + | method="POST" | |
| 105 | + | action={`/${repo.name}/settings/delete`} | |
| 106 | + | class="inline-form" | |
| 107 | + | > | |
| 108 | + | <button | |
| 109 | + | type="submit" | |
| 110 | + | class="btn btn-danger" | |
| 111 | + | > | |
| 112 | + | Yes, delete | |
| 113 | + | </button> | |
| 114 | + | </form> | |
| 115 | + | </div> | |
| 116 | + | </details> | |
| 117 | + | </div> | |
| 118 | + | </div> | |
| 119 | + | </div> | |
| 120 | + | </div> | |
| 121 | + | </Layout> | |
| 122 | + | ); | |
| 123 | + | } | |
Asrc/workers/highlight.worker.ts
| @@ -0,0 +1,26 @@ | |||
|---|---|---|---|
| 1 | + | import { highlightFile } from "../services/diffHighlight.ts"; | |
| 2 | + | import { highlightStartup, serveFile } from "../services/highlight.ts"; | |
| 3 | + | ||
| 4 | + | declare const self: { | |
| 5 | + | postMessage(message: unknown): void; | |
| 6 | + | onmessage: ((event: MessageEvent) => void) | null; | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | await highlightStartup(); | |
| 10 | + | self.postMessage({ type: "ready" }); | |
| 11 | + | ||
| 12 | + | self.onmessage = async (event: MessageEvent) => { | |
| 13 | + | const { id, type, ...data } = event.data; | |
| 14 | + | try { | |
| 15 | + | let result: unknown; | |
| 16 | + | if (type === "serveFile") { | |
| 17 | + | const content = Buffer.from(data.content as ArrayBuffer); | |
| 18 | + | result = await serveFile(content, data.filename, data.cacheKey); | |
| 19 | + | } else if (type === "highlightFile") { | |
| 20 | + | result = await highlightFile(data.file); | |
| 21 | + | } | |
| 22 | + | self.postMessage({ id, result }); | |
| 23 | + | } catch (err) { | |
| 24 | + | self.postMessage({ id, error: String(err) }); | |
| 25 | + | } | |
| 26 | + | }; | |
Atests/e2e.test.ts
| @@ -0,0 +1,2036 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; | |
| 2 | + | import { chromium } from 'playwright'; | |
| 3 | + | import type { Browser, BrowserContext } from 'playwright'; | |
| 4 | + | import { $ } from 'bun'; | |
| 5 | + | import { | |
| 6 | + | BASE, | |
| 7 | + | ADMIN_PASS, | |
| 8 | + | setupTestEnv, | |
| 9 | + | spawnServer, | |
| 10 | + | waitForServer, | |
| 11 | + | login, | |
| 12 | + | logout, | |
| 13 | + | seedRepo, | |
| 14 | + | seedBranch, | |
| 15 | + | seedSubdir, | |
| 16 | + | writeTempFile, | |
| 17 | + | getHeadCommit, | |
| 18 | + | PORT, | |
| 19 | + | } from './helpers.ts'; | |
| 20 | + | ||
| 21 | + | // ─── Global setup ──────────────────────────────────────────────────────────── | |
| 22 | + | ||
| 23 | + | let browser: Browser; | |
| 24 | + | let server: ReturnType<typeof spawnServer>; | |
| 25 | + | ||
| 26 | + | beforeAll(async () => { | |
| 27 | + | await setupTestEnv(); | |
| 28 | + | server = spawnServer(); | |
| 29 | + | await waitForServer(); | |
| 30 | + | browser = await chromium.launch(); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | afterAll(async () => { | |
| 34 | + | await browser.close(); | |
| 35 | + | server.kill(); | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | // Helper: create a context already logged in as a given user. | |
| 39 | + | async function loggedInContext(username = 'admin', password = ADMIN_PASS) { | |
| 40 | + | const ctx = await browser.newContext(); | |
| 41 | + | const page = await ctx.newPage(); | |
| 42 | + | await login(page, username, password); | |
| 43 | + | await page.close(); | |
| 44 | + | return ctx; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Helper: create N issues in a repo using the server API (no browser rendering) | |
| 48 | + | async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) { | |
| 49 | + | for (let i = 1; i <= count; i++) { | |
| 50 | + | await ctx.request.fetch(`${BASE}/${repo}/issues`, { | |
| 51 | + | method: 'POST', | |
| 52 | + | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 53 | + | data: `title=Issue+number+${i}&body=`, | |
| 54 | + | maxRedirects: 0, | |
| 55 | + | }).catch(() => {}); // 302 redirect throws; that's fine | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Helper: create N patches in a repo using the server API | |
| 60 | + | async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) { | |
| 61 | + | const VALID_PATCH = [ | |
| 62 | + | 'diff --git a/f.txt b/f.txt', | |
| 63 | + | 'new file mode 100644', | |
| 64 | + | 'index 0000000..9daeafb', | |
| 65 | + | '--- /dev/null', | |
| 66 | + | '+++ b/f.txt', | |
| 67 | + | '@@ -0,0 +1 @@', | |
| 68 | + | '+x', | |
| 69 | + | '', | |
| 70 | + | ].join('\n'); | |
| 71 | + | ||
| 72 | + | writeTempFile('/tmp/bulk.patch', VALID_PATCH); | |
| 73 | + | for (let i = 1; i <= count; i++) { | |
| 74 | + | const page = await ctx.newPage(); | |
| 75 | + | try { | |
| 76 | + | await page.goto(`${BASE}/${repo}/patches/new`); | |
| 77 | + | await page.fill('[name=title]', `Patch number ${i}`); | |
| 78 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch'); | |
| 79 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 80 | + | await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`)); | |
| 81 | + | } finally { await page.close(); } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // ─── Auth ───────────────────────────────────────────────────────────────────── | |
| 86 | + | ||
| 87 | + | describe('auth', () => { | |
| 88 | + | test('homepage loads', async () => { | |
| 89 | + | const ctx = await browser.newContext(); | |
| 90 | + | const page = await ctx.newPage(); | |
| 91 | + | try { | |
| 92 | + | await page.goto(BASE); | |
| 93 | + | expect(await page.title()).toContain('Hearthforge'); | |
| 94 | + | } finally { await ctx.close(); } | |
| 95 | + | }); | |
| 96 | + | ||
| 97 | + | test('wrong password shows error', async () => { | |
| 98 | + | const ctx = await browser.newContext(); | |
| 99 | + | const page = await ctx.newPage(); | |
| 100 | + | try { | |
| 101 | + | await page.goto(`${BASE}/login`); | |
| 102 | + | await page.fill('[name=username]', 'admin'); | |
| 103 | + | await page.fill('[name=password]', 'wrongpassword'); | |
| 104 | + | await page.click('button[type=submit]'); | |
| 105 | + | expect(await page.locator('.form-error').textContent()).toContain('Invalid'); | |
| 106 | + | } finally { await ctx.close(); } | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | test('correct credentials redirect to homepage', async () => { | |
| 110 | + | const ctx = await browser.newContext(); | |
| 111 | + | const page = await ctx.newPage(); | |
| 112 | + | try { | |
| 113 | + | await login(page); | |
| 114 | + | expect(page.url()).toBe(BASE + '/'); | |
| 115 | + | expect(await page.locator('.nav-user').isVisible()).toBe(true); | |
| 116 | + | } finally { await ctx.close(); } | |
| 117 | + | }); | |
| 118 | + | ||
| 119 | + | test('register new user', async () => { | |
| 120 | + | const ctx = await browser.newContext(); | |
| 121 | + | const page = await ctx.newPage(); | |
| 122 | + | try { | |
| 123 | + | await page.goto(`${BASE}/register`); | |
| 124 | + | await page.fill('[name=username]', 'alice'); | |
| 125 | + | await page.fill('[name=password]', 'password123'); | |
| 126 | + | await page.fill('[name=password2]', 'password123'); | |
| 127 | + | await page.click('button[type=submit]'); | |
| 128 | + | await page.waitForURL(BASE + '/'); | |
| 129 | + | expect(await page.locator('.nav-user').textContent()).toBe('alice'); | |
| 130 | + | } finally { await ctx.close(); } | |
| 131 | + | }); | |
| 132 | + | ||
| 133 | + | test('register with mismatched passwords shows error', async () => { | |
| 134 | + | const ctx = await browser.newContext(); | |
| 135 | + | const page = await ctx.newPage(); | |
| 136 | + | try { | |
| 137 | + | await page.goto(`${BASE}/register`); | |
| 138 | + | await page.fill('[name=username]', 'bob'); | |
| 139 | + | await page.fill('[name=password]', 'password123'); | |
| 140 | + | await page.fill('[name=password2]', 'different456'); | |
| 141 | + | await page.click('button[type=submit]'); | |
| 142 | + | expect(await page.locator('.form-error').textContent()).toContain('match'); | |
| 143 | + | } finally { await ctx.close(); } | |
| 144 | + | }); | |
| 145 | + | ||
| 146 | + | test('register with duplicate username shows error', async () => { | |
| 147 | + | const ctx = await browser.newContext(); | |
| 148 | + | const page = await ctx.newPage(); | |
| 149 | + | try { | |
| 150 | + | await page.goto(`${BASE}/register`); | |
| 151 | + | await page.fill('[name=username]', 'alice'); // already registered above | |
| 152 | + | await page.fill('[name=password]', 'password123'); | |
| 153 | + | await page.fill('[name=password2]', 'password123'); | |
| 154 | + | await page.click('button[type=submit]'); | |
| 155 | + | expect(await page.locator('.form-error').textContent()).toContain('taken'); | |
| 156 | + | } finally { await ctx.close(); } | |
| 157 | + | }); | |
| 158 | + | ||
| 159 | + | test('logout clears session', async () => { | |
| 160 | + | const ctx = await browser.newContext(); | |
| 161 | + | const page = await ctx.newPage(); | |
| 162 | + | try { | |
| 163 | + | await login(page); | |
| 164 | + | await logout(page); | |
| 165 | + | expect(await page.locator('.nav-user').count()).toBe(0); | |
| 166 | + | expect(await page.locator('a[href="/login"]').isVisible()).toBe(true); | |
| 167 | + | } finally { await ctx.close(); } | |
| 168 | + | }); | |
| 169 | + | }); | |
| 170 | + | ||
| 171 | + | // ─── Repos ──────────────────────────────────────────────────────────────────── | |
| 172 | + | ||
| 173 | + | describe('repos', () => { | |
| 174 | + | // Shared admin context — cookies persist across tests in this block. | |
| 175 | + | let adminCtx: BrowserContext; | |
| 176 | + | // Alice's context, created after alice is registered in auth tests. | |
| 177 | + | let aliceCtx: BrowserContext; | |
| 178 | + | // Set after 'commit log' test; used by all commit-detail tests below. | |
| 179 | + | let commitUrl: string; | |
| 180 | + | ||
| 181 | + | beforeAll(async () => { | |
| 182 | + | adminCtx = await loggedInContext(); | |
| 183 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 184 | + | }); | |
| 185 | + | ||
| 186 | + | afterAll(async () => { | |
| 187 | + | await adminCtx.close(); | |
| 188 | + | await aliceCtx.close(); | |
| 189 | + | }); | |
| 190 | + | ||
| 191 | + | test('non-admin gets 403 on /new', async () => { | |
| 192 | + | const page = await aliceCtx.newPage(); | |
| 193 | + | try { | |
| 194 | + | const resp = await page.request.get(`${BASE}/new`); | |
| 195 | + | expect(resp.status()).toBe(403); | |
| 196 | + | } finally { await page.close(); } | |
| 197 | + | }); | |
| 198 | + | ||
| 199 | + | test('create repository', async () => { | |
| 200 | + | const page = await adminCtx.newPage(); | |
| 201 | + | try { | |
| 202 | + | await page.goto(`${BASE}/new`); | |
| 203 | + | await page.fill('[name=name]', 'my-repo'); | |
| 204 | + | await page.fill('[name=description]', 'A test repo'); | |
| 205 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 206 | + | await page.waitForURL(`${BASE}/my-repo`); | |
| 207 | + | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 208 | + | } finally { await page.close(); } | |
| 209 | + | }); | |
| 210 | + | ||
| 211 | + | test('repository appears in list', async () => { | |
| 212 | + | const page = await adminCtx.newPage(); | |
| 213 | + | try { | |
| 214 | + | await page.goto(BASE); | |
| 215 | + | expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); | |
| 216 | + | } finally { await page.close(); } | |
| 217 | + | }); | |
| 218 | + | ||
| 219 | + | test('search finds matching repo', async () => { | |
| 220 | + | const page = await adminCtx.newPage(); | |
| 221 | + | try { | |
| 222 | + | await page.goto(BASE); | |
| 223 | + | await page.fill('[name=q]', 'my-repo'); | |
| 224 | + | await page.click('.search-form button[type=submit]'); | |
| 225 | + | expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo'); | |
| 226 | + | } finally { await page.close(); } | |
| 227 | + | }); | |
| 228 | + | ||
| 229 | + | test('search returns empty for unknown term', async () => { | |
| 230 | + | const page = await adminCtx.newPage(); | |
| 231 | + | try { | |
| 232 | + | await page.goto(BASE); | |
| 233 | + | await page.fill('[name=q]', 'zzz-nothing-here'); | |
| 234 | + | await page.click('.search-form button[type=submit]'); | |
| 235 | + | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 236 | + | } finally { await page.close(); } | |
| 237 | + | }); | |
| 238 | + | ||
| 239 | + | test('browse file tree after seeding content', async () => { | |
| 240 | + | await seedRepo('my-repo'); | |
| 241 | + | const page = await adminCtx.newPage(); | |
| 242 | + | try { | |
| 243 | + | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 244 | + | const files = await page.locator('.file-name a').allTextContents(); | |
| 245 | + | expect(files).toContain('README.md'); | |
| 246 | + | expect(files).toContain('index.js'); | |
| 247 | + | } finally { await page.close(); } | |
| 248 | + | }); | |
| 249 | + | ||
| 250 | + | test('view file blob with syntax highlighting', async () => { | |
| 251 | + | const page = await adminCtx.newPage(); | |
| 252 | + | try { | |
| 253 | + | await page.goto(`${BASE}/my-repo/blob/main/index.js`); | |
| 254 | + | expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); | |
| 255 | + | expect(await page.locator('.file-blob-body').isVisible()).toBe(true); | |
| 256 | + | } finally { await page.close(); } | |
| 257 | + | }); | |
| 258 | + | ||
| 259 | + | test('raw file download responds 200', async () => { | |
| 260 | + | const page = await adminCtx.newPage(); | |
| 261 | + | try { | |
| 262 | + | const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`); | |
| 263 | + | expect(resp.status()).toBe(200); | |
| 264 | + | expect(resp.headers()['content-disposition']).toContain('README.md'); | |
| 265 | + | } finally { await page.close(); } | |
| 266 | + | }); | |
| 267 | + | ||
| 268 | + | test('commit log shows initial commit', async () => { | |
| 269 | + | const page = await adminCtx.newPage(); | |
| 270 | + | try { | |
| 271 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 272 | + | const subjects = await page.locator('.commit-subject').allTextContents(); | |
| 273 | + | expect(subjects.some(s => s.includes('Initial commit'))).toBe(true); | |
| 274 | + | // Navigate to the commit page and capture the URL for subsequent tests | |
| 275 | + | await page.locator('.commit-hash').first().click(); | |
| 276 | + | await page.waitForURL(/\/my-repo\/commit\//); | |
| 277 | + | commitUrl = page.url(); | |
| 278 | + | } finally { await page.close(); } | |
| 279 | + | }); | |
| 280 | + | ||
| 281 | + | test('commit detail shows metadata card', async () => { | |
| 282 | + | const page = await adminCtx.newPage(); | |
| 283 | + | try { | |
| 284 | + | await page.goto(commitUrl); | |
| 285 | + | expect(await page.locator('.commit-card').isVisible()).toBe(true); | |
| 286 | + | expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit'); | |
| 287 | + | // Author, date and SHA rows are all present | |
| 288 | + | const metaText = await page.locator('.commit-card-meta').textContent(); | |
| 289 | + | expect(metaText).toContain('Test'); // author name set by seedRepo | |
| 290 | + | expect(metaText).toContain('Author'); // label (CSS uppercases visually) | |
| 291 | + | expect(metaText).toContain('Date'); | |
| 292 | + | expect(metaText).toContain('Commit'); | |
| 293 | + | } finally { await page.close(); } | |
| 294 | + | }); | |
| 295 | + | ||
| 296 | + | test('commit detail full SHA is shown', async () => { | |
| 297 | + | const page = await adminCtx.newPage(); | |
| 298 | + | try { | |
| 299 | + | await page.goto(commitUrl); | |
| 300 | + | const sha = commitUrl.split('/commit/')[1] ?? null; | |
| 301 | + | expect(await page.locator('.commit-sha-full').textContent()).toBe(sha); | |
| 302 | + | } finally { await page.close(); } | |
| 303 | + | }); | |
| 304 | + | ||
| 305 | + | test('commit detail shows file nav sidebar', async () => { | |
| 306 | + | const page = await adminCtx.newPage(); | |
| 307 | + | try { | |
| 308 | + | await page.goto(commitUrl); | |
| 309 | + | expect(await page.locator('.file-nav-details').isVisible()).toBe(true); | |
| 310 | + | const navItems = await page.locator('.file-nav-item').allTextContents(); | |
| 311 | + | // seedRepo adds README.md and index.js | |
| 312 | + | expect(navItems.some(t => t.includes('README.md'))).toBe(true); | |
| 313 | + | expect(navItems.some(t => t.includes('index.js'))).toBe(true); | |
| 314 | + | } finally { await page.close(); } | |
| 315 | + | }); | |
| 316 | + | ||
| 317 | + | test('commit detail file nav items are anchor links to diff sections', async () => { | |
| 318 | + | const page = await adminCtx.newPage(); | |
| 319 | + | try { | |
| 320 | + | await page.goto(commitUrl); | |
| 321 | + | const hrefs = await page.locator('.file-nav-item').evaluateAll( | |
| 322 | + | els => els.map(el => el.getAttribute('href') ?? ''), | |
| 323 | + | ); | |
| 324 | + | expect(hrefs.every(h => h.startsWith('#'))).toBe(true); | |
| 325 | + | } finally { await page.close(); } | |
| 326 | + | }); | |
| 327 | + | ||
| 328 | + | test('commit detail shows diff table with added lines', async () => { | |
| 329 | + | const page = await adminCtx.newPage(); | |
| 330 | + | try { | |
| 331 | + | await page.goto(commitUrl); | |
| 332 | + | // Initial commit only adds lines | |
| 333 | + | expect(await page.locator('.diff-table').first().isVisible()).toBe(true); | |
| 334 | + | expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); | |
| 335 | + | expect(await page.locator('.diff-row-del').count()).toBe(0); | |
| 336 | + | } finally { await page.close(); } | |
| 337 | + | }); | |
| 338 | + | ||
| 339 | + | test('commit detail diff table has line numbers', async () => { | |
| 340 | + | const page = await adminCtx.newPage(); | |
| 341 | + | try { | |
| 342 | + | await page.goto(commitUrl); | |
| 343 | + | // New-side line numbers (column 2) on add rows start at 1 | |
| 344 | + | const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent(); | |
| 345 | + | expect(firstNewLn?.trim()).toBe('1'); | |
| 346 | + | } finally { await page.close(); } | |
| 347 | + | }); | |
| 348 | + | ||
| 349 | + | test('commit detail shows added stats on file header', async () => { | |
| 350 | + | const page = await adminCtx.newPage(); | |
| 351 | + | try { | |
| 352 | + | await page.goto(commitUrl); | |
| 353 | + | const addStats = await page.locator('.diff-stat-add').allTextContents(); | |
| 354 | + | expect(addStats.length).toBeGreaterThan(0); | |
| 355 | + | expect(addStats.every(s => s.startsWith('+'))).toBe(true); | |
| 356 | + | } finally { await page.close(); } | |
| 357 | + | }); | |
| 358 | + | ||
| 359 | + | test('commit detail view-at-sha button links to blob at that commit', async () => { | |
| 360 | + | const page = await adminCtx.newPage(); | |
| 361 | + | try { | |
| 362 | + | await page.goto(commitUrl); | |
| 363 | + | const sha = commitUrl.split('/commit/')[1]; | |
| 364 | + | const btn = page.locator('.btn-xs').first(); | |
| 365 | + | const href = await btn.getAttribute('href'); | |
| 366 | + | expect(href).toContain(`/blob/${sha}/`); | |
| 367 | + | } finally { await page.close(); } | |
| 368 | + | }); | |
| 369 | + | ||
| 370 | + | test('commit detail view-at-branch button links to blob at default branch', async () => { | |
| 371 | + | const page = await adminCtx.newPage(); | |
| 372 | + | try { | |
| 373 | + | await page.goto(commitUrl); | |
| 374 | + | const btns = await page.locator('.btn-xs').allTextContents(); | |
| 375 | + | expect(btns.some(t => t.includes('@ main'))).toBe(true); | |
| 376 | + | const branchBtns = await page.locator('.btn-xs').evaluateAll( | |
| 377 | + | els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''), | |
| 378 | + | ); | |
| 379 | + | expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true); | |
| 380 | + | } finally { await page.close(); } | |
| 381 | + | }); | |
| 382 | + | ||
| 383 | + | test('commit detail file diff can be collapsed', async () => { | |
| 384 | + | const page = await adminCtx.newPage(); | |
| 385 | + | try { | |
| 386 | + | await page.goto(commitUrl); | |
| 387 | + | // File body is visible when details is open | |
| 388 | + | const diffFile = page.locator('.diff-file').first(); | |
| 389 | + | expect(await diffFile.getAttribute('open')).not.toBeNull(); | |
| 390 | + | // Click the summary to collapse | |
| 391 | + | await diffFile.locator('.diff-file-header').click(); | |
| 392 | + | expect(await diffFile.getAttribute('open')).toBeNull(); | |
| 393 | + | } finally { await page.close(); } | |
| 394 | + | }); | |
| 395 | + | ||
| 396 | + | test('commit detail file nav sidebar can be collapsed', async () => { | |
| 397 | + | const page = await adminCtx.newPage(); | |
| 398 | + | try { | |
| 399 | + | await page.goto(commitUrl); | |
| 400 | + | const nav = page.locator('.file-nav-details'); | |
| 401 | + | expect(await nav.getAttribute('open')).not.toBeNull(); | |
| 402 | + | await nav.locator('.file-nav-toggle').click(); | |
| 403 | + | expect(await nav.getAttribute('open')).toBeNull(); | |
| 404 | + | } finally { await page.close(); } | |
| 405 | + | }); | |
| 406 | + | ||
| 407 | + | test('readme renders on repo home', async () => { | |
| 408 | + | const page = await adminCtx.newPage(); | |
| 409 | + | try { | |
| 410 | + | await page.goto(`${BASE}/my-repo`); | |
| 411 | + | expect(await page.locator('.readme-header').isVisible()).toBe(true); | |
| 412 | + | expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo'); | |
| 413 | + | } finally { await page.close(); } | |
| 414 | + | }); | |
| 415 | + | ||
| 416 | + | test('private repo hidden from other users', async () => { | |
| 417 | + | // Make private | |
| 418 | + | const adminPage = await adminCtx.newPage(); | |
| 419 | + | try { | |
| 420 | + | await adminPage.goto(`${BASE}/my-repo/settings`); | |
| 421 | + | await adminPage.check('[name=is_private]'); | |
| 422 | + | await adminPage.click('form[action$="/settings"] button[type=submit]'); | |
| 423 | + | expect(await adminPage.locator('.form-success').isVisible()).toBe(true); | |
| 424 | + | } finally { await adminPage.close(); } | |
| 425 | + | ||
| 426 | + | // Alice should get 404 | |
| 427 | + | const alicePage = await aliceCtx.newPage(); | |
| 428 | + | try { | |
| 429 | + | const resp = await alicePage.request.get(`${BASE}/my-repo`); | |
| 430 | + | expect(resp.status()).toBe(404); | |
| 431 | + | await alicePage.goto(BASE); | |
| 432 | + | expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo'); | |
| 433 | + | } finally { await alicePage.close(); } | |
| 434 | + | ||
| 435 | + | // Restore to public | |
| 436 | + | const adminPage2 = await adminCtx.newPage(); | |
| 437 | + | try { | |
| 438 | + | await adminPage2.goto(`${BASE}/my-repo/settings`); | |
| 439 | + | await adminPage2.uncheck('[name=is_private]'); | |
| 440 | + | await adminPage2.click('form[action$="/settings"] button[type=submit]'); | |
| 441 | + | } finally { await adminPage2.close(); } | |
| 442 | + | }); | |
| 443 | + | ||
| 444 | + | test('settings tab visible for admin, hidden for others', async () => { | |
| 445 | + | const adminPage = await adminCtx.newPage(); | |
| 446 | + | try { | |
| 447 | + | await adminPage.goto(`${BASE}/my-repo`); | |
| 448 | + | expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true); | |
| 449 | + | } finally { await adminPage.close(); } | |
| 450 | + | ||
| 451 | + | const alicePage = await aliceCtx.newPage(); | |
| 452 | + | try { | |
| 453 | + | await alicePage.goto(`${BASE}/my-repo`); | |
| 454 | + | expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0); | |
| 455 | + | } finally { await alicePage.close(); } | |
| 456 | + | }); | |
| 457 | + | }); | |
| 458 | + | ||
| 459 | + | // ─── Issues ─────────────────────────────────────────────────────────────────── | |
| 460 | + | ||
| 461 | + | describe('issues', () => { | |
| 462 | + | let adminCtx: BrowserContext; | |
| 463 | + | let issueUrl: string; | |
| 464 | + | ||
| 465 | + | beforeAll(async () => { | |
| 466 | + | adminCtx = await loggedInContext(); | |
| 467 | + | }); | |
| 468 | + | ||
| 469 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 470 | + | ||
| 471 | + | test('create issue', async () => { | |
| 472 | + | const page = await adminCtx.newPage(); | |
| 473 | + | try { | |
| 474 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 475 | + | await page.fill('[name=title]', 'First issue'); | |
| 476 | + | await page.fill('[name=body]', 'Body with **markdown**.'); | |
| 477 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 478 | + | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 479 | + | issueUrl = page.url(); | |
| 480 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue'); | |
| 481 | + | } finally { await page.close(); } | |
| 482 | + | }); | |
| 483 | + | ||
| 484 | + | test('issue body renders markdown', async () => { | |
| 485 | + | const page = await adminCtx.newPage(); | |
| 486 | + | try { | |
| 487 | + | await page.goto(issueUrl); | |
| 488 | + | expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>'); | |
| 489 | + | } finally { await page.close(); } | |
| 490 | + | }); | |
| 491 | + | ||
| 492 | + | test('issue appears in open list', async () => { | |
| 493 | + | const page = await adminCtx.newPage(); | |
| 494 | + | try { | |
| 495 | + | await page.goto(`${BASE}/my-repo/issues`); | |
| 496 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 497 | + | expect(titles.some(t => t.includes('First issue'))).toBe(true); | |
| 498 | + | } finally { await page.close(); } | |
| 499 | + | }); | |
| 500 | + | ||
| 501 | + | test('unauthenticated user is redirected to login from new issue form', async () => { | |
| 502 | + | const ctx = await browser.newContext(); | |
| 503 | + | const page = await ctx.newPage(); | |
| 504 | + | try { | |
| 505 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 506 | + | expect(page.url()).toContain('/login'); | |
| 507 | + | } finally { await ctx.close(); } | |
| 508 | + | }); | |
| 509 | + | ||
| 510 | + | test('add comment', async () => { | |
| 511 | + | const page = await adminCtx.newPage(); | |
| 512 | + | try { | |
| 513 | + | await page.goto(issueUrl); | |
| 514 | + | const beforeCount = await page.locator('.timeline-item').count(); | |
| 515 | + | await page.fill('textarea[name=body]', 'A follow-up comment.'); | |
| 516 | + | await page.click('form[action*="/comments"] button[type=submit]'); | |
| 517 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 518 | + | expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount); | |
| 519 | + | } finally { await page.close(); } | |
| 520 | + | }); | |
| 521 | + | ||
| 522 | + | test('react to issue', async () => { | |
| 523 | + | const page = await adminCtx.newPage(); | |
| 524 | + | try { | |
| 525 | + | await page.goto(issueUrl); | |
| 526 | + | await page.locator('.reaction-picker').first().click(); | |
| 527 | + | await page.locator('.reaction-picker-btn').first().click(); | |
| 528 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 529 | + | expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0); | |
| 530 | + | } finally { await page.close(); } | |
| 531 | + | }); | |
| 532 | + | ||
| 533 | + | test('close issue changes status badge', async () => { | |
| 534 | + | const page = await adminCtx.newPage(); | |
| 535 | + | try { | |
| 536 | + | await page.goto(issueUrl); | |
| 537 | + | await page.click('.issue-detail-meta-actions button'); | |
| 538 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 539 | + | expect(await page.locator('.issue-badge').textContent()).toBe('closed'); | |
| 540 | + | } finally { await page.close(); } | |
| 541 | + | }); | |
| 542 | + | ||
| 543 | + | test('closed issue appears in closed list', async () => { | |
| 544 | + | const page = await adminCtx.newPage(); | |
| 545 | + | try { | |
| 546 | + | await page.goto(`${BASE}/my-repo/issues?status=closed`); | |
| 547 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 548 | + | expect(titles.some(t => t.includes('First issue'))).toBe(true); | |
| 549 | + | } finally { await page.close(); } | |
| 550 | + | }); | |
| 551 | + | ||
| 552 | + | test('reopen issue', async () => { | |
| 553 | + | const page = await adminCtx.newPage(); | |
| 554 | + | try { | |
| 555 | + | await page.goto(issueUrl); | |
| 556 | + | await page.click('.issue-detail-meta-actions button'); | |
| 557 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 558 | + | expect(await page.locator('.issue-badge').textContent()).toBe('open'); | |
| 559 | + | } finally { await page.close(); } | |
| 560 | + | }); | |
| 561 | + | }); | |
| 562 | + | ||
| 563 | + | // ─── Patches ────────────────────────────────────────────────────────────────── | |
| 564 | + | ||
| 565 | + | describe('patches', () => { | |
| 566 | + | let adminCtx: BrowserContext; | |
| 567 | + | let cleanPatchUrl: string; | |
| 568 | + | let conflictPatchUrl: string; | |
| 569 | + | let closePatchUrl: string; | |
| 570 | + | ||
| 571 | + | // Adds a new file — applies cleanly to my-repo | |
| 572 | + | const CLEAN_PATCH = [ | |
| 573 | + | 'diff --git a/patch-test.txt b/patch-test.txt', | |
| 574 | + | 'new file mode 100644', | |
| 575 | + | 'index 0000000..9daeafb', | |
| 576 | + | '--- /dev/null', | |
| 577 | + | '+++ b/patch-test.txt', | |
| 578 | + | '@@ -0,0 +1 @@', | |
| 579 | + | '+patch test content', | |
| 580 | + | '', | |
| 581 | + | ].join('\n'); | |
| 582 | + | ||
| 583 | + | // References non-existent lines in README.md — always conflicts | |
| 584 | + | const CONFLICT_PATCH = [ | |
| 585 | + | 'diff --git a/README.md b/README.md', | |
| 586 | + | 'index abc1234..def5678 100644', | |
| 587 | + | '--- a/README.md', | |
| 588 | + | '+++ b/README.md', | |
| 589 | + | '@@ -50,3 +50,3 @@', | |
| 590 | + | ' nonexistent context line', | |
| 591 | + | '-nonexistent old line', | |
| 592 | + | '+nonexistent new line', | |
| 593 | + | '', | |
| 594 | + | ].join('\n'); | |
| 595 | + | ||
| 596 | + | // Adds another new file — for testing close flow | |
| 597 | + | const CLOSE_PATCH = [ | |
| 598 | + | 'diff --git a/patch-close.txt b/patch-close.txt', | |
| 599 | + | 'new file mode 100644', | |
| 600 | + | 'index 0000000..9daeafb', | |
| 601 | + | '--- /dev/null', | |
| 602 | + | '+++ b/patch-close.txt', | |
| 603 | + | '@@ -0,0 +1 @@', | |
| 604 | + | '+close test', | |
| 605 | + | '', | |
| 606 | + | ].join('\n'); | |
| 607 | + | ||
| 608 | + | beforeAll(async () => { | |
| 609 | + | adminCtx = await loggedInContext(); | |
| 610 | + | }); | |
| 611 | + | ||
| 612 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 613 | + | ||
| 614 | + | test('reject file without patch markers', async () => { | |
| 615 | + | writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text'); | |
| 616 | + | const page = await adminCtx.newPage(); | |
| 617 | + | try { | |
| 618 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 619 | + | await page.fill('[name=title]', 'Bad patch'); | |
| 620 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt'); | |
| 621 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 622 | + | expect(await page.locator('.form-error').textContent()).toContain('valid patch'); | |
| 623 | + | } finally { await page.close(); } | |
| 624 | + | }); | |
| 625 | + | ||
| 626 | + | test('upload clean patch', async () => { | |
| 627 | + | writeTempFile('/tmp/clean.patch', CLEAN_PATCH); | |
| 628 | + | const page = await adminCtx.newPage(); | |
| 629 | + | try { | |
| 630 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 631 | + | await page.fill('[name=title]', 'Add patch-test.txt'); | |
| 632 | + | await page.fill('[name=description]', 'Adds a file with **markdown** desc.'); | |
| 633 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch'); | |
| 634 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 635 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 636 | + | cleanPatchUrl = page.url(); | |
| 637 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt'); | |
| 638 | + | } finally { await page.close(); } | |
| 639 | + | }); | |
| 640 | + | ||
| 641 | + | test('patch description renders markdown', async () => { | |
| 642 | + | const page = await adminCtx.newPage(); | |
| 643 | + | try { | |
| 644 | + | await page.goto(cleanPatchUrl); | |
| 645 | + | expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>'); | |
| 646 | + | } finally { await page.close(); } | |
| 647 | + | }); | |
| 648 | + | ||
| 649 | + | test('clean patch shows apply-clean status immediately', async () => { | |
| 650 | + | const page = await adminCtx.newPage(); | |
| 651 | + | try { | |
| 652 | + | await page.goto(cleanPatchUrl); | |
| 653 | + | expect(await page.locator('.apply-result').isVisible()).toBe(true); | |
| 654 | + | expect(await page.locator('.apply-clean').isVisible()).toBe(true); | |
| 655 | + | } finally { await page.close(); } | |
| 656 | + | }); | |
| 657 | + | ||
| 658 | + | test('merge button appears for clean patch', async () => { | |
| 659 | + | const page = await adminCtx.newPage(); | |
| 660 | + | try { | |
| 661 | + | await page.goto(cleanPatchUrl); | |
| 662 | + | expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true); | |
| 663 | + | } finally { await page.close(); } | |
| 664 | + | }); | |
| 665 | + | ||
| 666 | + | test('patch diff is displayed with highlighted table', async () => { | |
| 667 | + | const page = await adminCtx.newPage(); | |
| 668 | + | try { | |
| 669 | + | await page.goto(cleanPatchUrl + '?tab=changes'); | |
| 670 | + | expect(await page.locator('.diff-table').first().isVisible()).toBe(true); | |
| 671 | + | expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0); | |
| 672 | + | } finally { await page.close(); } | |
| 673 | + | }); | |
| 674 | + | ||
| 675 | + | test('patch appears in open list', async () => { | |
| 676 | + | const page = await adminCtx.newPage(); | |
| 677 | + | try { | |
| 678 | + | await page.goto(`${BASE}/my-repo/patches`); | |
| 679 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 680 | + | expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); | |
| 681 | + | } finally { await page.close(); } | |
| 682 | + | }); | |
| 683 | + | ||
| 684 | + | test('unauthenticated user is redirected to login from patch upload', async () => { | |
| 685 | + | const ctx = await browser.newContext(); | |
| 686 | + | const page = await ctx.newPage(); | |
| 687 | + | try { | |
| 688 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 689 | + | expect(page.url()).toContain('/login'); | |
| 690 | + | } finally { await ctx.close(); } | |
| 691 | + | }); | |
| 692 | + | ||
| 693 | + | test('upload conflict patch', async () => { | |
| 694 | + | writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH); | |
| 695 | + | const page = await adminCtx.newPage(); | |
| 696 | + | try { | |
| 697 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 698 | + | await page.fill('[name=title]', 'Conflict patch'); | |
| 699 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch'); | |
| 700 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 701 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 702 | + | conflictPatchUrl = page.url(); | |
| 703 | + | } finally { await page.close(); } | |
| 704 | + | }); | |
| 705 | + | ||
| 706 | + | test('conflict patch shows apply-conflict status', async () => { | |
| 707 | + | const page = await adminCtx.newPage(); | |
| 708 | + | try { | |
| 709 | + | await page.goto(conflictPatchUrl); | |
| 710 | + | expect(await page.locator('.apply-conflict').isVisible()).toBe(true); | |
| 711 | + | } finally { await page.close(); } | |
| 712 | + | }); | |
| 713 | + | ||
| 714 | + | test('merge button absent for conflict patch', async () => { | |
| 715 | + | const page = await adminCtx.newPage(); | |
| 716 | + | try { | |
| 717 | + | await page.goto(conflictPatchUrl); | |
| 718 | + | expect(await page.locator('form[action*="/merge"] button').count()).toBe(0); | |
| 719 | + | } finally { await page.close(); } | |
| 720 | + | }); | |
| 721 | + | ||
| 722 | + | test('merge clean patch changes status to merged', async () => { | |
| 723 | + | const page = await adminCtx.newPage(); | |
| 724 | + | try { | |
| 725 | + | await page.goto(cleanPatchUrl); | |
| 726 | + | await page.click('form[action*="/merge"] button'); | |
| 727 | + | await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, ''))); | |
| 728 | + | expect(await page.locator('.patch-badge').textContent()).toBe('merged'); | |
| 729 | + | } finally { await page.close(); } | |
| 730 | + | }); | |
| 731 | + | ||
| 732 | + | test('merged patch appears in merged list', async () => { | |
| 733 | + | const page = await adminCtx.newPage(); | |
| 734 | + | try { | |
| 735 | + | await page.goto(`${BASE}/my-repo/patches?status=merged`); | |
| 736 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 737 | + | expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true); | |
| 738 | + | } finally { await page.close(); } | |
| 739 | + | }); | |
| 740 | + | ||
| 741 | + | test('upload and close a patch', async () => { | |
| 742 | + | writeTempFile('/tmp/close.patch', CLOSE_PATCH); | |
| 743 | + | const page = await adminCtx.newPage(); | |
| 744 | + | try { | |
| 745 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 746 | + | await page.fill('[name=title]', 'Close me'); | |
| 747 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch'); | |
| 748 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 749 | + | await page.waitForURL(/\/my-repo\/patches\/\d+/); | |
| 750 | + | closePatchUrl = page.url(); | |
| 751 | + | await page.click('form[action*="/close"] button'); | |
| 752 | + | await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); | |
| 753 | + | expect(await page.locator('.patch-badge').textContent()).toBe('closed'); | |
| 754 | + | } finally { await page.close(); } | |
| 755 | + | }); | |
| 756 | + | ||
| 757 | + | test('closed patch appears in closed list', async () => { | |
| 758 | + | const page = await adminCtx.newPage(); | |
| 759 | + | try { | |
| 760 | + | await page.goto(`${BASE}/my-repo/patches?status=closed`); | |
| 761 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 762 | + | expect(titles.some(t => t.includes('Close me'))).toBe(true); | |
| 763 | + | } finally { await page.close(); } | |
| 764 | + | }); | |
| 765 | + | ||
| 766 | + | test('closed patch can be reopened', async () => { | |
| 767 | + | const page = await adminCtx.newPage(); | |
| 768 | + | try { | |
| 769 | + | await page.goto(closePatchUrl); | |
| 770 | + | expect(await page.locator('.patch-badge').textContent()).toBe('closed'); | |
| 771 | + | await page.click('form[action*="/close"] button'); | |
| 772 | + | await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, ''))); | |
| 773 | + | expect(await page.locator('.patch-badge').textContent()).toBe('open'); | |
| 774 | + | } finally { await page.close(); } | |
| 775 | + | }); | |
| 776 | + | ||
| 777 | + | test('patch title and description can be edited', async () => { | |
| 778 | + | const page = await adminCtx.newPage(); | |
| 779 | + | try { | |
| 780 | + | await page.goto(conflictPatchUrl); | |
| 781 | + | await page.click('.timeline-author .inline-edit-details summary'); | |
| 782 | + | await page.fill('.inline-edit-form-area [name=title]', 'Edited Conflict Patch'); | |
| 783 | + | await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc'); | |
| 784 | + | await page.click('.inline-edit-form-area [type=submit]'); | |
| 785 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 786 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch'); | |
| 787 | + | } finally { await page.close(); } | |
| 788 | + | }); | |
| 789 | + | ||
| 790 | + | test('patch comment: add and edit', async () => { | |
| 791 | + | const page = await adminCtx.newPage(); | |
| 792 | + | try { | |
| 793 | + | await page.goto(conflictPatchUrl); | |
| 794 | + | await page.fill('[name=body]', 'My patch comment'); | |
| 795 | + | await page.click('form[action$="/comments"] button[type=submit]'); | |
| 796 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 797 | + | expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment'); | |
| 798 | + | ||
| 799 | + | // Edit the comment — scope to the timeline-item containing the comment text | |
| 800 | + | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' }); | |
| 801 | + | await commentItem.locator('.inline-edit-details summary').click(); | |
| 802 | + | await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment'); | |
| 803 | + | await commentItem.locator('.inline-edit-form-area [type=submit]').click(); | |
| 804 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 805 | + | expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment'); | |
| 806 | + | } finally { await page.close(); } | |
| 807 | + | }); | |
| 808 | + | ||
| 809 | + | test('patch reaction on description', async () => { | |
| 810 | + | const page = await adminCtx.newPage(); | |
| 811 | + | try { | |
| 812 | + | await page.goto(conflictPatchUrl); | |
| 813 | + | // Open reaction picker on the first timeline-item (description) | |
| 814 | + | await page.locator('.timeline-item').first().locator('.reaction-add-btn').click(); | |
| 815 | + | await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click(); | |
| 816 | + | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 817 | + | expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/); | |
| 818 | + | } finally { await page.close(); } | |
| 819 | + | }); | |
| 820 | + | }); | |
| 821 | + | ||
| 822 | + | // ─── Pagination ─────────────────────────────────────────────────────────────── | |
| 823 | + | ||
| 824 | + | describe('pagination', () => { | |
| 825 | + | let adminCtx: BrowserContext; | |
| 826 | + | ||
| 827 | + | beforeAll(async () => { | |
| 828 | + | adminCtx = await loggedInContext(); | |
| 829 | + | ||
| 830 | + | // Create a repo dedicated to pagination testing | |
| 831 | + | const page = await adminCtx.newPage(); | |
| 832 | + | try { | |
| 833 | + | await page.goto(`${BASE}/new`); | |
| 834 | + | await page.fill('[name=name]', 'paged-repo'); | |
| 835 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 836 | + | await page.waitForURL(`${BASE}/paged-repo`); | |
| 837 | + | } finally { await page.close(); } | |
| 838 | + | ||
| 839 | + | // Create 21 issues via the API (triggers page 2 at 20 per page) | |
| 840 | + | await bulkCreateIssues(adminCtx, 'paged-repo', 21); | |
| 841 | + | ||
| 842 | + | // Create 21 patches via browser (patch upload requires multipart) | |
| 843 | + | await bulkCreatePatches(adminCtx, 'paged-repo', 21); | |
| 844 | + | }); | |
| 845 | + | ||
| 846 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 847 | + | ||
| 848 | + | // ── Repo list pagination ────────────────────────────────────────────────── | |
| 849 | + | ||
| 850 | + | test('repo list page 1 shows repos and no pagination when few repos', async () => { | |
| 851 | + | // With only a handful of test repos (< 20), there should be no pagination nav | |
| 852 | + | const page = await adminCtx.newPage(); | |
| 853 | + | try { | |
| 854 | + | await page.goto(BASE); | |
| 855 | + | // Repos are shown | |
| 856 | + | expect(await page.locator('.repo-name').count()).toBeGreaterThan(0); | |
| 857 | + | } finally { await page.close(); } | |
| 858 | + | }); | |
| 859 | + | ||
| 860 | + | // ── Issue pagination ────────────────────────────────────────────────────── | |
| 861 | + | ||
| 862 | + | test('issue list page 1 shows at most 20 items', async () => { | |
| 863 | + | const page = await adminCtx.newPage(); | |
| 864 | + | try { | |
| 865 | + | await page.goto(`${BASE}/paged-repo/issues`); | |
| 866 | + | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 867 | + | } finally { await page.close(); } | |
| 868 | + | }); | |
| 869 | + | ||
| 870 | + | test('issue list pagination nav appears when more than 20 issues', async () => { | |
| 871 | + | const page = await adminCtx.newPage(); | |
| 872 | + | try { | |
| 873 | + | await page.goto(`${BASE}/paged-repo/issues`); | |
| 874 | + | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 875 | + | } finally { await page.close(); } | |
| 876 | + | }); | |
| 877 | + | ||
| 878 | + | test('issue list page 2 shows remaining issues', async () => { | |
| 879 | + | const page = await adminCtx.newPage(); | |
| 880 | + | try { | |
| 881 | + | await page.goto(`${BASE}/paged-repo/issues?page=2`); | |
| 882 | + | const count = await page.locator('.issue-item').count(); | |
| 883 | + | expect(count).toBeGreaterThan(0); | |
| 884 | + | expect(count).toBeLessThanOrEqual(20); | |
| 885 | + | } finally { await page.close(); } | |
| 886 | + | }); | |
| 887 | + | ||
| 888 | + | test('issue list page 2 prev link goes to page 1', async () => { | |
| 889 | + | const page = await adminCtx.newPage(); | |
| 890 | + | try { | |
| 891 | + | await page.goto(`${BASE}/paged-repo/issues?page=2`); | |
| 892 | + | const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href'); | |
| 893 | + | expect(prevHref).toContain('page=1'); | |
| 894 | + | } finally { await page.close(); } | |
| 895 | + | }); | |
| 896 | + | ||
| 897 | + | test('issue list page 1 next link goes to page 2', async () => { | |
| 898 | + | const page = await adminCtx.newPage(); | |
| 899 | + | try { | |
| 900 | + | await page.goto(`${BASE}/paged-repo/issues`); | |
| 901 | + | const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href'); | |
| 902 | + | expect(nextHref).toContain('page=2'); | |
| 903 | + | } finally { await page.close(); } | |
| 904 | + | }); | |
| 905 | + | ||
| 906 | + | // ── Patch pagination ────────────────────────────────────────────────────── | |
| 907 | + | ||
| 908 | + | test('patch list page 1 shows at most 20 items', async () => { | |
| 909 | + | const page = await adminCtx.newPage(); | |
| 910 | + | try { | |
| 911 | + | await page.goto(`${BASE}/paged-repo/patches`); | |
| 912 | + | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 913 | + | } finally { await page.close(); } | |
| 914 | + | }); | |
| 915 | + | ||
| 916 | + | test('patch list pagination nav appears when more than 20 patches', async () => { | |
| 917 | + | const page = await adminCtx.newPage(); | |
| 918 | + | try { | |
| 919 | + | await page.goto(`${BASE}/paged-repo/patches`); | |
| 920 | + | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 921 | + | } finally { await page.close(); } | |
| 922 | + | }); | |
| 923 | + | ||
| 924 | + | test('patch list page 2 shows remaining patches', async () => { | |
| 925 | + | const page = await adminCtx.newPage(); | |
| 926 | + | try { | |
| 927 | + | await page.goto(`${BASE}/paged-repo/patches?page=2`); | |
| 928 | + | const count = await page.locator('.issue-item').count(); | |
| 929 | + | expect(count).toBeGreaterThan(0); | |
| 930 | + | } finally { await page.close(); } | |
| 931 | + | }); | |
| 932 | + | ||
| 933 | + | // ── Commit log pagination ───────────────────────────────────────────────── | |
| 934 | + | ||
| 935 | + | test('commit log with few commits shows no cursor nav', async () => { | |
| 936 | + | // my-repo has 1 commit — both newer and older links should be absent | |
| 937 | + | const page = await adminCtx.newPage(); | |
| 938 | + | try { | |
| 939 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 940 | + | expect(await page.locator('.commit-cursor-nav').count()).toBe(0); | |
| 941 | + | } finally { await page.close(); } | |
| 942 | + | }); | |
| 943 | + | }); | |
| 944 | + | ||
| 945 | + | // ─── Branch selector ────────────────────────────────────────────────────────── | |
| 946 | + | ||
| 947 | + | describe('branch selector', () => { | |
| 948 | + | let adminCtx: BrowserContext; | |
| 949 | + | ||
| 950 | + | beforeAll(async () => { | |
| 951 | + | adminCtx = await loggedInContext(); | |
| 952 | + | // Add a second branch so the selector is meaningful | |
| 953 | + | await seedBranch('my-repo', 'dev'); | |
| 954 | + | }); | |
| 955 | + | ||
| 956 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 957 | + | ||
| 958 | + | test('branch selector appears on repo home', async () => { | |
| 959 | + | const page = await adminCtx.newPage(); | |
| 960 | + | try { | |
| 961 | + | await page.goto(`${BASE}/my-repo`); | |
| 962 | + | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 963 | + | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 964 | + | } finally { await page.close(); } | |
| 965 | + | }); | |
| 966 | + | ||
| 967 | + | test('branch selector shows all branches on repo home', async () => { | |
| 968 | + | const page = await adminCtx.newPage(); | |
| 969 | + | try { | |
| 970 | + | await page.goto(`${BASE}/my-repo`); | |
| 971 | + | const options = await page.locator('.branch-select option').allTextContents(); | |
| 972 | + | expect(options).toContain('main'); | |
| 973 | + | expect(options).toContain('dev'); | |
| 974 | + | } finally { await page.close(); } | |
| 975 | + | }); | |
| 976 | + | ||
| 977 | + | test('branch selector appears on file tree with current ref selected', async () => { | |
| 978 | + | const page = await adminCtx.newPage(); | |
| 979 | + | try { | |
| 980 | + | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 981 | + | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 982 | + | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 983 | + | } finally { await page.close(); } | |
| 984 | + | }); | |
| 985 | + | ||
| 986 | + | test('branch selector appears on commit log with current ref selected', async () => { | |
| 987 | + | const page = await adminCtx.newPage(); | |
| 988 | + | try { | |
| 989 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 990 | + | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 991 | + | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 992 | + | } finally { await page.close(); } | |
| 993 | + | }); | |
| 994 | + | ||
| 995 | + | test('branch selector appears on file blob', async () => { | |
| 996 | + | const page = await adminCtx.newPage(); | |
| 997 | + | try { | |
| 998 | + | await page.goto(`${BASE}/my-repo/blob/main/README.md`); | |
| 999 | + | expect(await page.locator('.branch-selector').isVisible()).toBe(true); | |
| 1000 | + | expect(await page.locator('.branch-select').inputValue()).toBe('main'); | |
| 1001 | + | } finally { await page.close(); } | |
| 1002 | + | }); | |
| 1003 | + | ||
| 1004 | + | test('switching branch on commit log navigates to the selected branch', async () => { | |
| 1005 | + | const page = await adminCtx.newPage(); | |
| 1006 | + | try { | |
| 1007 | + | await page.goto(`${BASE}/my-repo/commits/main`); | |
| 1008 | + | await page.locator('.branch-select').selectOption('dev'); | |
| 1009 | + | await page.locator('form.branch-selector').evaluate((f: any) => f.submit()); | |
| 1010 | + | await page.waitForURL(`${BASE}/my-repo/commits/dev`); | |
| 1011 | + | expect(page.url()).toContain('/commits/dev'); | |
| 1012 | + | } finally { await page.close(); } | |
| 1013 | + | }); | |
| 1014 | + | ||
| 1015 | + | test('switching branch on file tree navigates to the selected branch', async () => { | |
| 1016 | + | const page = await adminCtx.newPage(); | |
| 1017 | + | try { | |
| 1018 | + | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 1019 | + | await page.locator('.branch-select').selectOption('dev'); | |
| 1020 | + | await page.locator('form.branch-selector').evaluate((f: any) => f.submit()); | |
| 1021 | + | await page.waitForURL(`${BASE}/my-repo/tree/dev`); | |
| 1022 | + | expect(page.url()).toContain('/tree/dev'); | |
| 1023 | + | } finally { await page.close(); } | |
| 1024 | + | }); | |
| 1025 | + | ||
| 1026 | + | test('branch-switch route preserves subpath when switching tree', async () => { | |
| 1027 | + | const page = await adminCtx.newPage(); | |
| 1028 | + | try { | |
| 1029 | + | const resp = await page.request.get( | |
| 1030 | + | `${BASE}/my-repo/branch-switch?view=tree&ref=dev&path=src/foo`, | |
| 1031 | + | { maxRedirects: 0 }, | |
| 1032 | + | ).catch(r => r); | |
| 1033 | + | // 302 redirect to /my-repo/tree/dev/src/foo | |
| 1034 | + | const loc = (resp as any).headers()?.['location'] ?? ''; | |
| 1035 | + | expect(loc).toContain('/tree/dev/src/foo'); | |
| 1036 | + | } finally { await page.close(); } | |
| 1037 | + | }); | |
| 1038 | + | ||
| 1039 | + | test('branch-switch route redirects commits view correctly', async () => { | |
| 1040 | + | const page = await adminCtx.newPage(); | |
| 1041 | + | try { | |
| 1042 | + | const resp = await page.request.get( | |
| 1043 | + | `${BASE}/my-repo/branch-switch?view=commits&ref=dev`, | |
| 1044 | + | { maxRedirects: 0 }, | |
| 1045 | + | ).catch(r => r); | |
| 1046 | + | const loc = (resp as any).headers()?.['location'] ?? ''; | |
| 1047 | + | expect(loc).toContain('/commits/dev'); | |
| 1048 | + | } finally { await page.close(); } | |
| 1049 | + | }); | |
| 1050 | + | ||
| 1051 | + | test('branch-switch route redirects blob view correctly', async () => { | |
| 1052 | + | const page = await adminCtx.newPage(); | |
| 1053 | + | try { | |
| 1054 | + | const resp = await page.request.get( | |
| 1055 | + | `${BASE}/my-repo/branch-switch?view=blob&ref=dev&path=README.md`, | |
| 1056 | + | { maxRedirects: 0 }, | |
| 1057 | + | ).catch(r => r); | |
| 1058 | + | const loc = (resp as any).headers()?.['location'] ?? ''; | |
| 1059 | + | expect(loc).toContain('/blob/dev/README.md'); | |
| 1060 | + | } finally { await page.close(); } | |
| 1061 | + | }); | |
| 1062 | + | }); | |
| 1063 | + | ||
| 1064 | + | // ─── Default branch settings ────────────────────────────────────────────────── | |
| 1065 | + | ||
| 1066 | + | describe('default branch settings', () => { | |
| 1067 | + | // Runs after 'branch selector', so my-repo already has both main and dev branches. | |
| 1068 | + | let adminCtx: BrowserContext; | |
| 1069 | + | ||
| 1070 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 1071 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 1072 | + | ||
| 1073 | + | test('settings page shows default branch select', async () => { | |
| 1074 | + | const page = await adminCtx.newPage(); | |
| 1075 | + | try { | |
| 1076 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 1077 | + | expect(await page.locator('#default_branch').isVisible()).toBe(true); | |
| 1078 | + | const options = await page.locator('#default_branch option').allTextContents(); | |
| 1079 | + | expect(options).toContain('main'); | |
| 1080 | + | expect(options).toContain('dev'); | |
| 1081 | + | } finally { await page.close(); } | |
| 1082 | + | }); | |
| 1083 | + | ||
| 1084 | + | test('current default branch is pre-selected', async () => { | |
| 1085 | + | const page = await adminCtx.newPage(); | |
| 1086 | + | try { | |
| 1087 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 1088 | + | expect(await page.locator('#default_branch').inputValue()).toBe('main'); | |
| 1089 | + | } finally { await page.close(); } | |
| 1090 | + | }); | |
| 1091 | + | ||
| 1092 | + | test('changing default branch saves and is reflected in the repo home', async () => { | |
| 1093 | + | const page = await adminCtx.newPage(); | |
| 1094 | + | try { | |
| 1095 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 1096 | + | await page.locator('#default_branch').selectOption('dev'); | |
| 1097 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 1098 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 1099 | + | // The select now shows dev as current | |
| 1100 | + | expect(await page.locator('#default_branch').inputValue()).toBe('dev'); | |
| 1101 | + | ||
| 1102 | + | // Repo home branch selector should reflect the new default | |
| 1103 | + | await page.goto(`${BASE}/my-repo`); | |
| 1104 | + | expect(await page.locator('.branch-select').inputValue()).toBe('dev'); | |
| 1105 | + | } finally { await page.close(); } | |
| 1106 | + | }); | |
| 1107 | + | ||
| 1108 | + | test('commit log link in repo nav uses the new default branch', async () => { | |
| 1109 | + | const page = await adminCtx.newPage(); | |
| 1110 | + | try { | |
| 1111 | + | await page.goto(`${BASE}/my-repo`); | |
| 1112 | + | const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href'); | |
| 1113 | + | expect(commitsHref).toContain('/commits/dev'); | |
| 1114 | + | } finally { await page.close(); } | |
| 1115 | + | }); | |
| 1116 | + | ||
| 1117 | + | test('changing default branch back to main restores original state', async () => { | |
| 1118 | + | const page = await adminCtx.newPage(); | |
| 1119 | + | try { | |
| 1120 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 1121 | + | await page.locator('#default_branch').selectOption('main'); | |
| 1122 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 1123 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 1124 | + | expect(await page.locator('#default_branch').inputValue()).toBe('main'); | |
| 1125 | + | } finally { await page.close(); } | |
| 1126 | + | }); | |
| 1127 | + | ||
| 1128 | + | test('settings page shows hint instead of select when repo has no branches', async () => { | |
| 1129 | + | // Create an empty repo (no commits → no branches) | |
| 1130 | + | const page = await adminCtx.newPage(); | |
| 1131 | + | try { | |
| 1132 | + | await page.goto(`${BASE}/new`); | |
| 1133 | + | await page.fill('[name=name]', 'empty-for-branch-test'); | |
| 1134 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 1135 | + | await page.waitForURL(`${BASE}/empty-for-branch-test`); | |
| 1136 | + | ||
| 1137 | + | await page.goto(`${BASE}/empty-for-branch-test/settings`); | |
| 1138 | + | expect(await page.locator('#default_branch').count()).toBe(0); | |
| 1139 | + | expect(await page.locator('.form-hint').isVisible()).toBe(true); | |
| 1140 | + | } finally { await page.close(); } | |
| 1141 | + | }); | |
| 1142 | + | }); | |
| 1143 | + | ||
| 1144 | + | // ─── File browser ───────────────────────────────────────────────────────────── | |
| 1145 | + | ||
| 1146 | + | describe('file browser', () => { | |
| 1147 | + | // my-repo already has README.md + index.js from the 'repos' describe block. | |
| 1148 | + | // We add a subdirectory here so we can test directory navigation. | |
| 1149 | + | let adminCtx: BrowserContext; | |
| 1150 | + | ||
| 1151 | + | beforeAll(async () => { | |
| 1152 | + | adminCtx = await loggedInContext(); | |
| 1153 | + | await seedSubdir('my-repo', 'src', { | |
| 1154 | + | 'app.ts': 'export {};\n', | |
| 1155 | + | 'README.md': '# src readme\n', | |
| 1156 | + | }); | |
| 1157 | + | }); | |
| 1158 | + | ||
| 1159 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 1160 | + | ||
| 1161 | + | test('repo home shows file tree instead of recent commits', async () => { | |
| 1162 | + | const page = await adminCtx.newPage(); | |
| 1163 | + | try { | |
| 1164 | + | await page.goto(`${BASE}/my-repo`); | |
| 1165 | + | expect(await page.locator('.file-tree').isVisible()).toBe(true); | |
| 1166 | + | expect(await page.locator('.repo-commits-section').count()).toBe(0); | |
| 1167 | + | } finally { await page.close(); } | |
| 1168 | + | }); | |
| 1169 | + | ||
| 1170 | + | test('repo home file tree lists files and directories', async () => { | |
| 1171 | + | const page = await adminCtx.newPage(); | |
| 1172 | + | try { | |
| 1173 | + | await page.goto(`${BASE}/my-repo`); | |
| 1174 | + | const names = await page.locator('.file-name a').allTextContents(); | |
| 1175 | + | expect(names).toContain('README.md'); | |
| 1176 | + | expect(names).toContain('index.js'); | |
| 1177 | + | expect(names).toContain('src'); | |
| 1178 | + | } finally { await page.close(); } | |
| 1179 | + | }); | |
| 1180 | + | ||
| 1181 | + | test('directories appear before files in file tree', async () => { | |
| 1182 | + | const page = await adminCtx.newPage(); | |
| 1183 | + | try { | |
| 1184 | + | await page.goto(`${BASE}/my-repo`); | |
| 1185 | + | const names = await page.locator('.file-name a').allTextContents(); | |
| 1186 | + | const srcIdx = names.indexOf('src'); | |
| 1187 | + | const readmeIdx = names.indexOf('README.md'); | |
| 1188 | + | expect(srcIdx).toBeGreaterThanOrEqual(0); | |
| 1189 | + | expect(readmeIdx).toBeGreaterThanOrEqual(0); | |
| 1190 | + | expect(srcIdx).toBeLessThan(readmeIdx); | |
| 1191 | + | } finally { await page.close(); } | |
| 1192 | + | }); | |
| 1193 | + | ||
| 1194 | + | test('no ".." entry at repository root', async () => { | |
| 1195 | + | const page = await adminCtx.newPage(); | |
| 1196 | + | try { | |
| 1197 | + | await page.goto(`${BASE}/my-repo`); | |
| 1198 | + | const names = await page.locator('.file-name a').allTextContents(); | |
| 1199 | + | expect(names).not.toContain('..'); | |
| 1200 | + | } finally { await page.close(); } | |
| 1201 | + | }); | |
| 1202 | + | ||
| 1203 | + | test('clicking directory navigates into it', async () => { | |
| 1204 | + | const page = await adminCtx.newPage(); | |
| 1205 | + | try { | |
| 1206 | + | await page.goto(`${BASE}/my-repo`); | |
| 1207 | + | await page.locator('.file-name a', { hasText: 'src' }).click(); | |
| 1208 | + | await page.waitForURL(`${BASE}/my-repo/tree/main/src`); | |
| 1209 | + | expect(page.url()).toContain('/tree/main/src'); | |
| 1210 | + | } finally { await page.close(); } | |
| 1211 | + | }); | |
| 1212 | + | ||
| 1213 | + | test('".." entry appears in subdirectory', async () => { | |
| 1214 | + | const page = await adminCtx.newPage(); | |
| 1215 | + | try { | |
| 1216 | + | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1217 | + | const names = await page.locator('.file-name a').allTextContents(); | |
| 1218 | + | expect(names).toContain('..'); | |
| 1219 | + | } finally { await page.close(); } | |
| 1220 | + | }); | |
| 1221 | + | ||
| 1222 | + | test('".." at one level deep links to tree root', async () => { | |
| 1223 | + | const page = await adminCtx.newPage(); | |
| 1224 | + | try { | |
| 1225 | + | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1226 | + | const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href'); | |
| 1227 | + | expect(upHref).toBe('/my-repo/tree/main'); | |
| 1228 | + | } finally { await page.close(); } | |
| 1229 | + | }); | |
| 1230 | + | ||
| 1231 | + | test('files in subdirectory show plain names, not full paths', async () => { | |
| 1232 | + | const page = await adminCtx.newPage(); | |
| 1233 | + | try { | |
| 1234 | + | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1235 | + | const names = await page.locator('.file-name a').allTextContents(); | |
| 1236 | + | expect(names).toContain('app.ts'); | |
| 1237 | + | // Must NOT contain the full path with prefix | |
| 1238 | + | expect(names).not.toContain('src/app.ts'); | |
| 1239 | + | expect(names).not.toContain('src/README.md'); | |
| 1240 | + | } finally { await page.close(); } | |
| 1241 | + | }); | |
| 1242 | + | ||
| 1243 | + | test('readme is shown below file tree on repo home', async () => { | |
| 1244 | + | const page = await adminCtx.newPage(); | |
| 1245 | + | try { | |
| 1246 | + | await page.goto(`${BASE}/my-repo`); | |
| 1247 | + | const treeBox = await page.locator('.file-tree').boundingBox(); | |
| 1248 | + | const readmeBox = await page.locator('.readme-section').boundingBox(); | |
| 1249 | + | expect(treeBox).not.toBeNull(); | |
| 1250 | + | expect(readmeBox).not.toBeNull(); | |
| 1251 | + | expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1); | |
| 1252 | + | } finally { await page.close(); } | |
| 1253 | + | }); | |
| 1254 | + | ||
| 1255 | + | test('readme in subdirectory is shown when present', async () => { | |
| 1256 | + | const page = await adminCtx.newPage(); | |
| 1257 | + | try { | |
| 1258 | + | await page.goto(`${BASE}/my-repo/tree/main/src`); | |
| 1259 | + | expect(await page.locator('.readme-section').isVisible()).toBe(true); | |
| 1260 | + | expect(await page.locator('.readme-section .markdown-body').innerHTML()) | |
| 1261 | + | .toContain('src readme'); | |
| 1262 | + | } finally { await page.close(); } | |
| 1263 | + | }); | |
| 1264 | + | ||
| 1265 | + | test('file tree on /tree/:ref also shows readme', async () => { | |
| 1266 | + | const page = await adminCtx.newPage(); | |
| 1267 | + | try { | |
| 1268 | + | await page.goto(`${BASE}/my-repo/tree/main`); | |
| 1269 | + | expect(await page.locator('.file-tree').isVisible()).toBe(true); | |
| 1270 | + | expect(await page.locator('.readme-section').isVisible()).toBe(true); | |
| 1271 | + | } finally { await page.close(); } | |
| 1272 | + | }); | |
| 1273 | + | }); | |
| 1274 | + | ||
| 1275 | + | // ─── Releases ───────────────────────────────────────────────────────────────── | |
| 1276 | + | ||
| 1277 | + | describe('releases', () => { | |
| 1278 | + | let adminCtx: BrowserContext; | |
| 1279 | + | let aliceCtx: BrowserContext; | |
| 1280 | + | let commitHash: string; | |
| 1281 | + | let releaseUrl: string; | |
| 1282 | + | let srcReleaseUrl: string; | |
| 1283 | + | let releaseWithAssetsUrl: string; | |
| 1284 | + | ||
| 1285 | + | beforeAll(async () => { | |
| 1286 | + | adminCtx = await loggedInContext(); | |
| 1287 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1288 | + | ||
| 1289 | + | // Create a dedicated repo with at least one commit | |
| 1290 | + | const page = await adminCtx.newPage(); | |
| 1291 | + | try { | |
| 1292 | + | await page.goto(`${BASE}/new`); | |
| 1293 | + | await page.fill('[name=name]', 'releases-repo'); | |
| 1294 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 1295 | + | await page.waitForURL(`${BASE}/releases-repo`); | |
| 1296 | + | } finally { await page.close(); } | |
| 1297 | + | ||
| 1298 | + | await seedRepo('releases-repo'); | |
| 1299 | + | commitHash = await getHeadCommit('releases-repo'); | |
| 1300 | + | }); | |
| 1301 | + | ||
| 1302 | + | afterAll(async () => { | |
| 1303 | + | await adminCtx.close(); | |
| 1304 | + | await aliceCtx.close(); | |
| 1305 | + | }); | |
| 1306 | + | ||
| 1307 | + | // ── Navigation ────────────────────────────────────────────────────────────── | |
| 1308 | + | ||
| 1309 | + | test('releases tab visible in repo nav', async () => { | |
| 1310 | + | const page = await adminCtx.newPage(); | |
| 1311 | + | try { | |
| 1312 | + | await page.goto(`${BASE}/releases-repo`); | |
| 1313 | + | expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true); | |
| 1314 | + | } finally { await page.close(); } | |
| 1315 | + | }); | |
| 1316 | + | ||
| 1317 | + | test('releases list shows empty state when no releases', async () => { | |
| 1318 | + | const page = await adminCtx.newPage(); | |
| 1319 | + | try { | |
| 1320 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1321 | + | expect(await page.locator('.empty-state').isVisible()).toBe(true); | |
| 1322 | + | } finally { await page.close(); } | |
| 1323 | + | }); | |
| 1324 | + | ||
| 1325 | + | // ── Access control ────────────────────────────────────────────────────────── | |
| 1326 | + | ||
| 1327 | + | test('non-admin cannot access /releases/new', async () => { | |
| 1328 | + | const page = await aliceCtx.newPage(); | |
| 1329 | + | try { | |
| 1330 | + | const resp = await page.request.get(`${BASE}/releases-repo/releases/new`); | |
| 1331 | + | expect(resp.status()).toBe(403); | |
| 1332 | + | } finally { await page.close(); } | |
| 1333 | + | }); | |
| 1334 | + | ||
| 1335 | + | test('non-admin POST to /releases returns 403', async () => { | |
| 1336 | + | const page = await aliceCtx.newPage(); | |
| 1337 | + | try { | |
| 1338 | + | const resp = await page.request.post(`${BASE}/releases-repo/releases`, { | |
| 1339 | + | multipart: { tag_name: 'v0.1.0', commit_hash: commitHash }, | |
| 1340 | + | maxRedirects: 0, | |
| 1341 | + | }); | |
| 1342 | + | expect(resp.status()).toBe(403); | |
| 1343 | + | } finally { await page.close(); } | |
| 1344 | + | }); | |
| 1345 | + | ||
| 1346 | + | test('unauthenticated user is redirected to login from /releases/new', async () => { | |
| 1347 | + | const ctx = await browser.newContext(); | |
| 1348 | + | const page = await ctx.newPage(); | |
| 1349 | + | try { | |
| 1350 | + | await page.goto(`${BASE}/releases-repo/releases/new`); | |
| 1351 | + | expect(page.url()).toContain('/login'); | |
| 1352 | + | } finally { await ctx.close(); } | |
| 1353 | + | }); | |
| 1354 | + | ||
| 1355 | + | // ── Validation ────────────────────────────────────────────────────────────── | |
| 1356 | + | ||
| 1357 | + | test('empty tag name shows error', async () => { | |
| 1358 | + | // Omit tag_name entirely — route now uses t.Optional so app validation runs | |
| 1359 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1360 | + | multipart: { commit_hash: commitHash }, | |
| 1361 | + | }); | |
| 1362 | + | expect(await resp.text()).toContain('Tag name is required'); | |
| 1363 | + | }); | |
| 1364 | + | ||
| 1365 | + | test('invalid commit hash shows error', async () => { | |
| 1366 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1367 | + | multipart: { tag_name: 'v-bad', commit_hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' }, | |
| 1368 | + | }); | |
| 1369 | + | expect(await resp.text()).toContain('Invalid commit'); | |
| 1370 | + | }); | |
| 1371 | + | ||
| 1372 | + | // ── Create ────────────────────────────────────────────────────────────────── | |
| 1373 | + | ||
| 1374 | + | test('create a basic release', async () => { | |
| 1375 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1376 | + | multipart: { | |
| 1377 | + | tag_name: 'v1.0.0', | |
| 1378 | + | name: 'First release', | |
| 1379 | + | commit_hash: commitHash, | |
| 1380 | + | notes: 'Initial stable release.\n\n- Feature A\n- Feature B', | |
| 1381 | + | }, | |
| 1382 | + | maxRedirects: 0, | |
| 1383 | + | }); | |
| 1384 | + | expect(resp.status()).toBe(302); | |
| 1385 | + | const location = resp.headers()['location']!; | |
| 1386 | + | expect(location).toMatch(/\/releases-repo\/releases\/\d+/); | |
| 1387 | + | releaseUrl = `${BASE}${location}`; | |
| 1388 | + | }); | |
| 1389 | + | ||
| 1390 | + | test('duplicate tag name shows error', async () => { | |
| 1391 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1392 | + | multipart: { tag_name: 'v1.0.0', commit_hash: commitHash }, | |
| 1393 | + | }); | |
| 1394 | + | expect(await resp.text()).toContain('already exists'); | |
| 1395 | + | }); | |
| 1396 | + | ||
| 1397 | + | // ── List ──────────────────────────────────────────────────────────────────── | |
| 1398 | + | ||
| 1399 | + | test('release appears in list with tag badge', async () => { | |
| 1400 | + | const page = await adminCtx.newPage(); | |
| 1401 | + | try { | |
| 1402 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1403 | + | expect(await page.locator('.release-item-title').textContent()).toContain('First release'); | |
| 1404 | + | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); | |
| 1405 | + | } finally { await page.close(); } | |
| 1406 | + | }); | |
| 1407 | + | ||
| 1408 | + | test('release list shows commit hash link', async () => { | |
| 1409 | + | const page = await adminCtx.newPage(); | |
| 1410 | + | try { | |
| 1411 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1412 | + | expect(await page.locator('.release-item-meta a.monospace').textContent()) | |
| 1413 | + | .toBe(commitHash.slice(0, 8)); | |
| 1414 | + | } finally { await page.close(); } | |
| 1415 | + | }); | |
| 1416 | + | ||
| 1417 | + | test('new release button hidden for non-admin', async () => { | |
| 1418 | + | const page = await aliceCtx.newPage(); | |
| 1419 | + | try { | |
| 1420 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1421 | + | expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0); | |
| 1422 | + | } finally { await page.close(); } | |
| 1423 | + | }); | |
| 1424 | + | ||
| 1425 | + | // ── Detail ────────────────────────────────────────────────────────────────── | |
| 1426 | + | ||
| 1427 | + | test('release detail shows title, tag badge, and commit link', async () => { | |
| 1428 | + | const page = await adminCtx.newPage(); | |
| 1429 | + | try { | |
| 1430 | + | await page.goto(releaseUrl); | |
| 1431 | + | expect(await page.locator('h2.page-title').textContent()).toBe('First release'); | |
| 1432 | + | expect(await page.locator('.badge').textContent()).toContain('v1.0.0'); | |
| 1433 | + | expect(await page.locator('.release-item-meta a.monospace').textContent()) | |
| 1434 | + | .toBe(commitHash.slice(0, 8)); | |
| 1435 | + | } finally { await page.close(); } | |
| 1436 | + | }); | |
| 1437 | + | ||
| 1438 | + | test('release notes rendered in detail view', async () => { | |
| 1439 | + | const page = await adminCtx.newPage(); | |
| 1440 | + | try { | |
| 1441 | + | await page.goto(releaseUrl); | |
| 1442 | + | expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release'); | |
| 1443 | + | } finally { await page.close(); } | |
| 1444 | + | }); | |
| 1445 | + | ||
| 1446 | + | // ── Source archives ───────────────────────────────────────────────────────── | |
| 1447 | + | ||
| 1448 | + | test('create release with source code archives', async () => { | |
| 1449 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1450 | + | multipart: { tag_name: 'v1.1.0', commit_hash: commitHash, include_source_code: 'on' }, | |
| 1451 | + | maxRedirects: 0, | |
| 1452 | + | }); | |
| 1453 | + | expect(resp.status()).toBe(302); | |
| 1454 | + | const location = resp.headers()['location']!; | |
| 1455 | + | srcReleaseUrl = `${BASE}${location}`; | |
| 1456 | + | }); | |
| 1457 | + | ||
| 1458 | + | test('zip and tar.gz archives appear in downloads', async () => { | |
| 1459 | + | const page = await adminCtx.newPage(); | |
| 1460 | + | try { | |
| 1461 | + | await page.goto(srcReleaseUrl); | |
| 1462 | + | const assetNames = await page.locator('.asset-name').allTextContents(); | |
| 1463 | + | expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true); | |
| 1464 | + | expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true); | |
| 1465 | + | } finally { await page.close(); } | |
| 1466 | + | }); | |
| 1467 | + | ||
| 1468 | + | test('source archive download responds with 200', async () => { | |
| 1469 | + | const page = await adminCtx.newPage(); | |
| 1470 | + | try { | |
| 1471 | + | await page.goto(srcReleaseUrl); | |
| 1472 | + | const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href'); | |
| 1473 | + | const resp = await page.request.get(`${BASE}${zipLink}`); | |
| 1474 | + | expect(resp.status()).toBe(200); | |
| 1475 | + | } finally { await page.close(); } | |
| 1476 | + | }); | |
| 1477 | + | ||
| 1478 | + | // ── File upload ───────────────────────────────────────────────────────────── | |
| 1479 | + | ||
| 1480 | + | test('create release with attached file', async () => { | |
| 1481 | + | const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1482 | + | multipart: { | |
| 1483 | + | tag_name: 'v1.2.0', | |
| 1484 | + | commit_hash: commitHash, | |
| 1485 | + | files: { | |
| 1486 | + | name: 'release-asset.txt', | |
| 1487 | + | mimeType: 'text/plain', | |
| 1488 | + | buffer: Buffer.from('binary-like content for testing\n'), | |
| 1489 | + | }, | |
| 1490 | + | }, | |
| 1491 | + | maxRedirects: 0, | |
| 1492 | + | }); | |
| 1493 | + | expect(resp.status()).toBe(302); | |
| 1494 | + | const location = resp.headers()['location']!; | |
| 1495 | + | releaseWithAssetsUrl = `${BASE}${location}`; | |
| 1496 | + | }); | |
| 1497 | + | ||
| 1498 | + | test('uploaded asset appears in downloads with filename and size', async () => { | |
| 1499 | + | const page = await adminCtx.newPage(); | |
| 1500 | + | try { | |
| 1501 | + | await page.goto(releaseWithAssetsUrl); | |
| 1502 | + | const names = await page.locator('.asset-name').allTextContents(); | |
| 1503 | + | expect(names.some(n => n.includes('release-asset.txt'))).toBe(true); | |
| 1504 | + | expect(await page.locator('.asset-size').isVisible()).toBe(true); | |
| 1505 | + | } finally { await page.close(); } | |
| 1506 | + | }); | |
| 1507 | + | ||
| 1508 | + | test('asset download responds with 200', async () => { | |
| 1509 | + | const page = await adminCtx.newPage(); | |
| 1510 | + | try { | |
| 1511 | + | await page.goto(releaseWithAssetsUrl); | |
| 1512 | + | const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href'); | |
| 1513 | + | const resp = await page.request.get(`${BASE}${link}`); | |
| 1514 | + | expect(resp.status()).toBe(200); | |
| 1515 | + | } finally { await page.close(); } | |
| 1516 | + | }); | |
| 1517 | + | ||
| 1518 | + | // ── Delete ────────────────────────────────────────────────────────────────── | |
| 1519 | + | ||
| 1520 | + | test('non-admin cannot delete a release', async () => { | |
| 1521 | + | const page = await aliceCtx.newPage(); | |
| 1522 | + | try { | |
| 1523 | + | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); | |
| 1524 | + | const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, { | |
| 1525 | + | maxRedirects: 0, | |
| 1526 | + | }); | |
| 1527 | + | expect(resp.status()).toBe(403); | |
| 1528 | + | } finally { await page.close(); } | |
| 1529 | + | }); | |
| 1530 | + | ||
| 1531 | + | test('admin can delete a release', async () => { | |
| 1532 | + | const idMatch = releaseUrl.match(/\/releases\/(\d+)/); | |
| 1533 | + | const resp = await adminCtx.request.post( | |
| 1534 | + | `${BASE}/releases-repo/releases/${idMatch![1]}/delete`, | |
| 1535 | + | { maxRedirects: 0 }, | |
| 1536 | + | ); | |
| 1537 | + | expect(resp.status()).toBe(302); | |
| 1538 | + | // Verify it's gone from the list | |
| 1539 | + | const page = await adminCtx.newPage(); | |
| 1540 | + | try { | |
| 1541 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1542 | + | const titles = await page.locator('.release-item-title').allTextContents(); | |
| 1543 | + | expect(titles.some(t => t.includes('First release'))).toBe(false); | |
| 1544 | + | } finally { await page.close(); } | |
| 1545 | + | }); | |
| 1546 | + | ||
| 1547 | + | // ── Pagination ────────────────────────────────────────────────────────────── | |
| 1548 | + | ||
| 1549 | + | test('release list shows at most 20 per page', async () => { | |
| 1550 | + | // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total | |
| 1551 | + | // regardless of which browser-based tests above succeeded | |
| 1552 | + | for (let i = 1; i <= 25; i++) { | |
| 1553 | + | await adminCtx.request.post(`${BASE}/releases-repo/releases`, { | |
| 1554 | + | multipart: { tag_name: `v9.${i}.0`, commit_hash: commitHash }, | |
| 1555 | + | maxRedirects: 0, | |
| 1556 | + | }).catch(() => {}); | |
| 1557 | + | } | |
| 1558 | + | const page = await adminCtx.newPage(); | |
| 1559 | + | try { | |
| 1560 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1561 | + | expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20); | |
| 1562 | + | } finally { await page.close(); } | |
| 1563 | + | }); | |
| 1564 | + | ||
| 1565 | + | test('pagination nav appears with more than 20 releases', async () => { | |
| 1566 | + | const page = await adminCtx.newPage(); | |
| 1567 | + | try { | |
| 1568 | + | await page.goto(`${BASE}/releases-repo/releases`); | |
| 1569 | + | expect(await page.locator('.pagination').isVisible()).toBe(true); | |
| 1570 | + | } finally { await page.close(); } | |
| 1571 | + | }); | |
| 1572 | + | ||
| 1573 | + | test('release list page 2 shows remaining releases', async () => { | |
| 1574 | + | const page = await adminCtx.newPage(); | |
| 1575 | + | try { | |
| 1576 | + | await page.goto(`${BASE}/releases-repo/releases?page=2`); | |
| 1577 | + | const count = await page.locator('.issue-item').count(); | |
| 1578 | + | expect(count).toBeGreaterThan(0); | |
| 1579 | + | expect(count).toBeLessThanOrEqual(20); | |
| 1580 | + | } finally { await page.close(); } | |
| 1581 | + | }); | |
| 1582 | + | ||
| 1583 | + | test('source archive tar.zst appears in downloads', async () => { | |
| 1584 | + | const page = await adminCtx.newPage(); | |
| 1585 | + | try { | |
| 1586 | + | await page.goto(srcReleaseUrl); | |
| 1587 | + | const assetNames = await page.locator('.asset-name').allTextContents(); | |
| 1588 | + | expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true); | |
| 1589 | + | } finally { await page.close(); } | |
| 1590 | + | }); | |
| 1591 | + | }); | |
| 1592 | + | ||
| 1593 | + | // ─── Settings ───────────────────────────────────────────────────────────────── | |
| 1594 | + | ||
| 1595 | + | describe('settings', () => { | |
| 1596 | + | let adminCtx: BrowserContext; | |
| 1597 | + | let aliceCtx: BrowserContext; | |
| 1598 | + | // Public key generated once in beforeAll, reused across SSH key tests | |
| 1599 | + | let testPubKey: string; | |
| 1600 | + | ||
| 1601 | + | beforeAll(async () => { | |
| 1602 | + | adminCtx = await loggedInContext(); | |
| 1603 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1604 | + | ||
| 1605 | + | // Generate a throwaway ed25519 key for SSH key tests. | |
| 1606 | + | // Use Bun.spawn so the empty passphrase arg is passed correctly. | |
| 1607 | + | const keyPath = '/tmp/hf-e2e-sshkey'; | |
| 1608 | + | await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow(); | |
| 1609 | + | const keygen = Bun.spawn( | |
| 1610 | + | ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'], | |
| 1611 | + | { stdout: 'ignore', stderr: 'ignore' }, | |
| 1612 | + | ); | |
| 1613 | + | await keygen.exited; | |
| 1614 | + | testPubKey = await Bun.file(`${keyPath}.pub`).text(); | |
| 1615 | + | testPubKey = testPubKey.trim(); | |
| 1616 | + | await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow(); | |
| 1617 | + | }); | |
| 1618 | + | ||
| 1619 | + | afterAll(async () => { | |
| 1620 | + | await adminCtx.close(); | |
| 1621 | + | await aliceCtx.close(); | |
| 1622 | + | }); | |
| 1623 | + | ||
| 1624 | + | test('settings page requires auth', async () => { | |
| 1625 | + | const ctx = await browser.newContext(); | |
| 1626 | + | const page = await ctx.newPage(); | |
| 1627 | + | try { | |
| 1628 | + | await page.goto(`${BASE}/settings`); | |
| 1629 | + | expect(page.url()).toContain('/login'); | |
| 1630 | + | } finally { await ctx.close(); } | |
| 1631 | + | }); | |
| 1632 | + | ||
| 1633 | + | test('settings page loads for logged-in user', async () => { | |
| 1634 | + | const page = await adminCtx.newPage(); | |
| 1635 | + | try { | |
| 1636 | + | await page.goto(`${BASE}/settings`); | |
| 1637 | + | expect(await page.locator('h1.page-title').textContent()).toBe('Settings'); | |
| 1638 | + | } finally { await page.close(); } | |
| 1639 | + | }); | |
| 1640 | + | ||
| 1641 | + | // ── Password ────────────────────────────────────────────────────────────── | |
| 1642 | + | ||
| 1643 | + | test('password change with mismatched passwords shows error', async () => { | |
| 1644 | + | const page = await aliceCtx.newPage(); | |
| 1645 | + | try { | |
| 1646 | + | await page.goto(`${BASE}/settings`); | |
| 1647 | + | await page.fill('[name=new_password]', 'newpass123'); | |
| 1648 | + | await page.fill('[name=confirm_password]', 'different456'); | |
| 1649 | + | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 1650 | + | await page.waitForURL(/\/settings/); | |
| 1651 | + | expect(page.url()).toContain('error'); | |
| 1652 | + | } finally { await page.close(); } | |
| 1653 | + | }); | |
| 1654 | + | ||
| 1655 | + | test('password change with wrong current password shows error', async () => { | |
| 1656 | + | const page = await aliceCtx.newPage(); | |
| 1657 | + | try { | |
| 1658 | + | await page.goto(`${BASE}/settings`); | |
| 1659 | + | await page.fill('[name=current_password]', 'wrongpassword'); | |
| 1660 | + | await page.fill('[name=new_password]', 'newpass123'); | |
| 1661 | + | await page.fill('[name=confirm_password]', 'newpass123'); | |
| 1662 | + | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 1663 | + | await page.waitForURL(/\/settings/); | |
| 1664 | + | expect(page.url()).toContain('error'); | |
| 1665 | + | } finally { await page.close(); } | |
| 1666 | + | }); | |
| 1667 | + | ||
| 1668 | + | test('password change too short shows error', async () => { | |
| 1669 | + | const page = await aliceCtx.newPage(); | |
| 1670 | + | try { | |
| 1671 | + | await page.goto(`${BASE}/settings`); | |
| 1672 | + | await page.fill('[name=current_password]', 'password123'); | |
| 1673 | + | await page.fill('[name=new_password]', 'short'); | |
| 1674 | + | await page.fill('[name=confirm_password]', 'short'); | |
| 1675 | + | await page.click('form[action="/settings/password"] button[type=submit]'); | |
| 1676 | + | await page.waitForURL(/\/settings/); | |
| 1677 | + | expect(page.url()).toContain('error'); | |
| 1678 | + | } finally { await page.close(); } | |
| 1679 | + | }); | |
| 1680 | + | ||
| 1681 | + | // ── SSH keys ────────────────────────────────────────────────────────────── | |
| 1682 | + | ||
| 1683 | + | test('add SSH key with unsupported key type shows error', async () => { | |
| 1684 | + | const page = await adminCtx.newPage(); | |
| 1685 | + | try { | |
| 1686 | + | await page.goto(`${BASE}/settings`); | |
| 1687 | + | await page.fill('#ssh_key_name', 'Bad key'); | |
| 1688 | + | await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test'); | |
| 1689 | + | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 1690 | + | await page.waitForURL(/\/settings/); | |
| 1691 | + | expect(page.url()).toContain('error'); | |
| 1692 | + | } finally { await page.close(); } | |
| 1693 | + | }); | |
| 1694 | + | ||
| 1695 | + | test('add valid SSH key shows success and key appears in list', async () => { | |
| 1696 | + | const page = await adminCtx.newPage(); | |
| 1697 | + | try { | |
| 1698 | + | await page.goto(`${BASE}/settings`); | |
| 1699 | + | await page.fill('#ssh_key_name', 'My Laptop'); | |
| 1700 | + | await page.fill('#ssh_public_key', testPubKey); | |
| 1701 | + | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 1702 | + | await page.waitForURL(/\/settings/); | |
| 1703 | + | expect(page.url()).toContain('success=ssh_key_added'); | |
| 1704 | + | await page.goto(`${BASE}/settings`); | |
| 1705 | + | expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop'); | |
| 1706 | + | } finally { await page.close(); } | |
| 1707 | + | }); | |
| 1708 | + | ||
| 1709 | + | test('add duplicate SSH key shows error', async () => { | |
| 1710 | + | const page = await adminCtx.newPage(); | |
| 1711 | + | try { | |
| 1712 | + | await page.goto(`${BASE}/settings`); | |
| 1713 | + | await page.fill('#ssh_key_name', 'Duplicate'); | |
| 1714 | + | await page.fill('#ssh_public_key', testPubKey); | |
| 1715 | + | await page.click('form[action="/settings/ssh-keys"] button[type=submit]'); | |
| 1716 | + | await page.waitForURL(/\/settings/); | |
| 1717 | + | expect(page.url()).toContain('error'); | |
| 1718 | + | } finally { await page.close(); } | |
| 1719 | + | }); | |
| 1720 | + | ||
| 1721 | + | test('delete SSH key removes it from list', async () => { | |
| 1722 | + | const page = await adminCtx.newPage(); | |
| 1723 | + | try { | |
| 1724 | + | await page.goto(`${BASE}/settings`); | |
| 1725 | + | // Click the Remove button for the key added above | |
| 1726 | + | await page.click('form[action="/settings/ssh-keys/delete"] button'); | |
| 1727 | + | await page.waitForURL(/\/settings/); | |
| 1728 | + | expect(page.url()).toContain('success=ssh_key_deleted'); | |
| 1729 | + | await page.goto(`${BASE}/settings`); | |
| 1730 | + | expect(await page.locator('.ssh-key-name').count()).toBe(0); | |
| 1731 | + | } finally { await page.close(); } | |
| 1732 | + | }); | |
| 1733 | + | ||
| 1734 | + | // ── Admin user management ──────────────────────────────────────────────── | |
| 1735 | + | ||
| 1736 | + | test('admin can create a new user account', async () => { | |
| 1737 | + | const page = await adminCtx.newPage(); | |
| 1738 | + | try { | |
| 1739 | + | await page.goto(`${BASE}/settings`); | |
| 1740 | + | await page.fill('#new_username', 'charlie'); | |
| 1741 | + | await page.fill('#new_user_password', 'charliepw1'); | |
| 1742 | + | await page.click('form[action="/admin/users"] button[type=submit]'); | |
| 1743 | + | await page.waitForURL(/\/settings/); | |
| 1744 | + | expect(page.url()).toContain('success=user_created'); | |
| 1745 | + | } finally { await page.close(); } | |
| 1746 | + | }); | |
| 1747 | + | ||
| 1748 | + | test('admin cannot create duplicate username', async () => { | |
| 1749 | + | const page = await adminCtx.newPage(); | |
| 1750 | + | try { | |
| 1751 | + | await page.goto(`${BASE}/settings`); | |
| 1752 | + | await page.fill('#new_username', 'charlie'); | |
| 1753 | + | await page.fill('#new_user_password', 'charliepw1'); | |
| 1754 | + | await page.click('form[action="/admin/users"] button[type=submit]'); | |
| 1755 | + | await page.waitForURL(/\/settings/); | |
| 1756 | + | expect(page.url()).toContain('error'); | |
| 1757 | + | } finally { await page.close(); } | |
| 1758 | + | }); | |
| 1759 | + | ||
| 1760 | + | test('admin cannot create user with invalid username characters', async () => { | |
| 1761 | + | const resp = await adminCtx.request.post(`${BASE}/admin/users`, { | |
| 1762 | + | form: { username: 'bad user!', password: 'password123' }, | |
| 1763 | + | maxRedirects: 0, | |
| 1764 | + | }); | |
| 1765 | + | expect(resp.status()).toBe(302); | |
| 1766 | + | const location = resp.headers()['location'] ?? ''; | |
| 1767 | + | expect(location).toContain('error'); | |
| 1768 | + | }); | |
| 1769 | + | ||
| 1770 | + | test('non-admin gets 403 when creating user', async () => { | |
| 1771 | + | const resp = await aliceCtx.request.post(`${BASE}/admin/users`, { | |
| 1772 | + | form: { username: 'hacker', password: 'password123' }, | |
| 1773 | + | maxRedirects: 0, | |
| 1774 | + | }); | |
| 1775 | + | expect(resp.status()).toBe(403); | |
| 1776 | + | }); | |
| 1777 | + | ||
| 1778 | + | test('admin can delete user account', async () => { | |
| 1779 | + | const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { | |
| 1780 | + | form: { username: 'charlie' }, | |
| 1781 | + | maxRedirects: 0, | |
| 1782 | + | }); | |
| 1783 | + | expect(resp.status()).toBe(302); | |
| 1784 | + | expect(resp.headers()['location']).toContain('success=user_deleted'); | |
| 1785 | + | }); | |
| 1786 | + | ||
| 1787 | + | test('admin cannot delete the admin account', async () => { | |
| 1788 | + | const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, { | |
| 1789 | + | form: { username: 'admin' }, | |
| 1790 | + | maxRedirects: 0, | |
| 1791 | + | }); | |
| 1792 | + | expect(resp.status()).toBe(302); | |
| 1793 | + | expect(resp.headers()['location']).toContain('error'); | |
| 1794 | + | }); | |
| 1795 | + | }); | |
| 1796 | + | ||
| 1797 | + | // ─── Repository deletion ────────────────────────────────────────────────────── | |
| 1798 | + | ||
| 1799 | + | describe('repository deletion', () => { | |
| 1800 | + | let adminCtx: BrowserContext; | |
| 1801 | + | ||
| 1802 | + | beforeAll(async () => { | |
| 1803 | + | adminCtx = await loggedInContext(); | |
| 1804 | + | ||
| 1805 | + | // Create a repo to delete | |
| 1806 | + | const page = await adminCtx.newPage(); | |
| 1807 | + | try { | |
| 1808 | + | await page.goto(`${BASE}/new`); | |
| 1809 | + | await page.fill('[name=name]', 'deleteme-repo'); | |
| 1810 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 1811 | + | await page.waitForURL(`${BASE}/deleteme-repo`); | |
| 1812 | + | } finally { await page.close(); } | |
| 1813 | + | }); | |
| 1814 | + | ||
| 1815 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 1816 | + | ||
| 1817 | + | test('admin can delete repository', async () => { | |
| 1818 | + | const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, { | |
| 1819 | + | maxRedirects: 0, | |
| 1820 | + | }); | |
| 1821 | + | expect(resp.status()).toBe(302); | |
| 1822 | + | expect(resp.headers()['location']).toBe('/'); | |
| 1823 | + | }); | |
| 1824 | + | ||
| 1825 | + | test('deleted repository returns 404', async () => { | |
| 1826 | + | const page = await adminCtx.newPage(); | |
| 1827 | + | try { | |
| 1828 | + | const resp = await page.request.get(`${BASE}/deleteme-repo`); | |
| 1829 | + | expect(resp.status()).toBe(404); | |
| 1830 | + | } finally { await page.close(); } | |
| 1831 | + | }); | |
| 1832 | + | ||
| 1833 | + | test('deleted repository no longer appears in list', async () => { | |
| 1834 | + | const page = await adminCtx.newPage(); | |
| 1835 | + | try { | |
| 1836 | + | await page.goto(BASE); | |
| 1837 | + | expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo'); | |
| 1838 | + | } finally { await page.close(); } | |
| 1839 | + | }); | |
| 1840 | + | ||
| 1841 | + | test('non-admin cannot delete repository', async () => { | |
| 1842 | + | const aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1843 | + | const page = await aliceCtx.newPage(); | |
| 1844 | + | try { | |
| 1845 | + | const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, { | |
| 1846 | + | maxRedirects: 0, | |
| 1847 | + | }); | |
| 1848 | + | expect(resp.status()).toBe(403); | |
| 1849 | + | } finally { | |
| 1850 | + | await page.close(); | |
| 1851 | + | await aliceCtx.close(); | |
| 1852 | + | } | |
| 1853 | + | }); | |
| 1854 | + | }); | |
| 1855 | + | ||
| 1856 | + | // ─── 404 handling ───────────────────────────────────────────────────────────── | |
| 1857 | + | ||
| 1858 | + | describe('404 handling', () => { | |
| 1859 | + | let adminCtx: BrowserContext; | |
| 1860 | + | ||
| 1861 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 1862 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 1863 | + | ||
| 1864 | + | test('non-existent repository returns 404', async () => { | |
| 1865 | + | const page = await adminCtx.newPage(); | |
| 1866 | + | try { | |
| 1867 | + | const resp = await page.request.get(`${BASE}/no-such-repo`); | |
| 1868 | + | expect(resp.status()).toBe(404); | |
| 1869 | + | } finally { await page.close(); } | |
| 1870 | + | }); | |
| 1871 | + | ||
| 1872 | + | test('non-existent issue returns 404', async () => { | |
| 1873 | + | const page = await adminCtx.newPage(); | |
| 1874 | + | try { | |
| 1875 | + | const resp = await page.request.get(`${BASE}/my-repo/issues/99999`); | |
| 1876 | + | expect(resp.status()).toBe(404); | |
| 1877 | + | } finally { await page.close(); } | |
| 1878 | + | }); | |
| 1879 | + | ||
| 1880 | + | test('non-existent commit returns 404', async () => { | |
| 1881 | + | const page = await adminCtx.newPage(); | |
| 1882 | + | try { | |
| 1883 | + | const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`); | |
| 1884 | + | expect(resp.status()).toBe(404); | |
| 1885 | + | } finally { await page.close(); } | |
| 1886 | + | }); | |
| 1887 | + | ||
| 1888 | + | test('non-existent file blob returns 404', async () => { | |
| 1889 | + | const page = await adminCtx.newPage(); | |
| 1890 | + | try { | |
| 1891 | + | const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`); | |
| 1892 | + | expect(resp.status()).toBe(404); | |
| 1893 | + | } finally { await page.close(); } | |
| 1894 | + | }); | |
| 1895 | + | }); | |
| 1896 | + | ||
| 1897 | + | // ─── Issue editing and deletion ─────────────────────────────────────────────── | |
| 1898 | + | ||
| 1899 | + | describe('issue editing', () => { | |
| 1900 | + | let adminCtx: BrowserContext; | |
| 1901 | + | let aliceCtx: BrowserContext; | |
| 1902 | + | let issueUrl: string; | |
| 1903 | + | ||
| 1904 | + | beforeAll(async () => { | |
| 1905 | + | adminCtx = await loggedInContext(); | |
| 1906 | + | aliceCtx = await loggedInContext('alice', 'password123'); | |
| 1907 | + | ||
| 1908 | + | // Create an issue to edit | |
| 1909 | + | const page = await adminCtx.newPage(); | |
| 1910 | + | try { | |
| 1911 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 1912 | + | await page.fill('[name=title]', 'Issue to edit'); | |
| 1913 | + | await page.fill('[name=body]', 'Original body.'); | |
| 1914 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 1915 | + | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 1916 | + | issueUrl = page.url(); | |
| 1917 | + | } finally { await page.close(); } | |
| 1918 | + | }); | |
| 1919 | + | ||
| 1920 | + | afterAll(async () => { | |
| 1921 | + | await adminCtx.close(); | |
| 1922 | + | await aliceCtx.close(); | |
| 1923 | + | }); | |
| 1924 | + | ||
| 1925 | + | test('author can edit issue title and body', async () => { | |
| 1926 | + | const page = await adminCtx.newPage(); | |
| 1927 | + | try { | |
| 1928 | + | await page.goto(issueUrl); | |
| 1929 | + | await page.locator('.timeline-author .inline-edit-details').first().locator('summary').click(); | |
| 1930 | + | await page.fill('.inline-edit-form-area [name=title]', 'Edited issue title'); | |
| 1931 | + | await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.'); | |
| 1932 | + | await page.click('.inline-edit-form-area [type=submit]'); | |
| 1933 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 1934 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title'); | |
| 1935 | + | } finally { await page.close(); } | |
| 1936 | + | }); | |
| 1937 | + | ||
| 1938 | + | test('non-author non-admin cannot edit issue', async () => { | |
| 1939 | + | const page = await aliceCtx.newPage(); | |
| 1940 | + | try { | |
| 1941 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 1942 | + | const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, { | |
| 1943 | + | form: { title: 'Hacked title', edit_body: '' }, | |
| 1944 | + | maxRedirects: 0, | |
| 1945 | + | }); | |
| 1946 | + | expect(resp.status()).toBe(403); | |
| 1947 | + | } finally { await page.close(); } | |
| 1948 | + | }); | |
| 1949 | + | ||
| 1950 | + | test('author can edit issue comment', async () => { | |
| 1951 | + | const page = await adminCtx.newPage(); | |
| 1952 | + | try { | |
| 1953 | + | await page.goto(issueUrl); | |
| 1954 | + | // Add a comment first | |
| 1955 | + | await page.fill('textarea[name=body]', 'Comment to edit.'); | |
| 1956 | + | await page.click('form[action*="/comments"] button[type=submit]'); | |
| 1957 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 1958 | + | ||
| 1959 | + | // Edit the comment | |
| 1960 | + | const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' }); | |
| 1961 | + | await commentItem.locator('.inline-edit-details summary').click(); | |
| 1962 | + | await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.'); | |
| 1963 | + | await commentItem.locator('.inline-edit-form-area [type=submit]').click(); | |
| 1964 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 1965 | + | expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.'); | |
| 1966 | + | } finally { await page.close(); } | |
| 1967 | + | }); | |
| 1968 | + | ||
| 1969 | + | test('non-admin user can create an issue', async () => { | |
| 1970 | + | const page = await aliceCtx.newPage(); | |
| 1971 | + | try { | |
| 1972 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 1973 | + | await page.fill('[name=title]', "Alice's issue"); | |
| 1974 | + | await page.click('form[action$="/issues"] button[type=submit]'); | |
| 1975 | + | await page.waitForURL(/\/my-repo\/issues\/\d+/); | |
| 1976 | + | expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue"); | |
| 1977 | + | } finally { await page.close(); } | |
| 1978 | + | }); | |
| 1979 | + | ||
| 1980 | + | test('non-admin cannot comment on a closed issue', async () => { | |
| 1981 | + | // Close the issue as admin first | |
| 1982 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 1983 | + | await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {}); | |
| 1984 | + | ||
| 1985 | + | const page = await aliceCtx.newPage(); | |
| 1986 | + | try { | |
| 1987 | + | const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, { | |
| 1988 | + | form: { body: 'comment on closed issue' }, | |
| 1989 | + | maxRedirects: 0, | |
| 1990 | + | }); | |
| 1991 | + | // Non-admin gets redirected (silently ignored), not an error | |
| 1992 | + | expect(resp.status()).toBe(302); | |
| 1993 | + | // The comment should NOT appear | |
| 1994 | + | await page.goto(issueUrl); | |
| 1995 | + | const bodies = await page.locator('.timeline-body').allTextContents(); | |
| 1996 | + | expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true); | |
| 1997 | + | } finally { await page.close(); } | |
| 1998 | + | }); | |
| 1999 | + | ||
| 2000 | + | test('admin can delete issue', async () => { | |
| 2001 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 2002 | + | const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, { | |
| 2003 | + | maxRedirects: 0, | |
| 2004 | + | }); | |
| 2005 | + | expect(resp.status()).toBe(302); | |
| 2006 | + | // Issue should be gone | |
| 2007 | + | const page = await adminCtx.newPage(); | |
| 2008 | + | try { | |
| 2009 | + | const checkResp = await page.request.get(issueUrl); | |
| 2010 | + | expect(checkResp.status()).toBe(404); | |
| 2011 | + | } finally { await page.close(); } | |
| 2012 | + | }); | |
| 2013 | + | }); | |
| 2014 | + | ||
| 2015 | + | // ─── Repository description update ─────────────────────────────────────────── | |
| 2016 | + | ||
| 2017 | + | describe('repo description', () => { | |
| 2018 | + | let adminCtx: BrowserContext; | |
| 2019 | + | ||
| 2020 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 2021 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 2022 | + | ||
| 2023 | + | test('updating repo description is reflected on list page', async () => { | |
| 2024 | + | const page = await adminCtx.newPage(); | |
| 2025 | + | try { | |
| 2026 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 2027 | + | await page.fill('[name=description]', 'A freshly updated description'); | |
| 2028 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2029 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2030 | + | ||
| 2031 | + | await page.goto(BASE); | |
| 2032 | + | const desc = await page.locator('.repo-description').allTextContents(); | |
| 2033 | + | expect(desc.some(d => d.includes('freshly updated description'))).toBe(true); | |
| 2034 | + | } finally { await page.close(); } | |
| 2035 | + | }); | |
| 2036 | + | }); | |
Atests/helpers.ts
| @@ -0,0 +1,146 @@ | |||
|---|---|---|---|
| 1 | + | import { $ } from 'bun'; | |
| 2 | + | import { createServer } from 'net'; | |
| 3 | + | import { rmSync, mkdirSync, writeFileSync } from 'fs'; | |
| 4 | + | import path from 'path'; | |
| 5 | + | import type { Page } from 'playwright'; | |
| 6 | + | ||
| 7 | + | export const DATA_DIR = './data-test'; | |
| 8 | + | export const ADMIN_PASS = 'testpass123'; | |
| 9 | + | ||
| 10 | + | /** Bind to port 0 and return the OS-assigned free port number. */ | |
| 11 | + | function getFreePort(): Promise<number> { | |
| 12 | + | return new Promise((resolve, reject) => { | |
| 13 | + | const srv = createServer(); | |
| 14 | + | srv.listen(0, '127.0.0.1', () => { | |
| 15 | + | const port = (srv.address() as { port: number }).port; | |
| 16 | + | srv.close((err) => (err ? reject(err) : resolve(port))); | |
| 17 | + | }); | |
| 18 | + | srv.on('error', reject); | |
| 19 | + | }); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Resolved once in setupTestEnv() and re-exported for tests. | |
| 23 | + | export let PORT = 0; | |
| 24 | + | export let BASE = ''; | |
| 25 | + | ||
| 26 | + | export async function setupTestEnv() { | |
| 27 | + | PORT = await getFreePort(); | |
| 28 | + | BASE = `http://localhost:${PORT}`; | |
| 29 | + | ||
| 30 | + | rmSync(DATA_DIR, { recursive: true, force: true }); | |
| 31 | + | mkdirSync(`${DATA_DIR}/repos`, { recursive: true }); | |
| 32 | + | ||
| 33 | + | const init = Bun.spawn(['bun', 'run', 'src/db/init.ts'], { | |
| 34 | + | env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS }, | |
| 35 | + | stdout: 'pipe', | |
| 36 | + | stderr: 'pipe', | |
| 37 | + | }); | |
| 38 | + | await init.exited; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | export function spawnServer() { | |
| 42 | + | return Bun.spawn(['bun', 'run', 'src/index.tsx'], { | |
| 43 | + | env: { | |
| 44 | + | ...process.env, | |
| 45 | + | PORT: String(PORT), | |
| 46 | + | DATA_DIR, | |
| 47 | + | RATE_LIMIT_DISABLED: 'true', | |
| 48 | + | SSH_DISABLED: 'true', | |
| 49 | + | }, | |
| 50 | + | stdout: 'ignore', | |
| 51 | + | stderr: 'ignore', | |
| 52 | + | }); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | export async function waitForServer(maxMs = 10_000) { | |
| 56 | + | const deadline = Date.now() + maxMs; | |
| 57 | + | while (Date.now() < deadline) { | |
| 58 | + | try { | |
| 59 | + | await fetch(BASE); | |
| 60 | + | return; | |
| 61 | + | } catch { | |
| 62 | + | await Bun.sleep(150); | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | throw new Error('Server did not start in time'); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | export async function login( | |
| 69 | + | page: Page, | |
| 70 | + | username = 'admin', | |
| 71 | + | password = ADMIN_PASS, | |
| 72 | + | ) { | |
| 73 | + | await page.goto(`${BASE}/login`); | |
| 74 | + | await page.fill('[name=username]', username); | |
| 75 | + | await page.fill('[name=password]', password); | |
| 76 | + | await page.click('button[type=submit]'); | |
| 77 | + | await page.waitForURL(BASE + '/'); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | export async function logout(page: Page) { | |
| 81 | + | await page.click('form[action="/logout"] button'); | |
| 82 | + | await page.waitForURL(BASE + '/'); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /** Push an initial commit into a bare repo that already exists on disk. */ | |
| 86 | + | export async function seedRepo(name: string) { | |
| 87 | + | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; | |
| 88 | + | const tmp = `/tmp/hf-seed-${Date.now()}`; | |
| 89 | + | try { | |
| 90 | + | await $`git clone ${repoPath} ${tmp}`.quiet(); | |
| 91 | + | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); | |
| 92 | + | await $`git -C ${tmp} config user.name "Test"`.quiet(); | |
| 93 | + | writeFileSync(`${tmp}/README.md`, `# ${name}\n`); | |
| 94 | + | writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`); | |
| 95 | + | await $`git -C ${tmp} add -A`.quiet(); | |
| 96 | + | await $`git -C ${tmp} commit -m "Initial commit"`.quiet(); | |
| 97 | + | await $`git -C ${tmp} push origin HEAD:main`.quiet(); | |
| 98 | + | } finally { | |
| 99 | + | await $`rm -rf ${tmp}`.quiet().nothrow(); | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | export function writeTempFile(path: string, content: string) { | |
| 104 | + | writeFileSync(path, content); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** Commit a subdirectory with the given files into an existing repo on main. */ | |
| 108 | + | export async function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) { | |
| 109 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; | |
| 110 | + | const tmp = `/tmp/hf-subdir-${Date.now()}`; | |
| 111 | + | try { | |
| 112 | + | await $`git clone ${repoDir} ${tmp}`.quiet(); | |
| 113 | + | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); | |
| 114 | + | await $`git -C ${tmp} config user.name "Test"`.quiet(); | |
| 115 | + | mkdirSync(path.join(tmp, dirPath), { recursive: true }); | |
| 116 | + | for (const [fileName, content] of Object.entries(files)) { | |
| 117 | + | writeFileSync(path.join(tmp, dirPath, fileName), content); | |
| 118 | + | } | |
| 119 | + | await $`git -C ${tmp} add -A`.quiet(); | |
| 120 | + | await $`git -C ${tmp} commit -m ${'Add ' + dirPath}`.quiet(); | |
| 121 | + | await $`git -C ${tmp} push origin HEAD:main`.quiet(); | |
| 122 | + | } finally { | |
| 123 | + | await $`rm -rf ${tmp}`.quiet().nothrow(); | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /** Return the HEAD commit hash of a repo. */ | |
| 128 | + | export async function getHeadCommit(repoName: string): Promise<string> { | |
| 129 | + | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`; | |
| 130 | + | return (await $`git -C ${repoPath} rev-parse HEAD`.quiet()).text().trim(); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /** Create a new branch in an existing repo (from current HEAD). */ | |
| 134 | + | export async function seedBranch(name: string, branchName: string) { | |
| 135 | + | const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`; | |
| 136 | + | const tmp = `/tmp/hf-branch-${Date.now()}`; | |
| 137 | + | try { | |
| 138 | + | await $`git clone ${repoPath} ${tmp}`.quiet(); | |
| 139 | + | await $`git -C ${tmp} config user.email "test@test.com"`.quiet(); | |
| 140 | + | await $`git -C ${tmp} config user.name "Test"`.quiet(); | |
| 141 | + | await $`git -C ${tmp} checkout -b ${branchName}`.quiet(); | |
| 142 | + | await $`git -C ${tmp} push origin ${branchName}`.quiet(); | |
| 143 | + | } finally { | |
| 144 | + | await $`rm -rf ${tmp}`.quiet().nothrow(); | |
| 145 | + | } | |
| 146 | + | } | |
Atests/highlight.test.ts
| @@ -0,0 +1,37 @@ | |||
|---|---|---|---|
| 1 | + | import { describe, test, expect, beforeAll } from "bun:test"; | |
| 2 | + | import { detectLang, highlightStartup } from "../src/services/highlight.ts"; | |
| 3 | + | ||
| 4 | + | beforeAll(async () => { | |
| 5 | + | await highlightStartup(); | |
| 6 | + | }); | |
| 7 | + | ||
| 8 | + | describe("detectLang", () => { | |
| 9 | + | // Extensions | |
| 10 | + | test("detects TypeScript", () => expect(detectLang("foo.ts")).toBe("typescript")); | |
| 11 | + | test("detects TSX", () => expect(detectLang("foo.tsx")).toBe("tsx")); | |
| 12 | + | test("detects JavaScript", () => expect(detectLang("foo.js")).toBe("javascript")); | |
| 13 | + | test("detects Python", () => expect(detectLang("foo.py")).toBe("python")); | |
| 14 | + | test("detects Rust", () => expect(detectLang("foo.rs")).toBe("rust")); | |
| 15 | + | test("detects Go", () => expect(detectLang("foo.go")).toBe("go")); | |
| 16 | + | test("detects C", () => expect(detectLang("foo.c")).toBe("c")); | |
| 17 | + | test("detects C++", () => expect(detectLang("foo.cpp")).toBe("cpp")); | |
| 18 | + | test("detects C header", () => expect(detectLang("foo.h")).toBe("c")); | |
| 19 | + | test("detects YAML", () => expect(detectLang("foo.yml")).toBe("yaml")); | |
| 20 | + | test("detects TOML", () => expect(detectLang("foo.toml")).toBe("toml")); | |
| 21 | + | test("detects JSON", () => expect(detectLang("foo.json")).toBe("json")); | |
| 22 | + | test("detects Markdown", () => expect(detectLang("foo.md")).toBe("markdown")); | |
| 23 | + | test("detects Shell", () => expect(detectLang("foo.sh")).toBe("shellscript")); | |
| 24 | + | test("detects diff/patch", () => expect(detectLang("foo.patch")).toBe("diff")); | |
| 25 | + | ||
| 26 | + | // Specific filenames | |
| 27 | + | test("detects Dockerfile by filename", () => expect(detectLang("Dockerfile")).toBe("docker")); | |
| 28 | + | test("detects Makefile by filename", () => expect(detectLang("Makefile")).toBe("make")); | |
| 29 | + | ||
| 30 | + | // Path stripping | |
| 31 | + | test("strips path before detecting", () => expect(detectLang("src/foo/bar.ts")).toBe("typescript")); | |
| 32 | + | test("strips path for Dockerfile", () => expect(detectLang("infra/Dockerfile")).toBe("docker")); | |
| 33 | + | ||
| 34 | + | // Fallback | |
| 35 | + | test("falls back to text for unknown extension", () => expect(detectLang("foo.xyz")).toBe("text")); | |
| 36 | + | test("falls back to text for no extension", () => expect(detectLang("LICENSE")).toBe("text")); | |
| 37 | + | }); | |
Atsconfig.json
| @@ -0,0 +1,30 @@ | |||
|---|---|---|---|
| 1 | + | { | |
| 2 | + | "compilerOptions": { | |
| 3 | + | // Environment setup & latest features | |
| 4 | + | "lib": ["ESNext"], | |
| 5 | + | "target": "ESNext", | |
| 6 | + | "module": "Preserve", | |
| 7 | + | "moduleDetection": "force", | |
| 8 | + | "jsx": "react-jsx", | |
| 9 | + | "jsxImportSource": "@kitajs/html", | |
| 10 | + | "allowJs": true, | |
| 11 | + | ||
| 12 | + | // Bundler mode | |
| 13 | + | "moduleResolution": "bundler", | |
| 14 | + | "allowImportingTsExtensions": true, | |
| 15 | + | "verbatimModuleSyntax": true, | |
| 16 | + | "noEmit": true, | |
| 17 | + | ||
| 18 | + | // Best practices | |
| 19 | + | "strict": true, | |
| 20 | + | "skipLibCheck": true, | |
| 21 | + | "noFallthroughCasesInSwitch": true, | |
| 22 | + | "noUncheckedIndexedAccess": true, | |
| 23 | + | "noImplicitOverride": true, | |
| 24 | + | ||
| 25 | + | // Some stricter flags (disabled by default) | |
| 26 | + | "noUnusedLocals": false, | |
| 27 | + | "noUnusedParameters": false, | |
| 28 | + | "noPropertyAccessFromIndexSignature": false | |
| 29 | + | } | |
| 30 | + | } | |