rin
Public Open-source lightweight git hosting.

Initial commit: Polar - lightweight self-hosted Git server

99db92d Browse files
R rin · 2026-10-16 15:30 UTC
95 files changed +17314 -0
.gitattributes
+1 -0
... ... @@ -0,0 +1 @@
1 +* text=auto eol=lf
.gitignore
+23 -0
... ... @@ -0,0 +1,23 @@
1 +# Binaries and build output
2 +/dist/
3 +/polar
4 +/polar.exe
5 +*.exe
6 +*.test
7 +
8 +# Runtime data and local configuration
9 +/data/
10 +/polar.toml
11 +*.local.toml
12 +
13 +# Development scratch
14 +/.shots/
15 +/tmpdbg/
16 +/.tmp_*
17 +
18 +# Editors and OS
19 +.DS_Store
20 +Thumbs.db
21 +.idea/
22 +.vscode/
23 +*.swp
Dockerfile
+24 -0
... ... @@ -0,0 +1,24 @@
1 +# Stage 1: Build static Go binary
2 +FROM golang:1.23-alpine AS builder
3 +WORKDIR /src
4 +COPY go.mod go.sum ./
5 +RUN go mod download
6 +COPY . .
7 +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/polar ./cmd/polar
8 +
9 +# Stage 2: Minimal runtime image with git
10 +FROM alpine:3.20
11 +RUN apk add --no-cache git ca-certificates tzdata && \
12 + adduser -D -u 10001 polar
13 +WORKDIR /app
14 +COPY --from=builder /out/polar /app/polar
15 +RUN mkdir -p /app/data && chown -R polar:polar /app
16 +
17 +USER polar
18 +EXPOSE 3000
19 +VOLUME ["/app/data"]
20 +
21 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
22 + CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
23 +
24 +ENTRYPOINT ["/app/polar"]
Makefile
+32 -0
... ... @@ -0,0 +1,32 @@
1 +.PHONY: build run dev css test lint build-all clean
2 +
3 +BINARY ?= polar
4 +TAILWIND_CLI ?= tailwindcss
5 +
6 +build:
7 + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o $(BINARY) ./cmd/polar
8 +
9 +run: build
10 + ./$(BINARY)
11 +
12 +dev:
13 + CGO_ENABLED=0 go run ./cmd/polar
14 +
15 +css:
16 + $(TAILWIND_CLI) -i ./assets/input.css -o ./internal/web/static/css/app.css --minify
17 +
18 +test:
19 + CGO_ENABLED=0 go test -v -race=false ./...
20 +
21 +lint:
22 + go vet ./...
23 +
24 +build-all:
25 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/polar-linux-amd64 ./cmd/polar
26 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/polar-linux-arm64 ./cmd/polar
27 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/polar-darwin-amd64 ./cmd/polar
28 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/polar-darwin-arm64 ./cmd/polar
29 + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/polar-windows-amd64.exe ./cmd/polar
30 +
31 +clean:
32 + rm -rf $(BINARY) $(BINARY).exe dist/
README.md
+220 -0
... ... @@ -0,0 +1,220 @@
1 +# Polar
2 +
3 +**Lightweight self-hosted Git hosting in a single Go binary.**
4 +
5 +Polar is a small, fast alternative to heavyweight Git servers: push over HTTP, browse code, file issues, open pull requests, and publish releases — all served from one static binary with an embedded SQLite database and no external runtime dependencies.
6 +
7 +## Highlights
8 +
9 +- **One binary, zero dependencies** — `CGO_ENABLED=0` build, pure-Go SQLite (`modernc.org/sqlite`) in WAL mode, bare repositories stored on disk.
10 +- **Git Smart HTTP** — anonymous read access for public repositories, token-authenticated push, per-user personal access tokens, and browser-assisted sign-in via the OAuth 2.0 Device Authorization Grant (RFC 8628).
11 +- **Code browsing** — file tree, blob viewer with server-side syntax highlighting (Chroma), commit history, diffs, branches, tags, and zip archives.
12 +- **Collaboration** — issues with comments and open/close state; pull requests with comments, approvals and change requests; forks with cross-fork pull requests; per-repository collaborators with read/write roles; and releases with changelogs.
13 +- **Polished UI** — `html/template` + htmx, no SPA build step; Markdown rendered with Goldmark and sanitized with bluemonday; Hugeicons outline sprite; self-hosted Inter and JetBrains Mono fonts (Cyrillic subsets included).
14 +- **Command palette** — press `Ctrl+K` / `Cmd+K` for quick navigation and actions.
15 +- **Three themes** — Light (`#fff` background, `#111` foreground), Dark (`#111` background, `#fff` foreground), and System (follows the OS `prefers-color-scheme`). The choice is persisted in a `polar_theme` cookie so the server renders the correct `data-theme` on first paint — no flash of the wrong theme.
16 +- **Bilingual interface** — Russian and English, with a configurable default language.
17 +- **Practical security** — Argon2id password hashing, CSRF protection, rate-limited login and registration, session cookies, and revocable access tokens.
18 +
19 +## Quick Start
20 +
21 +### Build from source
22 +
23 +Requirements: Go (version per `go.mod`) and `git` available on the host.
24 +
25 +```bash
26 +CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o polar ./cmd/polar
27 +./polar
28 +```
29 +
30 +Polar starts on `0.0.0.0:3000` and keeps its data in `./data`. Open http://localhost:3000 and register — **the first registered account becomes the administrator**. After that you can disable public registration or manage users from the admin panel.
31 +
32 +### Docker
33 +
34 +```bash
35 +docker compose up -d
36 +```
37 +
38 +Or without Compose:
39 +
40 +```bash
41 +docker build -t polar .
42 +docker run -d --name polar -p 3000:3000 -v "$PWD/data:/app/data" polar
43 +```
44 +
45 +### systemd
46 +
47 +```ini
48 +# /etc/systemd/system/polar.service
49 +[Unit]
50 +Description=Polar Self-Hosted Git Server
51 +After=network.target
52 +
53 +[Service]
54 +Type=simple
55 +User=polar
56 +Group=polar
57 +WorkingDirectory=/var/lib/polar
58 +ExecStart=/usr/local/bin/polar -config /etc/polar/polar.toml
59 +Environment=POLAR_STORAGE_DATA_DIR=/var/lib/polar/data
60 +Restart=on-failure
61 +
62 +[Install]
63 +WantedBy=multi-user.target
64 +```
65 +
66 +## Configuration
67 +
68 +Polar runs with sensible defaults and no config file. To customize, copy `polar.example.toml` to `polar.toml` (auto-detected in the working directory) or pass `-config /path/to/polar.toml`. Every setting can also be provided through environment variables, which take precedence over the file.
69 +
70 +```toml
71 +[server]
72 +host = "0.0.0.0"
73 +port = 3000
74 +base_url = "http://localhost:3000"
75 +
76 +[storage]
77 +data_dir = "./data"
78 +
79 +[auth]
80 +registration_enabled = true
81 +
82 +[landing]
83 +enabled = true
84 +default_lang = "en" # "en" or "ru"
85 +```
86 +
87 +| Environment variable | Description | Default |
88 +|---|---|---|
89 +| `POLAR_SERVER_HOST` | HTTP listen host | `0.0.0.0` |
90 +| `POLAR_SERVER_PORT` | HTTP listen port | `3000` |
91 +| `POLAR_SERVER_BASE_URL` | Public URL used for clone links | `http://localhost:3000` |
92 +| `POLAR_STORAGE_DATA_DIR` | Directory for the database and bare repositories | `./data` |
93 +| `POLAR_AUTH_REGISTRATION_ENABLED` | Allow public registration | `true` |
94 +| `POLAR_LANDING_ENABLED` | Serve the landing page at `/` | `true` |
95 +| `POLAR_LANDING_DEFAULT_LANG` | Default UI language (`ru` or `en`) | `ru` |
96 +| `POLAR_CONFIG` | Path to the configuration file | `polar.toml` if present |
97 +
98 +Short aliases are also accepted: `POLAR_HOST`, `POLAR_PORT`, `POLAR_BASE_URL`, `POLAR_DATA_DIR`.
99 +
100 +## Git over HTTP
101 +
102 +Clone a public repository:
103 +
104 +```bash
105 +git clone http://localhost:3000/rin/demo.git
106 +```
107 +
108 +Push using a personal access token (create one in **Settings → Access tokens**; it is shown once and looks like `polar_...`):
109 +
110 +```bash
111 +git clone http://localhost:3000/rin/demo.git
112 +cd demo
113 +git remote set-url origin http://rin:polar_XXXXXXXXXX@localhost:3000/rin/demo.git
114 +git push
115 +```
116 +
117 +When git asks for credentials, use your username as the login and the token as the password. Tokens can be revoked at any time from the settings page.
118 +
119 +### Browser-assisted sign-in (device flow)
120 +
121 +Instead of pasting a token, let the bundled credential helper obtain one through your browser. Polar implements the OAuth 2.0 Device Authorization Grant (RFC 8628):
122 +
123 +1. `git push` asks for credentials; the helper requests a device code from `POST /api/v1/device/code`.
124 +2. It opens `{base_url}/login/device?user_code=XXXX-XXXX` in your browser — sign in and approve (or deny) the request.
125 +3. The helper polls `POST /api/v1/device/token`, receives a personal access token, and caches it in `~/.polar/credentials.json`.
126 +
127 +Register the helper once, pointing the path at your Polar checkout:
128 +
129 +```bash
130 +# Linux / macOS
131 +git config --global credential.helper '!sh /path/to/polar/scripts/git-credential-polar.sh'
132 +
133 +# Windows (PowerShell)
134 +git config --global credential.helper "!powershell -NoProfile -ExecutionPolicy Bypass -File C:/path/to/polar/scripts/git-credential-polar.ps1"
135 +```
136 +
137 +Device codes expire after 15 minutes, are single-use, and the issued token can be revoked like any other. Set `POLAR_URL` to override the server address detected from the git remote.
138 +
139 +## Forks, Collaborators and Reviews
140 +
141 +**Forks.** The **Fork** button on any repository page copies the project to your account. Forking a fork re-roots the copy to the upstream repository, and every fork records its origin — the repository page shows a `forked from` line linking back. If you already have a fork, the button takes you to it instead of creating a duplicate.
142 +
143 +**Cross-fork pull requests.** Open a pull request from any branch of your fork into the base repository. The PR page marks the head repository, and merging a cross-fork pull request first fetches the head commits into the base repository, so no commits are lost.
144 +
145 +**Collaborators.** Repository owners and instance administrators manage access in **Settings → Collaborators**: add any registered account with a `read` or `write` role, change roles, or remove access at any time.
146 +
147 +**Reviews.** On an open pull request, reviewers submit an **Approve** or **Request changes** verdict — reviewing your own pull request is not allowed. Each reviewer has one current verdict, and the review panel lists them all.
148 +
149 +## Themes
150 +
151 +Polar ships exactly three themes, selectable from the theme switcher (or the command palette):
152 +
153 +| Theme | Background | Foreground |
154 +|---|---|---|
155 +| Light | `#fff` | `#111` |
156 +| Dark | `#111` | `#fff` |
157 +| System | follows the OS `prefers-color-scheme` | — |
158 +
159 +The active theme is saved to `localStorage` and a `polar_theme` cookie, so the server renders `<html data-theme="...">` immediately and there is no flash of the wrong theme on load.
160 +
161 +## Project Layout
162 +
163 +```
164 +polar/
165 +├── cmd/polar/main.go # Entry point, dependency wiring, graceful shutdown
166 +├── internal/
167 +│ ├── config/ # polar.toml + POLAR_* environment overrides
168 +│ ├── db/ # SQLite connection and migrations
169 +│ ├── auth/ # Sessions, CSRF, Argon2id, access tokens
170 +│ ├── user/ # User accounts and profiles
171 +│ ├── repo/ # Repositories, issues, pull requests, releases
172 +│ ├── git/ # Git plumbing (bare repos, commits, diffs)
173 +│ ├── i18n/ # Russian and English message catalogs
174 +│ └── web/
175 +│ ├── handlers/ # HTTP and Git Smart HTTP handlers
176 +│ ├── middleware/ # Auth, CSRF, rate limiting, i18n
177 +│ ├── views/ # html/template views
178 +│ └── static/ # go:embed assets (CSS, JS, fonts, icons)
179 +├── assets/input.css # Tailwind source for the generated stylesheet
180 +├── scripts/ # git credential helpers (device-flow sign-in)
181 +├── polar.example.toml
182 +├── Makefile
183 +├── Dockerfile
184 +└── docker-compose.yml
185 +```
186 +
187 +## Development
188 +
189 +```bash
190 +make dev # run locally
191 +make test # go test ./...
192 +make lint # go vet ./...
193 +make build # static binary with -trimpath -ldflags="-s -w"
194 +make build-all # cross-compile Linux/macOS/Windows binaries into dist/
195 +make css # rebuild internal/web/static/css/app.css (requires the Tailwind CLI)
196 +```
197 +
198 +All runtime assets are embedded with `go:embed` — the binary needs no external CDN or asset directory. See `THIRD_PARTY.md` for licenses of bundled assets (Hugeicons icons, Inter and JetBrains Mono fonts, htmx) and Go dependencies.
199 +
200 +## Data and Backups
201 +
202 +```
203 +data/
204 +├── polar.db # SQLite database (WAL mode)
205 +└── repos/ # Bare Git repositories, e.g. repos/rin/demo.git
206 +```
207 +
208 +Safe backup while Polar is running:
209 +
210 +```bash
211 +sqlite3 data/polar.db "VACUUM INTO 'data/polar-backup.db';"
212 +tar -czf polar-backup-$(date +%F).tar.gz data/polar-backup.db data/repos/
213 +rm data/polar-backup.db
214 +```
215 +
216 +To restore, stop Polar, replace `data/polar.db` and `data/repos/` with the backup contents, and start it again.
217 +
218 +## Health Check
219 +
220 +`GET /healthz` returns JSON with the database status — handy for Docker health checks and load balancers.
THIRD_PARTY.md
+56 -0
... ... @@ -0,0 +1,56 @@
1 +# Third-Party Licenses & Attributions
2 +
3 +Polar embeds all runtime assets into a single binary without external CDN requests. Below is the license information for all bundled third-party assets and libraries.
4 +
5 +---
6 +
7 +## 1. Hugeicons Free Icons (`@hugeicons/core-free-icons`)
8 +
9 +- **Source:** https://github.com/hugeicons/hugeicons-react / `@hugeicons/core-free-icons` (https://hugeicons.com)
10 +- **Usage in Polar:** Selected stroke icons exported as an inline SVG `<symbol>` sprite in `internal/web/static/icons/hugeicons-sprite.svg`.
11 +- **License:** MIT License
12 +
13 +```text
14 +MIT License
15 +
16 +Copyright (c) Hugeicons (https://hugeicons.com)
17 +
18 +Permission is hereby granted, free of charge, to any person obtaining a copy
19 +of this software and associated documentation files (the "Software"), to deal
20 +in the Software without restriction, including without limitation the rights
21 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
22 +copies of the Software, and to permit persons to whom the Software is
23 +furnished to do so, subject to the following conditions:
24 +
25 +The above copyright notice and this permission notice shall be included in all
26 +copies or substantial portions of the Software.
27 +
28 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
29 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
30 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
31 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
32 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
33 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
34 +SOFTWARE.
35 +```
36 +
37 +---
38 +
39 +## 2. Fonts (Inter & JetBrains Mono)
40 +
41 +- **Inter** — Copyright (c) The Inter Project Authors (Rasmus Andersson), licensed under the **SIL Open Font License 1.1 (OFL-1.1)**.
42 +- **JetBrains Mono** — Copyright (c) JetBrains s.r.o., licensed under the **SIL Open Font License 1.1 (OFL-1.1)**.
43 +
44 +---
45 +
46 +## 3. Go Dependencies
47 +
48 +| Package | License | Purpose |
49 +|---|---|---|
50 +| `github.com/gofiber/fiber/v2` | MIT | HTTP router, middleware, static server |
51 +| `modernc.org/sqlite` | BSD-3-Clause | Pure-Go SQLite driver (no CGO) |
52 +| `github.com/BurntSushi/toml` | MIT | Configuration file (`polar.toml`) parser |
53 +| `golang.org/x/crypto` | BSD-3-Clause | Argon2id password hashing |
54 +| `github.com/yuin/goldmark` | MIT | CommonMark / GFM Markdown parser |
55 +| `github.com/microcosm-cc/bluemonday` | BSD-3-Clause | HTML sanitizer for rendered Markdown |
56 +| `github.com/alecthomas/chroma/v2` | MIT | Server-side syntax highlighting |
assets/input.css
+1354 -0
... ... @@ -0,0 +1,1354 @@
1 +/* Polar Design System — Minimalist Sidebar Layout, 3 Themes (light, dark, system) */
2 +
3 +@font-face {
4 + font-family: 'Inter';
5 + font-style: normal;
6 + font-weight: 400 600;
7 + font-display: swap;
8 + src: url('/static/fonts/Inter-Regular.woff2') format('woff2');
9 +}
10 +
11 +@font-face {
12 + font-family: 'Inter';
13 + font-style: normal;
14 + font-weight: 400 600;
15 + font-display: swap;
16 + src: url('/static/fonts/Inter-Cyrillic.woff2') format('woff2');
17 + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
18 +}
19 +
20 +@font-face {
21 + font-family: 'JetBrains Mono';
22 + font-style: normal;
23 + font-weight: 400 600;
24 + font-display: swap;
25 + src: url('/static/fonts/JetBrainsMono-Regular.woff2') format('woff2');
26 +}
27 +
28 +:root,
29 +html[data-theme="light"] {
30 + --background: 0 0% 100%;
31 + --foreground: 0 0% 6.7%;
32 + --card: 0 0% 98%;
33 + --card-foreground: 0 0% 6.7%;
34 + --popover: 0 0% 100%;
35 + --popover-foreground: 0 0% 6.7%;
36 + --primary: 0 0% 6.7%;
37 + --primary-foreground: 0 0% 100%;
38 + --secondary: 0 0% 95%;
39 + --secondary-foreground: 0 0% 6.7%;
40 + --muted: 0 0% 95%;
41 + --muted-foreground: 0 0% 45%;
42 + --accent: 0 0% 93%;
43 + --accent-foreground: 0 0% 6.7%;
44 + --destructive: 0 72% 46%;
45 + --destructive-foreground: 0 0% 100%;
46 + --border: 0 0% 90%;
47 + --input: 0 0% 89%;
48 + --ring: 0 0% 6.7%;
49 + --radius: 0.45rem;
50 +
51 + --diff-add-bg: 142 70% 94%;
52 + --diff-add-fg: 142 76% 22%;
53 + --diff-del-bg: 0 86% 95%;
54 + --diff-del-fg: 0 72% 35%;
55 +
56 + --syntax-kw: 262 72% 42%;
57 + --syntax-str: 142 68% 28%;
58 + --syntax-cmt: 0 0% 46%;
59 + --syntax-fn: 215 80% 42%;
60 + --syntax-num: 24 85% 38%;
61 + --syntax-type: 190 78% 32%;
62 + --syntax-op: 0 0% 25%;
63 + color-scheme: light;
64 +}
65 +
66 +html[data-theme="dark"] {
67 + --background: 0 0% 6.7%;
68 + --foreground: 0 0% 100%;
69 + --card: 0 0% 9%;
70 + --card-foreground: 0 0% 100%;
71 + --popover: 0 0% 9.5%;
72 + --popover-foreground: 0 0% 100%;
73 + --primary: 0 0% 100%;
74 + --primary-foreground: 0 0% 6.7%;
75 + --secondary: 0 0% 13%;
76 + --secondary-foreground: 0 0% 100%;
77 + --muted: 0 0% 12%;
78 + --muted-foreground: 0 0% 65%;
79 + --accent: 0 0% 15%;
80 + --accent-foreground: 0 0% 100%;
81 + --destructive: 0 72% 58%;
82 + --destructive-foreground: 0 0% 98%;
83 + --border: 0 0% 16%;
84 + --input: 0 0% 17%;
85 + --ring: 0 0% 85%;
86 +
87 + --diff-add-bg: 142 50% 12%;
88 + --diff-add-fg: 142 70% 75%;
89 + --diff-del-bg: 0 55% 14%;
90 + --diff-del-fg: 0 80% 78%;
91 +
92 + --syntax-kw: 265 85% 78%;
93 + --syntax-str: 142 65% 70%;
94 + --syntax-cmt: 0 0% 54%;
95 + --syntax-fn: 210 90% 74%;
96 + --syntax-num: 32 90% 72%;
97 + --syntax-type: 188 80% 68%;
98 + --syntax-op: 0 0% 78%;
99 + color-scheme: dark;
100 +}
101 +
102 +@media (prefers-color-scheme: dark) {
103 + html[data-theme="system"] {
104 + --background: 0 0% 6.7%;
105 + --foreground: 0 0% 100%;
106 + --card: 0 0% 9%;
107 + --card-foreground: 0 0% 100%;
108 + --popover: 0 0% 9.5%;
109 + --popover-foreground: 0 0% 100%;
110 + --primary: 0 0% 100%;
111 + --primary-foreground: 0 0% 6.7%;
112 + --secondary: 0 0% 13%;
113 + --secondary-foreground: 0 0% 100%;
114 + --muted: 0 0% 12%;
115 + --muted-foreground: 0 0% 65%;
116 + --accent: 0 0% 15%;
117 + --accent-foreground: 0 0% 100%;
118 + --destructive: 0 72% 58%;
119 + --destructive-foreground: 0 0% 98%;
120 + --border: 0 0% 16%;
121 + --input: 0 0% 17%;
122 + --ring: 0 0% 85%;
123 + --diff-add-bg: 142 50% 12%;
124 + --diff-add-fg: 142 70% 75%;
125 + --diff-del-bg: 0 55% 14%;
126 + --diff-del-fg: 0 80% 78%;
127 + --syntax-kw: 265 85% 78%;
128 + --syntax-str: 142 65% 70%;
129 + --syntax-cmt: 0 0% 54%;
130 + --syntax-fn: 210 90% 74%;
131 + --syntax-num: 32 90% 72%;
132 + --syntax-type: 188 80% 68%;
133 + --syntax-op: 0 0% 78%;
134 + color-scheme: dark;
135 + }
136 +}
137 +
138 +*, *::before, *::after {
139 + box-sizing: border-box;
140 + border-color: hsl(var(--border));
141 +}
142 +
143 +html {
144 + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
145 + font-size: 13.5px;
146 + line-height: 1.5;
147 + -webkit-font-smoothing: antialiased;
148 + background-color: hsl(var(--background));
149 + color: hsl(var(--foreground));
150 + overflow-y: scroll;
151 +}
152 +
153 +body {
154 + margin: 0;
155 + min-height: 100vh;
156 + background-color: hsl(var(--background));
157 + color: hsl(var(--foreground));
158 +}
159 +
160 +code, pre, kbd, .font-mono {
161 + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
162 +}
163 +
164 +a {
165 + color: inherit;
166 + text-decoration: none;
167 +}
168 +
169 +@media (prefers-reduced-motion: reduce) {
170 + *, *::before, *::after {
171 + animation-duration: 0.01ms !important;
172 + transition-duration: 0.01ms !important;
173 + }
174 +}
175 +
176 +:focus-visible {
177 + outline: 2px solid hsl(var(--ring));
178 + outline-offset: 2px;
179 +}
180 +
181 +/* App Shell: Left Sidebar + Right Main Content */
182 +.app-shell {
183 + display: flex;
184 + flex-direction: row;
185 + align-items: stretch;
186 + min-height: 100vh;
187 + width: 100%;
188 +}
189 +
190 +.app-sidebar {
191 + width: 236px;
192 + min-width: 236px;
193 + max-width: 236px;
194 + flex: 0 0 236px;
195 + border-right: 1px solid hsl(var(--border));
196 + background-color: hsl(var(--card));
197 + display: none;
198 + flex-direction: column;
199 + justify-content: space-between;
200 + position: sticky;
201 + top: 0;
202 + height: 100vh;
203 + overflow-y: auto;
204 + overflow-x: hidden;
205 + padding: 0.85rem 0.75rem;
206 + user-select: none;
207 + transition:
208 + width 200ms cubic-bezier(0.16, 1, 0.3, 1),
209 + min-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
210 + max-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
211 + flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1),
212 + padding 200ms cubic-bezier(0.16, 1, 0.3, 1),
213 + border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
214 +}
215 +
216 +.sidebar-inner {
217 + width: 218px;
218 + min-width: 218px;
219 + height: 100%;
220 + display: flex;
221 + flex-direction: column;
222 + justify-content: space-between;
223 + transition:
224 + opacity 150ms cubic-bezier(0.16, 1, 0.3, 1),
225 + transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
226 +}
227 +
228 +html[data-sidebar="collapsed"] .app-sidebar {
229 + width: 0;
230 + min-width: 0;
231 + max-width: 0;
232 + flex-basis: 0;
233 + padding-left: 0;
234 + padding-right: 0;
235 + border-right-color: transparent;
236 +}
237 +
238 +html[data-sidebar="collapsed"] .sidebar-inner {
239 + opacity: 0;
240 + transform: translateX(-8px);
241 + pointer-events: none;
242 +}
243 +
244 +.sidebar-expand-btn {
245 + position: fixed;
246 + top: 0.85rem;
247 + left: 0.85rem;
248 + z-index: 50;
249 + display: none;
250 + align-items: center;
251 + justify-content: center;
252 + width: 2rem;
253 + height: 2rem;
254 + border-radius: var(--radius);
255 + border: 1px solid hsl(var(--border));
256 + background-color: hsl(var(--card));
257 + color: hsl(var(--muted-foreground));
258 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
259 + cursor: pointer;
260 + opacity: 0;
261 + transform: translateX(-6px) scale(0.95);
262 + pointer-events: none;
263 + transition:
264 + opacity 160ms cubic-bezier(0.16, 1, 0.3, 1),
265 + transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
266 + background-color 120ms ease,
267 + color 120ms ease;
268 +}
269 +
270 +.sidebar-expand-btn:hover {
271 + background-color: hsl(var(--accent));
272 + color: hsl(var(--foreground));
273 +}
274 +
275 +.mobile-topbar {
276 + display: flex;
277 + align-items: center;
278 + justify-content: space-between;
279 + height: 2.75rem;
280 + padding: 0 0.85rem;
281 + border-bottom: 1px solid hsl(var(--border));
282 + background-color: hsl(var(--card));
283 +}
284 +
285 +.app-col {
286 + flex: 1 1 0%;
287 + min-width: 0;
288 + width: 100%;
289 + display: flex;
290 + flex-direction: column;
291 + min-height: 100vh;
292 +}
293 +
294 +.app-main {
295 + flex: 1 1 auto;
296 + width: 100%;
297 + padding: 1.5rem 1.25rem 3rem;
298 +}
299 +
300 +@keyframes pageFadeIn {
301 + from {
302 + opacity: 0;
303 + transform: translateY(4px);
304 + }
305 + to {
306 + opacity: 1;
307 + transform: translateY(0);
308 + }
309 +}
310 +
311 +.app-main > .container,
312 +.app-main > .container-narrow {
313 + animation: pageFadeIn 160ms cubic-bezier(0.16, 1, 0.3, 1);
314 +}
315 +
316 +@media (min-width: 640px) {
317 + .app-sidebar {
318 + display: flex;
319 + }
320 + .sidebar-expand-btn {
321 + display: inline-flex;
322 + }
323 + html[data-sidebar="collapsed"] .sidebar-expand-btn {
324 + opacity: 1;
325 + transform: translateX(0) scale(1);
326 + pointer-events: auto;
327 + }
328 + .mobile-topbar {
329 + display: none;
330 + }
331 + .app-main {
332 + padding: 2rem 2.5rem 3.5rem;
333 + }
334 +}
335 +
336 +/* Unified Container Width — identical across Code, Commits, and Settings */
337 +.container,
338 +.container-narrow {
339 + width: 100%;
340 + max-width: 960px;
341 + margin-left: auto;
342 + margin-right: auto;
343 +}
344 +
345 +/* Sidebar Navigation */
346 +.nav-item {
347 + display: flex;
348 + align-items: center;
349 + justify-content: space-between;
350 + gap: 0.5rem;
351 + height: 1.95rem;
352 + padding: 0 0.55rem;
353 + border-radius: var(--radius);
354 + font-size: 0.85rem;
355 + color: hsl(var(--muted-foreground));
356 + transition: background-color 140ms ease, color 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1);
357 + cursor: pointer;
358 + border: 0;
359 + background: transparent;
360 + width: 100%;
361 + text-align: left;
362 + overflow: hidden;
363 +}
364 +.nav-item:hover,
365 +.nav-item.active {
366 + background-color: hsl(var(--accent));
367 + color: hsl(var(--foreground));
368 +}
369 +.nav-item:active {
370 + transform: scale(0.985);
371 +}
372 +.nav-section-label {
373 + display: flex;
374 + align-items: center;
375 + justify-content: space-between;
376 + padding: 0.45rem 0.55rem 0.25rem;
377 + font-size: 0.7rem;
378 + font-weight: 600;
379 + text-transform: uppercase;
380 + letter-spacing: 0.04em;
381 + color: hsl(var(--muted-foreground));
382 +}
383 +
384 +/* Segmented Theme Control in Sidebar */
385 +.segmented {
386 + display: grid;
387 + grid-template-columns: repeat(3, minmax(0, 1fr));
388 + gap: 2px;
389 + padding: 2px;
390 + width: 100%;
391 + background-color: hsl(var(--muted));
392 + border: 1px solid hsl(var(--border));
393 + border-radius: var(--radius);
394 +}
395 +.segmented-btn {
396 + display: inline-flex;
397 + align-items: center;
398 + justify-content: center;
399 + gap: 0.25rem;
400 + height: 1.65rem;
401 + padding: 0 0.2rem;
402 + border: 0;
403 + border-radius: calc(var(--radius) - 2px);
404 + background: transparent;
405 + color: hsl(var(--muted-foreground));
406 + font-size: 0.72rem;
407 + font-weight: 500;
408 + cursor: pointer;
409 + overflow: hidden;
410 + text-overflow: ellipsis;
411 + white-space: nowrap;
412 + transition: background-color 150ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, box-shadow 150ms ease, transform 120ms ease;
413 +}
414 +.segmented-btn:hover {
415 + color: hsl(var(--foreground));
416 +}
417 +.segmented-btn:active {
418 + transform: scale(0.96);
419 +}
420 +.segmented-btn.active {
421 + background-color: hsl(var(--background));
422 + color: hsl(var(--foreground));
423 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
424 +}
425 +
426 +/* Icons */
427 +.icon {
428 + display: inline-block;
429 + vertical-align: middle;
430 + flex-shrink: 0;
431 + stroke: currentColor;
432 +}
433 +.size-3 { width: 0.75rem; height: 0.75rem; }
434 +.size-3\.5 { width: 0.875rem; height: 0.875rem; }
435 +.size-4 { width: 1rem; height: 1rem; }
436 +.size-5 { width: 1.25rem; height: 1.25rem; }
437 +.size-6 { width: 1.5rem; height: 1.5rem; }
438 +
439 +/* Buttons */
440 +.btn {
441 + display: inline-flex;
442 + align-items: center;
443 + justify-content: center;
444 + gap: 0.4rem;
445 + white-space: nowrap;
446 + border-radius: var(--radius);
447 + font-size: 0.85rem;
448 + font-weight: 500;
449 + line-height: 1;
450 + height: 1.95rem;
451 + padding: 0 0.7rem;
452 + border: 1px solid transparent;
453 + cursor: pointer;
454 + transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1);
455 + user-select: none;
456 +}
457 +.btn:active:not(:disabled) {
458 + transform: scale(0.97);
459 +}
460 +.btn:disabled {
461 + opacity: 0.5;
462 + pointer-events: none;
463 +}
464 +.btn-sm {
465 + height: 1.7rem;
466 + padding: 0 0.5rem;
467 + font-size: 0.78rem;
468 +}
469 +.btn-lg {
470 + height: 2.3rem;
471 + padding: 0 1rem;
472 + font-size: 0.92rem;
473 +}
474 +.btn-icon {
475 + width: 1.9rem;
476 + height: 1.9rem;
477 + padding: 0;
478 +}
479 +.btn-default, .btn-primary {
480 + background-color: hsl(var(--primary));
481 + color: hsl(var(--primary-foreground));
482 +}
483 +.btn-default:hover, .btn-primary:hover {
484 + opacity: 0.9;
485 +}
486 +.btn-secondary {
487 + background-color: hsl(var(--secondary));
488 + color: hsl(var(--secondary-foreground));
489 + border-color: hsl(var(--border));
490 +}
491 +.btn-secondary:hover {
492 + background-color: hsl(var(--accent));
493 +}
494 +.btn-outline {
495 + background-color: transparent;
496 + color: hsl(var(--foreground));
497 + border-color: hsl(var(--border));
498 +}
499 +.btn-outline:hover {
500 + background-color: hsl(var(--accent));
501 + color: hsl(var(--accent-foreground));
502 +}
503 +.btn-ghost {
504 + background-color: transparent;
505 + color: hsl(var(--muted-foreground));
506 +}
507 +.btn-ghost:hover {
508 + background-color: hsl(var(--accent));
509 + color: hsl(var(--foreground));
510 +}
511 +.btn-destructive {
512 + background-color: hsl(var(--destructive));
513 + color: hsl(var(--destructive-foreground));
514 +}
515 +.btn-destructive:hover {
516 + opacity: 0.9;
517 +}
518 +
519 +/* Inputs, Selects, Textareas */
520 +.input, .select, .textarea {
521 + display: block;
522 + width: 100%;
523 + border-radius: var(--radius);
524 + border: 1px solid hsl(var(--input));
525 + background-color: hsl(var(--background));
526 + color: hsl(var(--foreground));
527 + font-size: 0.85rem;
528 + padding: 0.35rem 0.6rem;
529 + transition: border-color 100ms ease;
530 +}
531 +.input, .select {
532 + height: 2.05rem;
533 +}
534 +.textarea {
535 + min-height: 4rem;
536 + resize: vertical;
537 +}
538 +.input:focus, .select:focus, .textarea:focus {
539 + outline: none;
540 + border-color: hsl(var(--ring));
541 +}
542 +.input::placeholder, .textarea::placeholder {
543 + color: hsl(var(--muted-foreground));
544 +}
545 +.label {
546 + display: block;
547 + font-size: 0.8rem;
548 + font-weight: 500;
549 + margin-bottom: 0.3rem;
550 + color: hsl(var(--foreground));
551 +}
552 +.field-hint {
553 + font-size: 0.74rem;
554 + color: hsl(var(--muted-foreground));
555 + margin-top: 0.25rem;
556 +}
557 +
558 +/* Switch */
559 +.switch {
560 + position: relative;
561 + display: inline-flex;
562 + width: 2.1rem;
563 + height: 1.2rem;
564 + flex-shrink: 0;
565 + cursor: pointer;
566 + align-items: center;
567 + border-radius: 9999px;
568 + border: 1px solid hsl(var(--border));
569 + background-color: hsl(var(--muted));
570 +}
571 +.switch input {
572 + opacity: 0;
573 + width: 0;
574 + height: 0;
575 + position: absolute;
576 +}
577 +.switch-thumb {
578 + pointer-events: none;
579 + display: block;
580 + width: 0.85rem;
581 + height: 0.85rem;
582 + border-radius: 9999px;
583 + background-color: hsl(var(--foreground));
584 + transform: translateX(0.15rem);
585 + transition: transform 100ms ease;
586 +}
587 +.switch input:checked ~ .switch-thumb {
588 + transform: translateX(0.95rem);
589 +}
590 +
591 +/* Cards */
592 +.card {
593 + border-radius: var(--radius);
594 + border: 1px solid hsl(var(--border));
595 + background-color: hsl(var(--card));
596 + color: hsl(var(--card-foreground));
597 +}
598 +.card-header {
599 + padding: 0.7rem 0.9rem;
600 + border-bottom: 1px solid hsl(var(--border));
601 + display: flex;
602 + align-items: center;
603 + justify-content: space-between;
604 + gap: 0.75rem;
605 +}
606 +.card-body {
607 + padding: 0.9rem;
608 +}
609 +.card-footer {
610 + padding: 0.65rem 0.9rem;
611 + border-top: 1px solid hsl(var(--border));
612 + background-color: hsl(var(--muted) / 0.3);
613 +}
614 +
615 +/* Badges */
616 +.badge {
617 + display: inline-flex;
618 + align-items: center;
619 + gap: 0.25rem;
620 + border-radius: 9999px;
621 + border: 1px solid hsl(var(--border));
622 + padding: 0.08rem 0.45rem;
623 + font-size: 0.72rem;
624 + font-weight: 500;
625 + line-height: 1.3;
626 + color: hsl(var(--foreground));
627 + background-color: hsl(var(--secondary));
628 +}
629 +.badge-outline {
630 + background-color: transparent;
631 + color: hsl(var(--muted-foreground));
632 +}
633 +.badge-primary {
634 + background-color: hsl(var(--primary));
635 + color: hsl(var(--primary-foreground));
636 + border-color: transparent;
637 +}
638 +.badge-destructive {
639 + background-color: hsl(var(--destructive) / 0.12);
640 + color: hsl(var(--destructive));
641 + border-color: hsl(var(--destructive) / 0.3);
642 +}
643 +
644 +/* Kbd */
645 +kbd, .kbd {
646 + display: inline-flex;
647 + align-items: center;
648 + justify-content: center;
649 + min-width: 1.25rem;
650 + height: 1.15rem;
651 + padding: 0 0.28rem;
652 + font-size: 0.68rem;
653 + font-weight: 500;
654 + line-height: 1;
655 + color: hsl(var(--muted-foreground));
656 + background-color: hsl(var(--muted));
657 + border: 1px solid hsl(var(--border));
658 + border-radius: 0.25rem;
659 +}
660 +
661 +/* Avatar */
662 +.avatar {
663 + display: inline-flex;
664 + align-items: center;
665 + justify-content: center;
666 + width: 1.45rem;
667 + height: 1.45rem;
668 + border-radius: 9999px;
669 + background-color: hsl(var(--secondary));
670 + border: 1px solid hsl(var(--border));
671 + font-size: 0.7rem;
672 + font-weight: 600;
673 + color: hsl(var(--foreground));
674 + text-transform: uppercase;
675 + flex-shrink: 0;
676 +}
677 +.avatar-lg {
678 + width: 2.2rem;
679 + height: 2.2rem;
680 + font-size: 0.9rem;
681 +}
682 +
683 +/* Separator */
684 +.separator {
685 + height: 1px;
686 + width: 100%;
687 + background-color: hsl(var(--border));
688 + margin: 0.85rem 0;
689 + border: 0;
690 +}
691 +
692 +/* Tabs */
693 +.tabs-nav {
694 + display: flex;
695 + align-items: center;
696 + gap: 0.15rem;
697 + padding: 0.2rem;
698 + border: 1px solid hsl(var(--border));
699 + border-radius: 999px;
700 + background: hsl(var(--muted) / 0.25);
701 + margin-bottom: 1rem;
702 + overflow-x: auto;
703 +}
704 +.tab-link {
705 + display: inline-flex;
706 + align-items: center;
707 + gap: 0.35rem;
708 + padding: 0.38rem 0.8rem;
709 + font-size: 0.82rem;
710 + font-weight: 500;
711 + color: hsl(var(--muted-foreground));
712 + border-radius: 999px;
713 + white-space: nowrap;
714 + transition: color 100ms ease, background-color 100ms ease;
715 +}
716 +.tab-link:hover {
717 + color: hsl(var(--foreground));
718 +}
719 +.tab-link.active {
720 + color: hsl(var(--foreground));
721 + background: hsl(var(--card));
722 + box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08);
723 +}
724 +
725 +/* Dense File List & Tables */
726 +.dense-list {
727 + width: 100%;
728 +}
729 +.dense-row {
730 + display: flex;
731 + align-items: center;
732 + justify-content: space-between;
733 + gap: 0.75rem;
734 + min-height: 2.05rem;
735 + padding: 0.25rem 0.75rem;
736 + border-bottom: 1px solid hsl(var(--border));
737 + font-size: 0.85rem;
738 + transition: background-color 80ms ease;
739 +}
740 +.dense-row:last-child {
741 + border-bottom: none;
742 +}
743 +.dense-row:hover {
744 + background-color: hsl(var(--accent) / 0.5);
745 +}
746 +
747 +.table-wrap {
748 + width: 100%;
749 + overflow-x: auto;
750 + border: 1px solid hsl(var(--border));
751 + border-radius: var(--radius);
752 + background-color: hsl(var(--card));
753 +}
754 +.table {
755 + width: 100%;
756 + border-collapse: collapse;
757 + font-size: 0.85rem;
758 +}
759 +.table th {
760 + text-align: left;
761 + font-weight: 500;
762 + color: hsl(var(--muted-foreground));
763 + padding: 0.5rem 0.75rem;
764 + border-bottom: 1px solid hsl(var(--border));
765 + background-color: hsl(var(--muted) / 0.35);
766 + font-size: 0.75rem;
767 +}
768 +.table td {
769 + padding: 0.5rem 0.75rem;
770 + border-bottom: 1px solid hsl(var(--border));
771 + vertical-align: middle;
772 +}
773 +.table tr:last-child td {
774 + border-bottom: none;
775 +}
776 +
777 +/* Breadcrumb */
778 +.breadcrumb {
779 + display: flex;
780 + align-items: center;
781 + flex-wrap: wrap;
782 + gap: 0.25rem;
783 + font-size: 0.85rem;
784 +}
785 +.breadcrumb-sep {
786 + color: hsl(var(--muted-foreground));
787 +}
788 +
789 +/* Dropdown Menu */
790 +@keyframes dropdownIn {
791 + from {
792 + opacity: 0;
793 + transform: translateY(-4px) scale(0.98);
794 + }
795 + to {
796 + opacity: 1;
797 + transform: translateY(0) scale(1);
798 + }
799 +}
800 +.dropdown {
801 + position: relative;
802 + display: inline-block;
803 +}
804 +.dropdown-menu {
805 + position: absolute;
806 + left: 0;
807 + top: calc(100% + 0.3rem);
808 + min-width: 11rem;
809 + background-color: hsl(var(--popover));
810 + color: hsl(var(--popover-foreground));
811 + border: 1px solid hsl(var(--border));
812 + border-radius: var(--radius);
813 + padding: 0.25rem;
814 + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.22);
815 + z-index: 60;
816 + display: none;
817 + transform-origin: top left;
818 +}
819 +.dropdown-menu.open {
820 + display: block;
821 + animation: dropdownIn 140ms cubic-bezier(0.16, 1, 0.3, 1);
822 +}
823 +.dropdown-item {
824 + display: flex;
825 + width: 100%;
826 + align-items: center;
827 + justify-content: space-between;
828 + gap: 0.5rem;
829 + padding: 0.35rem 0.55rem;
830 + font-size: 0.8rem;
831 + border-radius: calc(var(--radius) - 2px);
832 + color: hsl(var(--popover-foreground));
833 + background: transparent;
834 + border: 0;
835 + cursor: pointer;
836 + text-align: left;
837 + transition: background-color 120ms ease, color 120ms ease;
838 +}
839 +.dropdown-item:hover, .dropdown-item.active {
840 + background-color: hsl(var(--accent));
841 + color: hsl(var(--accent-foreground));
842 +}
843 +
844 +/* Modal / Dialog & Command Palette */
845 +@keyframes backdropIn {
846 + from { opacity: 0; }
847 + to { opacity: 1; }
848 +}
849 +@keyframes dialogIn {
850 + from {
851 + opacity: 0;
852 + transform: translateY(-6px) scale(0.97);
853 + }
854 + to {
855 + opacity: 1;
856 + transform: translateY(0) scale(1);
857 + }
858 +}
859 +.backdrop {
860 + position: fixed;
861 + inset: 0;
862 + background-color: rgba(0, 0, 0, 0.55);
863 + backdrop-filter: blur(2px);
864 + z-index: 80;
865 + display: none;
866 + align-items: flex-start;
867 + justify-content: center;
868 + padding: 10vh 1rem 2rem;
869 +}
870 +.backdrop.open {
871 + display: flex;
872 + animation: backdropIn 140ms ease;
873 +}
874 +.dialog-box {
875 + width: 100%;
876 + max-width: 28rem;
877 + background-color: hsl(var(--popover));
878 + color: hsl(var(--popover-foreground));
879 + border: 1px solid hsl(var(--border));
880 + border-radius: var(--radius);
881 + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.35);
882 + overflow: hidden;
883 + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1);
884 +}
885 +.palette-box {
886 + width: 100%;
887 + max-width: 34rem;
888 + background-color: hsl(var(--popover));
889 + color: hsl(var(--popover-foreground));
890 + border: 1px solid hsl(var(--border));
891 + border-radius: var(--radius);
892 + box-shadow: 0 16px 48px -8px rgba(0, 0, 0, 0.4);
893 + overflow: hidden;
894 + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1);
895 +}
896 +.palette-input-wrap {
897 + display: flex;
898 + align-items: center;
899 + gap: 0.6rem;
900 + padding: 0.6rem 0.8rem;
901 + border-bottom: 1px solid hsl(var(--border));
902 +}
903 +.palette-input {
904 + width: 100%;
905 + border: 0;
906 + background: transparent;
907 + color: hsl(var(--foreground));
908 + font-size: 0.9rem;
909 + outline: none;
910 +}
911 +.palette-list {
912 + max-height: 20rem;
913 + overflow-y: auto;
914 + padding: 0.3rem;
915 +}
916 +.palette-group-title {
917 + padding: 0.3rem 0.5rem 0.15rem;
918 + font-size: 0.68rem;
919 + font-weight: 600;
920 + text-transform: uppercase;
921 + letter-spacing: 0.04em;
922 + color: hsl(var(--muted-foreground));
923 +}
924 +.palette-item {
925 + display: flex;
926 + width: 100%;
927 + align-items: center;
928 + justify-content: space-between;
929 + gap: 0.5rem;
930 + padding: 0.4rem 0.55rem;
931 + border-radius: calc(var(--radius) - 2px);
932 + font-size: 0.85rem;
933 + color: hsl(var(--foreground));
934 + cursor: pointer;
935 + border: 0;
936 + background: transparent;
937 + text-align: left;
938 + transition: background-color 100ms ease, color 100ms ease;
939 +}
940 +.palette-item:hover, .palette-item.selected {
941 + background-color: hsl(var(--accent));
942 + color: hsl(var(--accent-foreground));
943 +}
944 +
945 +/* Toast */
946 +@keyframes toastIn {
947 + from {
948 + opacity: 0;
949 + transform: translateY(8px) scale(0.97);
950 + }
951 + to {
952 + opacity: 1;
953 + transform: translateY(0) scale(1);
954 + }
955 +}
956 +.toast-container {
957 + position: fixed;
958 + bottom: 1rem;
959 + right: 1rem;
960 + z-index: 90;
961 + display: flex;
962 + flex-direction: column;
963 + gap: 0.4rem;
964 + max-width: 20rem;
965 +}
966 +.toast {
967 + display: flex;
968 + align-items: center;
969 + justify-content: space-between;
970 + gap: 0.65rem;
971 + padding: 0.5rem 0.75rem;
972 + border-radius: var(--radius);
973 + border: 1px solid hsl(var(--border));
974 + background-color: hsl(var(--popover));
975 + color: hsl(var(--popover-foreground));
976 + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.25);
977 + font-size: 0.8rem;
978 + animation: toastIn 180ms cubic-bezier(0.16, 1, 0.3, 1);
979 +}
980 +
981 +/* Skeleton */
982 +@keyframes pulse {
983 + 0%, 100% { opacity: 1; }
984 + 50% { opacity: 0.45; }
985 +}
986 +.skeleton {
987 + background-color: hsl(var(--muted));
988 + border-radius: calc(var(--radius) - 2px);
989 + animation: pulse 1.6s ease-in-out infinite;
990 +}
991 +
992 +/* Empty State */
993 +.empty-state {
994 + text-align: center;
995 + padding: 2rem 1rem;
996 + border: 1px dashed hsl(var(--border));
997 + border-radius: var(--radius);
998 + color: hsl(var(--muted-foreground));
999 +}
1000 +
1001 +/* Code Viewer & Chroma */
1002 +.code-table {
1003 + width: 100%;
1004 + border-collapse: collapse;
1005 + font-family: 'JetBrains Mono', monospace;
1006 + font-size: 0.8rem;
1007 + line-height: 1.5;
1008 +}
1009 +.code-ln {
1010 + width: 1%;
1011 + min-width: 2.75rem;
1012 + padding: 0 0.65rem;
1013 + text-align: right;
1014 + color: hsl(var(--muted-foreground) / 0.6);
1015 + user-select: none;
1016 + vertical-align: top;
1017 + border-right: 1px solid hsl(var(--border));
1018 +}
1019 +.code-line {
1020 + padding: 0 0.75rem;
1021 + white-space: pre;
1022 + overflow-x: auto;
1023 +}
1024 +.chroma .k, .chroma .kc, .chroma .kd, .chroma .kn, .chroma .kp, .chroma .kr { color: hsl(var(--syntax-kw)); font-weight: 500; }
1025 +.chroma .kt { color: hsl(var(--syntax-type)); }
1026 +.chroma .s, .chroma .sa, .chroma .sb, .chroma .sc, .chroma .dl, .chroma .sd, .chroma .s2, .chroma .se, .chroma .sh, .chroma .si, .chroma .sx, .chroma .sr, .chroma .s1, .chroma .ss { color: hsl(var(--syntax-str)); }
1027 +.chroma .c, .chroma .ch, .chroma .cm, .chroma .c1, .chroma .cs, .chroma .cp, .chroma .cpf { color: hsl(var(--syntax-cmt)); font-style: italic; }
1028 +.chroma .nf, .chroma .fm, .chroma .nx { color: hsl(var(--syntax-fn)); }
1029 +.chroma .m, .chroma .mb, .chroma .mf, .chroma .mh, .chroma .mi, .chroma .il, .chroma .mo { color: hsl(var(--syntax-num)); }
1030 +.chroma .o, .chroma .ow { color: hsl(var(--syntax-op)); }
1031 +
1032 +/* Diff Viewer */
1033 +.diff-table {
1034 + width: 100%;
1035 + border-collapse: collapse;
1036 + font-family: 'JetBrains Mono', monospace;
1037 + font-size: 0.78rem;
1038 + line-height: 1.5;
1039 +}
1040 +.diff-row-add {
1041 + background-color: hsl(var(--diff-add-bg));
1042 + color: hsl(var(--diff-add-fg));
1043 +}
1044 +.diff-row-del {
1045 + background-color: hsl(var(--diff-del-bg));
1046 + color: hsl(var(--diff-del-fg));
1047 +}
1048 +.diff-row-hunk {
1049 + background-color: hsl(var(--muted));
1050 + color: hsl(var(--muted-foreground));
1051 +}
1052 +.diff-num {
1053 + width: 2.75rem;
1054 + padding: 0 0.45rem;
1055 + text-align: right;
1056 + user-select: none;
1057 + opacity: 0.65;
1058 + border-right: 1px solid hsl(var(--border));
1059 +}
1060 +.diff-code {
1061 + padding: 0 0.65rem;
1062 + white-space: pre-wrap;
1063 + word-break: break-all;
1064 +}
1065 +
1066 +/* Markdown Body */
1067 +.markdown-body {
1068 + font-size: 0.9rem;
1069 + line-height: 1.6;
1070 + color: hsl(var(--foreground));
1071 +}
1072 +.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
1073 + margin-top: 1.25em;
1074 + margin-bottom: 0.45em;
1075 + font-weight: 600;
1076 + line-height: 1.25;
1077 +}
1078 +.markdown-body h1 { font-size: 1.35rem; padding-bottom: 0.25rem; border-bottom: 1px solid hsl(var(--border)); }
1079 +.markdown-body h2 { font-size: 1.15rem; padding-bottom: 0.2rem; border-bottom: 1px solid hsl(var(--border)); }
1080 +.markdown-body h3 { font-size: 1rem; }
1081 +.markdown-body h1:first-child, .markdown-body h2:first-child { margin-top: 0; }
1082 +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body pre, .markdown-body blockquote {
1083 + margin-top: 0;
1084 + margin-bottom: 0.8rem;
1085 +}
1086 +.markdown-body code {
1087 + background-color: hsl(var(--muted));
1088 + padding: 0.12rem 0.3rem;
1089 + border-radius: 0.25rem;
1090 + font-size: 0.84em;
1091 +}
1092 +.markdown-body pre {
1093 + background-color: hsl(var(--muted) / 0.6);
1094 + border: 1px solid hsl(var(--border));
1095 + border-radius: var(--radius);
1096 + padding: 0.75rem 0.9rem;
1097 + overflow-x: auto;
1098 +}
1099 +.markdown-body pre code {
1100 + background: transparent;
1101 + padding: 0;
1102 +}
1103 +.markdown-body a {
1104 + text-decoration: underline;
1105 + text-underline-offset: 3px;
1106 +}
1107 +
1108 +/* Utility Helpers */
1109 +.flex { display: flex; }
1110 +.block { display: block; }
1111 +.inline-flex { display: inline-flex; }
1112 +.grid { display: grid; }
1113 +.hidden { display: none; }
1114 +.items-center { align-items: center; }
1115 +.items-start { align-items: flex-start; }
1116 +.items-baseline { align-items: baseline; }
1117 +.justify-between { justify-content: space-between; }
1118 +.justify-center { justify-content: center; }
1119 +.justify-end { justify-content: flex-end; }
1120 +.flex-col { flex-direction: column; }
1121 +.flex-wrap { flex-wrap: wrap; }
1122 +.flex-1 { flex: 1 1 0%; }
1123 +.flex-shrink-0 { flex-shrink: 0; }
1124 +.min-w-0 { min-width: 0; }
1125 +.gap-0\.5 { gap: 0.125rem; }
1126 +.gap-1 { gap: 0.25rem; }
1127 +.gap-1\.5 { gap: 0.375rem; }
1128 +.gap-2 { gap: 0.5rem; }
1129 +.gap-2\.5 { gap: 0.625rem; }
1130 +.gap-3 { gap: 0.75rem; }
1131 +.gap-4 { gap: 1rem; }
1132 +.gap-6 { gap: 1.5rem; }
1133 +.w-full { width: 100%; }
1134 +.text-xs { font-size: 0.75rem; }
1135 +.text-sm { font-size: 0.85rem; }
1136 +.text-base { font-size: 0.96rem; }
1137 +.text-lg { font-size: 1.1rem; }
1138 +.text-xl { font-size: 1.25rem; }
1139 +.text-2xl { font-size: 1.5rem; }
1140 +.font-medium { font-weight: 500; }
1141 +.font-semibold { font-weight: 600; }
1142 +.font-bold { font-weight: 700; }
1143 +.uppercase { text-transform: uppercase; }
1144 +.tracking-tight { letter-spacing: -0.015em; }
1145 +.text-muted { color: hsl(var(--muted-foreground)); }
1146 +.text-destructive { color: hsl(var(--destructive)); }
1147 +.text-foreground { color: hsl(var(--foreground)); }
1148 +.text-center { text-align: center; }
1149 +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1150 +.hover\:underline:hover { text-decoration: underline; }
1151 +.select-all { user-select: all; }
1152 +.cursor-pointer { cursor: pointer; }
1153 +.mt-0\.5 { margin-top: 0.125rem; }
1154 +.mt-1 { margin-top: 0.25rem; }
1155 +.mt-2 { margin-top: 0.5rem; }
1156 +.mt-3 { margin-top: 0.75rem; }
1157 +.mt-4 { margin-top: 1rem; }
1158 +.mt-6 { margin-top: 1.5rem; }
1159 +.mr-2 { margin-right: 0.5rem; }
1160 +.mb-1 { margin-bottom: 0.25rem; }
1161 +.mb-1\.5 { margin-bottom: 0.375rem; }
1162 +.mb-2 { margin-bottom: 0.5rem; }
1163 +.mb-3 { margin-bottom: 0.75rem; }
1164 +.mb-4 { margin-bottom: 1rem; }
1165 +.mb-6 { margin-bottom: 1.5rem; }
1166 +.p-2 { padding: 0.5rem; }
1167 +.p-3 { padding: 0.75rem; }
1168 +.p-4 { padding: 1rem; }
1169 +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
1170 +.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
1171 +.pt-2 { padding-top: 0.5rem; }
1172 +.pt-3 { padding-top: 0.75rem; }
1173 +.pb-3 { padding-bottom: 0.75rem; }
1174 +
1175 +.grid-2 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; }
1176 +.grid-3 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; }
1177 +.grid-4 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; }
1178 +
1179 +@media (min-width: 768px) {
1180 + .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1181 + .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
1182 + .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1183 + .md-flex { display: flex !important; }
1184 + .md-hidden { display: none !important; }
1185 +}
1186 +
1187 +/* Mobile Drawer */
1188 +.mobile-drawer {
1189 + position: fixed;
1190 + inset: 0;
1191 + z-index: 75;
1192 + background-color: rgba(0, 0, 0, 0.5);
1193 + display: none;
1194 +}
1195 +.mobile-drawer.open {
1196 + display: block;
1197 +}
1198 +.mobile-drawer-panel {
1199 + width: 224px;
1200 + max-width: 85vw;
1201 + height: 100%;
1202 + background-color: hsl(var(--card));
1203 + border-right: 1px solid hsl(var(--border));
1204 + padding: 0.75rem 0.65rem;
1205 + display: flex;
1206 + flex-direction: column;
1207 + justify-content: space-between;
1208 +}
1209 +
1210 +@media (min-width: 1024px) {
1211 + .grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
1212 +}
1213 +
1214 +/* ── Polar repository shell ─────────────────────────────────────────── */
1215 +.repo-banner {
1216 + position: relative;
1217 + display: flex;
1218 + align-items: flex-start;
1219 + justify-content: space-between;
1220 + gap: 1rem;
1221 + flex-wrap: wrap;
1222 + padding: 0.9rem 1rem 0.95rem;
1223 + background: hsl(var(--card));
1224 + border: 1px solid hsl(var(--border));
1225 + border-radius: calc(var(--radius) * 1.6);
1226 + overflow: hidden;
1227 +}
1228 +.repo-banner::before {
1229 + content: "";
1230 + position: absolute;
1231 + inset: 0 0 auto 0;
1232 + height: 2px;
1233 + background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0) 65%);
1234 +}
1235 +.repo-banner-main {
1236 + display: flex;
1237 + align-items: center;
1238 + gap: 0.7rem;
1239 + min-width: 0;
1240 + flex: 1 1 20rem;
1241 +}
1242 +.repo-banner-actions {
1243 + display: flex;
1244 + align-items: center;
1245 + gap: 0.4rem;
1246 + flex-wrap: wrap;
1247 +}
1248 +
1249 +.repo-stats {
1250 + display: flex;
1251 + align-items: center;
1252 + gap: 0.4rem;
1253 + flex-wrap: wrap;
1254 +}
1255 +.stat-chip {
1256 + display: inline-flex;
1257 + align-items: center;
1258 + gap: 0.4rem;
1259 + height: 1.85rem;
1260 + padding: 0 0.7rem;
1261 + border: 1px solid hsl(var(--border));
1262 + border-radius: 999px;
1263 + background: hsl(var(--card));
1264 + font-size: 0.78rem;
1265 + color: hsl(var(--muted-foreground));
1266 + white-space: nowrap;
1267 + transition: border-color 120ms ease, color 120ms ease;
1268 +}
1269 +.stat-chip b {
1270 + color: hsl(var(--foreground));
1271 + font-weight: 600;
1272 +}
1273 +.stat-chip:hover {
1274 + color: hsl(var(--foreground));
1275 + border-color: hsl(var(--primary) / 0.55);
1276 +}
1277 +
1278 +.repo-layout {
1279 + display: grid;
1280 + grid-template-columns: minmax(0, 1fr) 305px;
1281 + gap: 1.1rem;
1282 + align-items: start;
1283 +}
1284 +.repo-main {
1285 + min-width: 0;
1286 +}
1287 +.repo-side {
1288 + display: flex;
1289 + flex-direction: column;
1290 + gap: 0.9rem;
1291 + position: sticky;
1292 + top: 0.75rem;
1293 +}
1294 +.side-card {
1295 + border: 1px solid hsl(var(--border));
1296 + border-radius: calc(var(--radius) * 1.3);
1297 + background: hsl(var(--card));
1298 + overflow: hidden;
1299 +}
1300 +.side-card-header {
1301 + display: flex;
1302 + align-items: center;
1303 + gap: 0.4rem;
1304 + padding: 0.5rem 0.8rem;
1305 + font-size: 0.78rem;
1306 + font-weight: 600;
1307 + border-bottom: 1px solid hsl(var(--border));
1308 + background: hsl(var(--muted) / 0.3);
1309 +}
1310 +.side-card-body {
1311 + display: flex;
1312 + flex-direction: column;
1313 + gap: 0.55rem;
1314 + padding: 0.7rem 0.8rem;
1315 + font-size: 0.8rem;
1316 +}
1317 +.meta-row {
1318 + display: flex;
1319 + align-items: center;
1320 + justify-content: space-between;
1321 + gap: 0.5rem;
1322 + color: hsl(var(--muted-foreground));
1323 +}
1324 +.meta-row a,
1325 +.meta-row b {
1326 + color: hsl(var(--foreground));
1327 +}
1328 +.clone-box {
1329 + display: flex;
1330 + align-items: center;
1331 + gap: 0.35rem;
1332 + border: 1px solid hsl(var(--border));
1333 + border-radius: var(--radius);
1334 + background: hsl(var(--muted) / 0.25);
1335 + padding: 0.15rem 0.25rem 0.15rem 0.6rem;
1336 + min-width: 0;
1337 +}
1338 +.clone-box code {
1339 + flex: 1;
1340 + min-width: 0;
1341 + overflow: hidden;
1342 + text-overflow: ellipsis;
1343 + white-space: nowrap;
1344 + font-size: 0.72rem;
1345 +}
1346 +@media (max-width: 1000px) {
1347 + .repo-layout {
1348 + grid-template-columns: 1fr;
1349 + }
1350 + .repo-side {
1351 + position: static;
1352 + order: -1;
1353 + }
1354 +}
cmd/polar/main.go
+97 -0
... ... @@ -0,0 +1,97 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "flag"
6 + "log/slog"
7 + "os"
8 + "os/signal"
9 + "runtime/debug"
10 + "syscall"
11 + "time"
12 +
13 + "polar/internal/auth"
14 + "polar/internal/config"
15 + "polar/internal/db"
16 + "polar/internal/repo"
17 + "polar/internal/user"
18 + "polar/internal/web/handlers"
19 + "polar/internal/web/views"
20 +
21 + "github.com/gofiber/fiber/v2"
22 +)
23 +
24 +func main() {
25 + configPath := flag.String("config", "", "path to polar.toml configuration file")
26 + flag.Parse()
27 +
28 + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
29 + Level: slog.LevelInfo,
30 + }))
31 + slog.SetDefault(logger)
32 +
33 + cfg, err := config.Load(*configPath)
34 + if err != nil {
35 + logger.Error("failed to load configuration", "err", err)
36 + os.Exit(1)
37 + }
38 +
39 + if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
40 + logger.Error("failed to create repositories directory", "path", cfg.ReposDir(), "err", err)
41 + os.Exit(1)
42 + }
43 +
44 + ctx := context.Background()
45 + database, err := db.Open(ctx, cfg.DBPath(), cfg.Auth.RegistrationEnabled)
46 + if err != nil {
47 + logger.Error("failed to initialize database", "path", cfg.DBPath(), "err", err)
48 + os.Exit(1)
49 + }
50 + defer func() { _ = database.Close() }()
51 +
52 + authSvc := auth.NewService(database)
53 + userSvc := user.NewService(database)
54 + repoSvc := repo.NewService(database, cfg.ReposDir())
55 +
56 + viewsEngine, err := views.NewEngine()
57 + if err != nil {
58 + logger.Error("failed to initialize template engine", "err", err)
59 + os.Exit(1)
60 + }
61 +
62 + app := fiber.New(fiber.Config{
63 + AppName: "Polar Git Server",
64 + DisableStartupMessage: true,
65 + BodyLimit: 100 * 1024 * 1024, // 100 MB for git pack pushes
66 + ReadTimeout: 60 * time.Second,
67 + WriteTimeout: 120 * time.Second,
68 + })
69 +
70 + h := handlers.New(cfg, database, authSvc, userSvc, repoSvc, viewsEngine, logger)
71 + h.RegisterRoutes(app)
72 + debug.FreeOSMemory()
73 +
74 + errCh := make(chan error, 1)
75 + go func() {
76 + logger.Info("Polar server starting",
77 + "addr", cfg.Addr(),
78 + "base_url", cfg.Server.BaseURL,
79 + "data_dir", cfg.Storage.DataDir,
80 + )
81 + if err := app.Listen(cfg.Addr()); err != nil {
82 + errCh <- err
83 + }
84 + }()
85 +
86 + sigCh := make(chan os.Signal, 1)
87 + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
88 +
89 + select {
90 + case sig := <-sigCh:
91 + logger.Info("shutting down gracefully", "signal", sig.String())
92 + _ = app.ShutdownWithTimeout(10 * time.Second)
93 + case err := <-errCh:
94 + logger.Error("server exited with error", "err", err)
95 + os.Exit(1)
96 + }
97 +}
docker-compose.yml
+17 -0
... ... @@ -0,0 +1,17 @@
1 +services:
2 + polar:
3 + build: .
4 + container_name: polar
5 + restart: unless-stopped
6 + ports:
7 + - "3000:3000"
8 + environment:
9 + POLAR_SERVER_HOST: "0.0.0.0"
10 + POLAR_SERVER_PORT: "3000"
11 + POLAR_SERVER_BASE_URL: "http://localhost:3000"
12 + POLAR_STORAGE_DATA_DIR: "/app/data"
13 + POLAR_AUTH_REGISTRATION_ENABLED: "true"
14 + POLAR_LANDING_ENABLED: "true"
15 + POLAR_LANDING_DEFAULT_LANG: "ru"
16 + volumes:
17 + - ./data:/app/data
go.mod
+37 -0
... ... @@ -0,0 +1,37 @@
1 +module polar
2 +
3 +go 1.27.1
4 +
5 +require (
6 + github.com/BurntSushi/toml v1.6.0
7 + github.com/alecthomas/chroma/v2 v2.27.0
8 + github.com/gofiber/fiber/v2 v2.52.15
9 + github.com/microcosm-cc/bluemonday v1.0.27
10 + github.com/yuin/goldmark v1.8.6
11 + golang.org/x/crypto v0.58.0
12 + modernc.org/sqlite v1.60.1
13 +)
14 +
15 +require (
16 + github.com/andybalholm/brotli v1.1.0 // indirect
17 + github.com/aymerick/douceur v0.2.0 // indirect
18 + github.com/dlclark/regexp2/v2 v2.2.1 // indirect
19 + github.com/dustin/go-humanize v1.0.1 // indirect
20 + github.com/google/uuid v1.6.0 // indirect
21 + github.com/gorilla/css v1.0.1 // indirect
22 + github.com/klauspost/compress v1.17.9 // indirect
23 + github.com/mattn/go-colorable v0.1.13 // indirect
24 + github.com/mattn/go-isatty v0.0.24 // indirect
25 + github.com/mattn/go-runewidth v0.0.16 // indirect
26 + github.com/ncruces/go-strftime v1.0.0 // indirect
27 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
28 + github.com/rivo/uniseg v0.2.0 // indirect
29 + github.com/valyala/bytebufferpool v1.0.0 // indirect
30 + github.com/valyala/fasthttp v1.51.0 // indirect
31 + github.com/valyala/tcplisten v1.0.0 // indirect
32 + golang.org/x/net v0.60.0 // indirect
33 + golang.org/x/sys v0.49.0 // indirect
34 + modernc.org/libc v1.77.1 // indirect
35 + modernc.org/mathutil v1.7.1 // indirect
36 + modernc.org/memory v1.12.1 // indirect
37 +)
go.sum
+94 -0
... ... @@ -0,0 +1,94 @@
1 +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2 +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
4 +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
5 +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
6 +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
7 +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
8 +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
9 +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
10 +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
11 +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
12 +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
13 +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
14 +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
15 +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
16 +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
17 +github.com/gofiber/fiber/v2 v2.52.15 h1:Cov1uKeVPyu9q0jSrN60W+A8XNX+/WK8J7cy5osHLIk=
18 +github.com/gofiber/fiber/v2 v2.52.15/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
19 +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
20 +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
21 +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
22 +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
23 +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
24 +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
25 +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
26 +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
27 +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
28 +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
29 +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
30 +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
31 +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
32 +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
33 +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
34 +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
35 +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
36 +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
37 +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
38 +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
39 +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
40 +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
41 +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
42 +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
43 +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
44 +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
45 +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
46 +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
47 +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
48 +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
49 +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
50 +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
51 +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
52 +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
53 +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
54 +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
55 +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
56 +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
57 +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
58 +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
59 +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
60 +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
61 +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
62 +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
63 +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
64 +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
65 +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
66 +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
67 +modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
68 +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
69 +modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
70 +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
71 +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
72 +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
73 +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
74 +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
75 +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
76 +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
77 +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
78 +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
79 +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
80 +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
81 +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
82 +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
83 +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
84 +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
85 +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
86 +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
87 +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
88 +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
89 +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
90 +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
91 +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
92 +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
93 +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
94 +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
internal/auth/auth.go
+280 -0
... ... @@ -0,0 +1,280 @@
1 +package auth
2 +
3 +import (
4 + "context"
5 + "crypto/rand"
6 + "crypto/sha256"
7 + "crypto/subtle"
8 + "database/sql"
9 + "encoding/base64"
10 + "encoding/hex"
11 + "errors"
12 + "fmt"
13 + "strings"
14 + "time"
15 +
16 + "polar/internal/db"
17 +
18 + "golang.org/x/crypto/argon2"
19 +)
20 +
21 +var (
22 + ErrInvalidCredentials = errors.New("invalid credentials")
23 + ErrSessionExpired = errors.New("session expired or not found")
24 + ErrInvalidToken = errors.New("invalid access token")
25 +)
26 +
27 +const (
28 + argonTime uint32 = 1
29 + argonMemory uint32 = 64 * 1024
30 + argonThreads uint8 = 4
31 + argonKeyLen uint32 = 32
32 + saltLen = 16
33 + SessionTTL = 30 * 24 * time.Hour
34 +)
35 +
36 +type Session struct {
37 + ID string
38 + UserID int64
39 + CSRFToken string
40 + ExpiresAt time.Time
41 + CreatedAt time.Time
42 +}
43 +
44 +type AccessToken struct {
45 + ID int64
46 + UserID int64
47 + Name string
48 + TokenPrefix string
49 + LastUsedAt *time.Time
50 + CreatedAt time.Time
51 +}
52 +
53 +type Service struct {
54 + db *db.DB
55 +}
56 +
57 +func NewService(database *db.DB) *Service {
58 + return &Service{db: database}
59 +}
60 +
61 +func HashPassword(password string) (string, error) {
62 + if len(password) < 8 {
63 + return "", errors.New("password must be at least 8 characters")
64 + }
65 + salt := make([]byte, saltLen)
66 + if _, err := rand.Read(salt); err != nil {
67 + return "", fmt.Errorf("generate salt: %w", err)
68 + }
69 + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
70 + b64Salt := base64.RawStdEncoding.EncodeToString(salt)
71 + b64Hash := base64.RawStdEncoding.EncodeToString(hash)
72 + encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
73 + argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash)
74 + return encoded, nil
75 +}
76 +
77 +func VerifyPassword(password, encodedHash string) (bool, error) {
78 + parts := strings.Split(encodedHash, "$")
79 + if len(parts) != 6 || parts[1] != "argon2id" {
80 + return false, errors.New("invalid argon2id hash format")
81 + }
82 + var memory, timeCost uint32
83 + var threads uint8
84 + _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads)
85 + if err != nil {
86 + return false, fmt.Errorf("parse argon2 params: %w", err)
87 + }
88 + salt, err := base64.RawStdEncoding.DecodeString(parts[4])
89 + if err != nil {
90 + return false, fmt.Errorf("decode salt: %w", err)
91 + }
92 + expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
93 + if err != nil {
94 + return false, fmt.Errorf("decode hash: %w", err)
95 + }
96 + computed := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(expectedHash)))
97 + return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
98 +}
99 +
100 +func randomHex(bytesLen int) (string, error) {
101 + b := make([]byte, bytesLen)
102 + if _, err := rand.Read(b); err != nil {
103 + return "", err
104 + }
105 + return hex.EncodeToString(b), nil
106 +}
107 +
108 +func (s *Service) CreateSession(ctx context.Context, userID int64) (*Session, error) {
109 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
110 + defer cancel()
111 +
112 + id, err := randomHex(32)
113 + if err != nil {
114 + return nil, fmt.Errorf("generate session id: %w", err)
115 + }
116 + csrf, err := randomHex(24)
117 + if err != nil {
118 + return nil, fmt.Errorf("generate csrf token: %w", err)
119 + }
120 + now := time.Now().UTC()
121 + expires := now.Add(SessionTTL)
122 +
123 + _, err = s.db.ExecContext(cctx, `
124 + INSERT INTO sessions (id, user_id, csrf_token, expires_at, created_at)
125 + VALUES (?, ?, ?, ?, ?)
126 + `, id, userID, csrf, expires, now)
127 + if err != nil {
128 + return nil, fmt.Errorf("insert session: %w", err)
129 + }
130 +
131 + return &Session{
132 + ID: id,
133 + UserID: userID,
134 + CSRFToken: csrf,
135 + ExpiresAt: expires,
136 + CreatedAt: now,
137 + }, nil
138 +}
139 +
140 +func (s *Service) GetSession(ctx context.Context, sessionID string) (*Session, error) {
141 + if sessionID == "" {
142 + return nil, ErrSessionExpired
143 + }
144 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
145 + defer cancel()
146 +
147 + var sess Session
148 + err := s.db.QueryRowContext(cctx, `
149 + SELECT id, user_id, csrf_token, expires_at, created_at
150 + FROM sessions WHERE id = ?
151 + `, sessionID).Scan(&sess.ID, &sess.UserID, &sess.CSRFToken, &sess.ExpiresAt, &sess.CreatedAt)
152 + if err != nil {
153 + if errors.Is(err, sql.ErrNoRows) {
154 + return nil, ErrSessionExpired
155 + }
156 + return nil, fmt.Errorf("query session: %w", err)
157 + }
158 +
159 + if time.Now().UTC().After(sess.ExpiresAt) {
160 + _ = s.DeleteSession(ctx, sessionID)
161 + return nil, ErrSessionExpired
162 + }
163 + return &sess, nil
164 +}
165 +
166 +func (s *Service) DeleteSession(ctx context.Context, sessionID string) error {
167 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
168 + defer cancel()
169 + _, err := s.db.ExecContext(cctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
170 + return err
171 +}
172 +
173 +func HashToken(rawToken string) string {
174 + sum := sha256.Sum256([]byte(rawToken))
175 + return hex.EncodeToString(sum[:])
176 +}
177 +
178 +func (s *Service) CreateToken(ctx context.Context, userID int64, name string) (string, *AccessToken, error) {
179 + name = strings.TrimSpace(name)
180 + if name == "" || len(name) > 64 {
181 + return "", nil, errors.New("token name must be between 1 and 64 characters")
182 + }
183 +
184 + secret, err := randomHex(20)
185 + if err != nil {
186 + return "", nil, fmt.Errorf("generate token secret: %w", err)
187 + }
188 + rawToken := "polar_" + secret
189 + tokenHash := HashToken(rawToken)
190 + prefix := rawToken[:10]
191 + now := time.Now().UTC()
192 +
193 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
194 + defer cancel()
195 +
196 + res, err := s.db.ExecContext(cctx, `
197 + INSERT INTO access_tokens (user_id, name, token_prefix, token_hash, created_at)
198 + VALUES (?, ?, ?, ?, ?)
199 + `, userID, name, prefix, tokenHash, now)
200 + if err != nil {
201 + return "", nil, fmt.Errorf("insert access token: %w", err)
202 + }
203 + id, _ := res.LastInsertId()
204 +
205 + return rawToken, &AccessToken{
206 + ID: id,
207 + UserID: userID,
208 + Name: name,
209 + TokenPrefix: prefix,
210 + CreatedAt: now,
211 + }, nil
212 +}
213 +
214 +func (s *Service) VerifyToken(ctx context.Context, rawToken string) (int64, error) {
215 + rawToken = strings.TrimSpace(rawToken)
216 + if !strings.HasPrefix(rawToken, "polar_") {
217 + return 0, ErrInvalidToken
218 + }
219 + tokenHash := HashToken(rawToken)
220 +
221 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
222 + defer cancel()
223 +
224 + var tokenID, userID int64
225 + err := s.db.QueryRowContext(cctx, `
226 + SELECT id, user_id FROM access_tokens WHERE token_hash = ?
227 + `, tokenHash).Scan(&tokenID, &userID)
228 + if err != nil {
229 + if errors.Is(err, sql.ErrNoRows) {
230 + return 0, ErrInvalidToken
231 + }
232 + return 0, fmt.Errorf("lookup token: %w", err)
233 + }
234 +
235 + now := time.Now().UTC()
236 + _, _ = s.db.ExecContext(cctx, `UPDATE access_tokens SET last_used_at = ? WHERE id = ?`, now, tokenID)
237 + return userID, nil
238 +}
239 +
240 +func (s *Service) ListTokens(ctx context.Context, userID int64) ([]AccessToken, error) {
241 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
242 + defer cancel()
243 +
244 + rows, err := s.db.QueryContext(cctx, `
245 + SELECT id, user_id, name, token_prefix, last_used_at, created_at
246 + FROM access_tokens
247 + WHERE user_id = ?
248 + ORDER BY created_at DESC
249 + `, userID)
250 + if err != nil {
251 + return nil, fmt.Errorf("list access tokens: %w", err)
252 + }
253 + defer rows.Close()
254 +
255 + var list []AccessToken
256 + for rows.Next() {
257 + var t AccessToken
258 + var lastUsed sql.NullTime
259 + if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.TokenPrefix, &lastUsed, &t.CreatedAt); err != nil {
260 + return nil, fmt.Errorf("scan access token: %w", err)
261 + }
262 + if lastUsed.Valid {
263 + tm := lastUsed.Time
264 + t.LastUsedAt = &tm
265 + }
266 + list = append(list, t)
267 + }
268 + return list, rows.Err()
269 +}
270 +
271 +func (s *Service) RevokeToken(ctx context.Context, userID, tokenID int64) error {
272 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
273 + defer cancel()
274 +
275 + _, err := s.db.ExecContext(cctx, `DELETE FROM access_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
276 + if err != nil {
277 + return fmt.Errorf("revoke token: %w", err)
278 + }
279 + return nil
280 +}
internal/auth/auth_test.go
+101 -0
... ... @@ -0,0 +1,101 @@
1 +package auth
2 +
3 +import (
4 + "context"
5 + "path/filepath"
6 + "strings"
7 + "testing"
8 +
9 + "polar/internal/db"
10 +)
11 +
12 +func TestPasswordHashAndVerify(t *testing.T) {
13 + if _, err := HashPassword("short"); err == nil {
14 + t.Fatalf("expected error for short password")
15 + }
16 +
17 + hash, err := HashPassword("correct-horse-battery-staple")
18 + if err != nil {
19 + t.Fatalf("HashPassword failed: %v", err)
20 + }
21 + if !strings.HasPrefix(hash, "$argon2id$") {
22 + t.Fatalf("expected argon2id PHC format, got %q", hash)
23 + }
24 +
25 + ok, err := VerifyPassword("correct-horse-battery-staple", hash)
26 + if err != nil || !ok {
27 + t.Fatalf("VerifyPassword failed for valid password: %v", err)
28 + }
29 +
30 + ok, err = VerifyPassword("wrong-password", hash)
31 + if err != nil || ok {
32 + t.Fatalf("VerifyPassword should return false for wrong password")
33 + }
34 +}
35 +
36 +func TestSessionsAndTokens(t *testing.T) {
37 + ctx := context.Background()
38 + database, err := db.Open(ctx, filepath.Join(t.TempDir(), "auth.db"), true)
39 + if err != nil {
40 + t.Fatalf("open db: %v", err)
41 + }
42 + defer database.Close()
43 +
44 + _, err = database.ExecContext(ctx, `INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (1, 'alice', 'alice@example.com', 'hash', 1)`)
45 + if err != nil {
46 + t.Fatalf("insert test user: %v", err)
47 + }
48 +
49 + svc := NewService(database)
50 +
51 + sess, err := svc.CreateSession(ctx, 1)
52 + if err != nil {
53 + t.Fatalf("CreateSession failed: %v", err)
54 + }
55 + if len(sess.ID) != 64 || len(sess.CSRFToken) < 32 {
56 + t.Fatalf("unexpected session ID or CSRF token length")
57 + }
58 +
59 + loaded, err := svc.GetSession(ctx, sess.ID)
60 + if err != nil || loaded.UserID != 1 {
61 + t.Fatalf("GetSession failed: %v", err)
62 + }
63 +
64 + if err := svc.DeleteSession(ctx, sess.ID); err != nil {
65 + t.Fatalf("DeleteSession failed: %v", err)
66 + }
67 + if _, err := svc.GetSession(ctx, sess.ID); err == nil {
68 + t.Fatalf("expected error after session deletion")
69 + }
70 +
71 + rawToken, tok, err := svc.CreateToken(ctx, 1, "Laptop CLI")
72 + if err != nil {
73 + t.Fatalf("CreateToken failed: %v", err)
74 + }
75 + if !strings.HasPrefix(rawToken, "polar_") {
76 + t.Fatalf("expected polar_ prefix, got %q", rawToken)
77 + }
78 + if tok.Name != "Laptop CLI" {
79 + t.Fatalf("unexpected token name: %q", tok.Name)
80 + }
81 +
82 + uid, err := svc.VerifyToken(ctx, rawToken)
83 + if err != nil || uid != 1 {
84 + t.Fatalf("VerifyToken failed: uid=%d err=%v", uid, err)
85 + }
86 +
87 + tokens, err := svc.ListTokens(ctx, 1)
88 + if err != nil || len(tokens) != 1 {
89 + t.Fatalf("ListTokens failed: len=%d err=%v", len(tokens), err)
90 + }
91 + if tokens[0].LastUsedAt == nil {
92 + t.Fatalf("expected LastUsedAt to be updated after VerifyToken")
93 + }
94 +
95 + if err := svc.RevokeToken(ctx, 1, tok.ID); err != nil {
96 + t.Fatalf("RevokeToken failed: %v", err)
97 + }
98 + if _, err := svc.VerifyToken(ctx, rawToken); err == nil {
99 + t.Fatalf("expected error for revoked token")
100 + }
101 +}
internal/auth/device.go
+195 -0
... ... @@ -0,0 +1,195 @@
1 +package auth
2 +
3 +import (
4 + "context"
5 + "crypto/rand"
6 + "database/sql"
7 + "errors"
8 + "fmt"
9 + "math/big"
10 + "strings"
11 + "time"
12 +)
13 +
14 +const (
15 + DeviceCodeTTL = 15 * time.Minute
16 + DeviceCodeInterval = 5 // seconds the client should wait between polls
17 +)
18 +
19 +// Status values for a device authorization grant.
20 +const (
21 + DeviceStatusPending = "pending"
22 + DeviceStatusApproved = "approved"
23 + DeviceStatusDenied = "denied"
24 +)
25 +
26 +var ErrDeviceCodeNotFound = errors.New("device code not found")
27 +
28 +// userCodeAlphabet avoids characters that are easy to confuse (0/O, 1/I/L).
29 +const userCodeAlphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"
30 +
31 +type DeviceCode struct {
32 + Code string
33 + UserCode string
34 + ClientID string
35 + UserID *int64
36 + Status string
37 + ExpiresAt time.Time
38 +}
39 +
40 +func generateUserCode() (string, error) {
41 + letters := make([]byte, 8)
42 + for i := range letters {
43 + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(userCodeAlphabet))))
44 + if err != nil {
45 + return "", err
46 + }
47 + letters[i] = userCodeAlphabet[n.Int64()]
48 + }
49 + return string(letters[:4]) + "-" + string(letters[4:]), nil
50 +}
51 +
52 +// NormalizeUserCode uppercases the entered code and restores the XXXX-XXXX shape.
53 +func NormalizeUserCode(code string) string {
54 + code = strings.ToUpper(strings.TrimSpace(code))
55 + code = strings.ReplaceAll(code, " ", "")
56 + code = strings.ReplaceAll(code, "-", "")
57 + if len(code) != 8 {
58 + return code
59 + }
60 + return code[:4] + "-" + code[4:]
61 +}
62 +
63 +// CreateDeviceCode starts a device authorization grant (RFC 8628).
64 +func (s *Service) CreateDeviceCode(ctx context.Context, clientID string) (*DeviceCode, error) {
65 + clientID = strings.TrimSpace(clientID)
66 + if len(clientID) > 120 {
67 + clientID = clientID[:120]
68 + }
69 +
70 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
71 + defer cancel()
72 +
73 + for attempt := 0; attempt < 5; attempt++ {
74 + deviceCode, err := randomHex(24)
75 + if err != nil {
76 + return nil, fmt.Errorf("generate device code: %w", err)
77 + }
78 + userCode, err := generateUserCode()
79 + if err != nil {
80 + return nil, fmt.Errorf("generate user code: %w", err)
81 + }
82 +
83 + expiresAt := time.Now().UTC().Add(DeviceCodeTTL)
84 + _, err = s.db.ExecContext(cctx, `
85 + INSERT INTO device_codes (device_code, user_code, client_id, status, created_at, expires_at)
86 + VALUES (?, ?, ?, ?, ?, ?)
87 + `, deviceCode, userCode, clientID, DeviceStatusPending, time.Now().UTC(), expiresAt)
88 + if err != nil {
89 + if strings.Contains(strings.ToLower(err.Error()), "unique") {
90 + continue
91 + }
92 + return nil, fmt.Errorf("insert device code: %w", err)
93 + }
94 + return &DeviceCode{Code: deviceCode, UserCode: userCode, ClientID: clientID, Status: DeviceStatusPending, ExpiresAt: expiresAt}, nil
95 + }
96 + return nil, errors.New("could not allocate a unique user code")
97 +}
98 +
99 +// GetDeviceCodeByUserCode looks up a grant by the human-friendly code.
100 +func (s *Service) GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error) {
101 + userCode = NormalizeUserCode(userCode)
102 + if userCode == "" {
103 + return nil, ErrDeviceCodeNotFound
104 + }
105 +
106 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
107 + defer cancel()
108 +
109 + var dc DeviceCode
110 + var userID sql.NullInt64
111 + var expiresAt time.Time
112 + err := s.db.QueryRowContext(cctx, `
113 + SELECT device_code, user_code, client_id, user_id, status, expires_at
114 + FROM device_codes WHERE user_code = ?
115 + `, userCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt)
116 + if err != nil {
117 + if errors.Is(err, sql.ErrNoRows) {
118 + return nil, ErrDeviceCodeNotFound
119 + }
120 + return nil, fmt.Errorf("get device code: %w", err)
121 + }
122 + if userID.Valid {
123 + dc.UserID = &userID.Int64
124 + }
125 + dc.ExpiresAt = expiresAt
126 + return &dc, nil
127 +}
128 +
129 +// SetDeviceCodeStatus approves or denies a pending grant.
130 +func (s *Service) SetDeviceCodeStatus(ctx context.Context, userCode string, userID int64, status string) error {
131 + if status != DeviceStatusApproved && status != DeviceStatusDenied {
132 + return errors.New("invalid device code status")
133 + }
134 +
135 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
136 + defer cancel()
137 +
138 + res, err := s.db.ExecContext(cctx, `
139 + UPDATE device_codes
140 + SET status = ?, user_id = ?
141 + WHERE user_code = ? AND status = ? AND expires_at > ?
142 + `, status, userID, NormalizeUserCode(userCode), DeviceStatusPending, time.Now().UTC())
143 + if err != nil {
144 + return fmt.Errorf("update device code: %w", err)
145 + }
146 + if n, _ := res.RowsAffected(); n == 0 {
147 + return ErrDeviceCodeNotFound
148 + }
149 + return nil
150 +}
151 +
152 +// ConsumeDeviceCode marks an approved grant as exchanged so the device code
153 +// cannot be traded for a second token.
154 +func (s *Service) ConsumeDeviceCode(ctx context.Context, deviceCode string) {
155 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
156 + defer cancel()
157 +
158 + _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET status = 'exchanged' WHERE device_code = ?`, deviceCode)
159 +}
160 +
161 +// PollDeviceCode returns the current grant status and marks the poll time.
162 +func (s *Service) PollDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error) {
163 + deviceCode = strings.TrimSpace(deviceCode)
164 + if deviceCode == "" {
165 + return nil, ErrDeviceCodeNotFound
166 + }
167 +
168 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
169 + defer cancel()
170 +
171 + var dc DeviceCode
172 + var userID sql.NullInt64
173 + var expiresAt time.Time
174 + err := s.db.QueryRowContext(cctx, `
175 + SELECT device_code, user_code, client_id, user_id, status, expires_at
176 + FROM device_codes WHERE device_code = ?
177 + `, deviceCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt)
178 + if err != nil {
179 + if errors.Is(err, sql.ErrNoRows) {
180 + return nil, ErrDeviceCodeNotFound
181 + }
182 + return nil, fmt.Errorf("poll device code: %w", err)
183 + }
184 + if userID.Valid {
185 + dc.UserID = &userID.Int64
186 + }
187 + dc.ExpiresAt = expiresAt
188 +
189 + _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET last_polled_at = ? WHERE device_code = ?`, time.Now().UTC(), deviceCode)
190 +
191 + if time.Now().UTC().After(expiresAt) && dc.Status == DeviceStatusPending {
192 + dc.Status = "expired"
193 + }
194 + return &dc, nil
195 +}
internal/config/config.go
+154 -0
... ... @@ -0,0 +1,154 @@
1 +package config
2 +
3 +import (
4 + "fmt"
5 + "os"
6 + "path/filepath"
7 + "strconv"
8 + "strings"
9 +
10 + "github.com/BurntSushi/toml"
11 +)
12 +
13 +type Config struct {
14 + Server ServerConfig `toml:"server"`
15 + Storage StorageConfig `toml:"storage"`
16 + Auth AuthConfig `toml:"auth"`
17 + Landing LandingConfig `toml:"landing"`
18 +}
19 +
20 +type ServerConfig struct {
21 + Host string `toml:"host"`
22 + Port int `toml:"port"`
23 + BaseURL string `toml:"base_url"`
24 +}
25 +
26 +type StorageConfig struct {
27 + DataDir string `toml:"data_dir"`
28 +}
29 +
30 +type AuthConfig struct {
31 + RegistrationEnabled bool `toml:"registration_enabled"`
32 +}
33 +
34 +type LandingConfig struct {
35 + Enabled bool `toml:"enabled"`
36 + DefaultLang string `toml:"default_lang"`
37 +}
38 +
39 +func Default() Config {
40 + return Config{
41 + Server: ServerConfig{
42 + Host: "0.0.0.0",
43 + Port: 3000,
44 + BaseURL: "http://localhost:3000",
45 + },
46 + Storage: StorageConfig{
47 + DataDir: "./data",
48 + },
49 + Auth: AuthConfig{
50 + RegistrationEnabled: true,
51 + },
52 + Landing: LandingConfig{
53 + Enabled: true,
54 + DefaultLang: "ru",
55 + },
56 + }
57 +}
58 +
59 +func Load(configPath string) (Config, error) {
60 + cfg := Default()
61 +
62 + if envPath := os.Getenv("POLAR_CONFIG"); envPath != "" && configPath == "" {
63 + configPath = envPath
64 + }
65 + if configPath == "" {
66 + if _, err := os.Stat("polar.toml"); err == nil {
67 + configPath = "polar.toml"
68 + }
69 + }
70 +
71 + if configPath != "" {
72 + data, err := os.ReadFile(configPath)
73 + if err != nil {
74 + return cfg, fmt.Errorf("read config file %s: %w", configPath, err)
75 + }
76 + if err := toml.Unmarshal(data, &cfg); err != nil {
77 + return cfg, fmt.Errorf("parse config file %s: %w", configPath, err)
78 + }
79 + }
80 +
81 + applyEnv(&cfg)
82 +
83 + cfg.Server.BaseURL = strings.TrimRight(cfg.Server.BaseURL, "/")
84 + if cfg.Server.BaseURL == "" {
85 + host := cfg.Server.Host
86 + if host == "0.0.0.0" || host == "" {
87 + host = "localhost"
88 + }
89 + cfg.Server.BaseURL = fmt.Sprintf("http://%s:%d", host, cfg.Server.Port)
90 + }
91 + if cfg.Landing.DefaultLang != "en" && cfg.Landing.DefaultLang != "ru" {
92 + cfg.Landing.DefaultLang = "ru"
93 + }
94 +
95 + return cfg, nil
96 +}
97 +
98 +func applyEnv(cfg *Config) {
99 + getEnv := os.Getenv
100 +
101 + if v := getEnv("POLAR_SERVER_HOST"); v != "" {
102 + cfg.Server.Host = v
103 + }
104 + if v := getEnv("POLAR_HOST"); v != "" {
105 + cfg.Server.Host = v
106 + }
107 + if v := getEnv("POLAR_SERVER_PORT"); v != "" {
108 + if p, err := strconv.Atoi(v); err == nil && p > 0 {
109 + cfg.Server.Port = p
110 + }
111 + }
112 + if v := getEnv("POLAR_PORT"); v != "" {
113 + if p, err := strconv.Atoi(v); err == nil && p > 0 {
114 + cfg.Server.Port = p
115 + }
116 + }
117 + if v := getEnv("POLAR_SERVER_BASE_URL"); v != "" {
118 + cfg.Server.BaseURL = v
119 + }
120 + if v := getEnv("POLAR_BASE_URL"); v != "" {
121 + cfg.Server.BaseURL = v
122 + }
123 + if v := getEnv("POLAR_STORAGE_DATA_DIR"); v != "" {
124 + cfg.Storage.DataDir = v
125 + }
126 + if v := getEnv("POLAR_DATA_DIR"); v != "" {
127 + cfg.Storage.DataDir = v
128 + }
129 + if v := getEnv("POLAR_AUTH_REGISTRATION_ENABLED"); v != "" {
130 + if b, err := strconv.ParseBool(v); err == nil {
131 + cfg.Auth.RegistrationEnabled = b
132 + }
133 + }
134 + if v := getEnv("POLAR_LANDING_ENABLED"); v != "" {
135 + if b, err := strconv.ParseBool(v); err == nil {
136 + cfg.Landing.Enabled = b
137 + }
138 + }
139 + if v := getEnv("POLAR_LANDING_DEFAULT_LANG"); v != "" {
140 + cfg.Landing.DefaultLang = strings.ToLower(strings.TrimSpace(v))
141 + }
142 +}
143 +
144 +func (c Config) Addr() string {
145 + return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
146 +}
147 +
148 +func (c Config) DBPath() string {
149 + return filepath.Join(c.Storage.DataDir, "polar.db")
150 +}
151 +
152 +func (c Config) ReposDir() string {
153 + return filepath.Join(c.Storage.DataDir, "repos")
154 +}
internal/config/config_test.go
+59 -0
... ... @@ -0,0 +1,59 @@
1 +package config
2 +
3 +import (
4 + "os"
5 + "path/filepath"
6 + "testing"
7 +)
8 +
9 +func TestLoadDefaultsAndOverrides(t *testing.T) {
10 + cfg, err := Load("")
11 + if err != nil {
12 + t.Fatalf("Load default failed: %v", err)
13 + }
14 + if cfg.Server.Port != 3000 || cfg.Landing.DefaultLang != "ru" {
15 + t.Fatalf("unexpected default config: %+v", cfg)
16 + }
17 +
18 + tmpFile := filepath.Join(t.TempDir(), "polar.toml")
19 + tomlData := `
20 +[server]
21 +host = "127.0.0.1"
22 +port = 4000
23 +base_url = "https://git.example.com/"
24 +
25 +[storage]
26 +data_dir = "/var/lib/polar"
27 +
28 +[auth]
29 +registration_enabled = false
30 +
31 +[landing]
32 +enabled = false
33 +default_lang = "en"
34 +`
35 + if err := os.WriteFile(tmpFile, []byte(tomlData), 0o644); err != nil {
36 + t.Fatalf("write temp toml: %v", err)
37 + }
38 +
39 + t.Setenv("POLAR_SERVER_PORT", "8080")
40 + loaded, err := Load(tmpFile)
41 + if err != nil {
42 + t.Fatalf("Load from file failed: %v", err)
43 + }
44 + if loaded.Server.Port != 8080 {
45 + t.Fatalf("expected env POLAR_SERVER_PORT=8080 to override file port 4000, got %d", loaded.Server.Port)
46 + }
47 + if loaded.Server.BaseURL != "https://git.example.com" {
48 + t.Fatalf("expected trailing slash trimmed from base_url, got %q", loaded.Server.BaseURL)
49 + }
50 + if loaded.Auth.RegistrationEnabled {
51 + t.Fatalf("expected registration_enabled = false")
52 + }
53 + if loaded.Landing.DefaultLang != "en" {
54 + t.Fatalf("expected default_lang = en, got %q", loaded.Landing.DefaultLang)
55 + }
56 + if loaded.DBPath() != filepath.Join(loaded.Storage.DataDir, "polar.db") {
57 + t.Fatalf("expected DBPath to default to polar.db, got %q", loaded.DBPath())
58 + }
59 +}
internal/db/db.go
+206 -0
... ... @@ -0,0 +1,206 @@
1 +package db
2 +
3 +import (
4 + "context"
5 + "database/sql"
6 + "embed"
7 + "errors"
8 + "fmt"
9 + "io/fs"
10 + "os"
11 + "path/filepath"
12 + "sort"
13 + "strconv"
14 + "strings"
15 + "time"
16 +
17 + _ "modernc.org/sqlite"
18 +)
19 +
20 +//go:embed migrations/*.sql
21 +var migrationsFS embed.FS
22 +
23 +type DB struct {
24 + *sql.DB
25 +}
26 +
27 +func Open(ctx context.Context, dbPath string, defaultRegEnabled bool) (*DB, error) {
28 + if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file::memory:") {
29 + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
30 + return nil, fmt.Errorf("create database directory: %w", err)
31 + }
32 + }
33 +
34 + dsn := dbPath
35 + if !strings.Contains(dsn, "?") {
36 + dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)"
37 + }
38 +
39 + sqlDB, err := sql.Open("sqlite", dsn)
40 + if err != nil {
41 + return nil, fmt.Errorf("open sqlite %s: %w", dbPath, err)
42 + }
43 +
44 + sqlDB.SetMaxOpenConns(1)
45 + sqlDB.SetMaxIdleConns(1)
46 + sqlDB.SetConnMaxLifetime(0)
47 +
48 + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
49 + defer cancel()
50 + if err := sqlDB.PingContext(pingCtx); err != nil {
51 + _ = sqlDB.Close()
52 + return nil, fmt.Errorf("ping sqlite: %w", err)
53 + }
54 +
55 + pragmas := []string{
56 + "PRAGMA journal_mode = WAL;",
57 + "PRAGMA foreign_keys = ON;",
58 + "PRAGMA busy_timeout = 5000;",
59 + "PRAGMA synchronous = NORMAL;",
60 + }
61 + for _, p := range pragmas {
62 + if _, err := sqlDB.ExecContext(pingCtx, p); err != nil {
63 + _ = sqlDB.Close()
64 + return nil, fmt.Errorf("exec pragma %q: %w", p, err)
65 + }
66 + }
67 +
68 + d := &DB{DB: sqlDB}
69 + if err := d.Migrate(pingCtx); err != nil {
70 + _ = sqlDB.Close()
71 + return nil, fmt.Errorf("run migrations: %w", err)
72 + }
73 +
74 + if _, err := d.GetSetting(pingCtx, "registration_enabled"); errors.Is(err, sql.ErrNoRows) {
75 + if err := d.SetSetting(pingCtx, "registration_enabled", strconv.FormatBool(defaultRegEnabled)); err != nil {
76 + _ = sqlDB.Close()
77 + return nil, fmt.Errorf("seed registration_enabled setting: %w", err)
78 + }
79 + }
80 +
81 + return d, nil
82 +}
83 +
84 +func (d *DB) Migrate(ctx context.Context) error {
85 + _, err := d.ExecContext(ctx, `
86 + CREATE TABLE IF NOT EXISTS schema_migrations (
87 + version TEXT PRIMARY KEY,
88 + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
89 + );
90 + `)
91 + if err != nil {
92 + return fmt.Errorf("create schema_migrations table: %w", err)
93 + }
94 +
95 + entries, err := fs.ReadDir(migrationsFS, "migrations")
96 + if err != nil {
97 + return fmt.Errorf("read embedded migrations: %w", err)
98 + }
99 +
100 + var files []string
101 + for _, entry := range entries {
102 + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
103 + files = append(files, entry.Name())
104 + }
105 + }
106 + sort.Strings(files)
107 +
108 + for _, name := range files {
109 + var exists int
110 + err := d.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists)
111 + if err == nil {
112 + continue
113 + }
114 + if !errors.Is(err, sql.ErrNoRows) {
115 + return fmt.Errorf("check migration %s: %w", name, err)
116 + }
117 +
118 + content, err := migrationsFS.ReadFile("migrations/" + name)
119 + if err != nil {
120 + return fmt.Errorf("read migration %s: %w", name, err)
121 + }
122 +
123 + tx, err := d.BeginTx(ctx, nil)
124 + if err != nil {
125 + return fmt.Errorf("begin migration tx %s: %w", name, err)
126 + }
127 +
128 + if _, err := tx.ExecContext(ctx, string(content)); err != nil {
129 + _ = tx.Rollback()
130 + return fmt.Errorf("execute migration %s: %w", name, err)
131 + }
132 + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil {
133 + _ = tx.Rollback()
134 + return fmt.Errorf("record migration %s: %w", name, err)
135 + }
136 + if err := tx.Commit(); err != nil {
137 + return fmt.Errorf("commit migration %s: %w", name, err)
138 + }
139 + }
140 +
141 + return nil
142 +}
143 +
144 +func (d *DB) GetSetting(ctx context.Context, key string) (string, error) {
145 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
146 + defer cancel()
147 + var val string
148 + err := d.QueryRowContext(cctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&val)
149 + if err != nil {
150 + return "", fmt.Errorf("get setting %s: %w", key, err)
151 + }
152 + return val, nil
153 +}
154 +
155 +func (d *DB) SetSetting(ctx context.Context, key, value string) error {
156 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
157 + defer cancel()
158 + _, err := d.ExecContext(cctx, `
159 + INSERT INTO settings (key, value) VALUES (?, ?)
160 + ON CONFLICT(key) DO UPDATE SET value = excluded.value
161 + `, key, value)
162 + if err != nil {
163 + return fmt.Errorf("set setting %s: %w", key, err)
164 + }
165 + return nil
166 +}
167 +
168 +func (d *DB) GetBoolSetting(ctx context.Context, key string, fallback bool) bool {
169 + val, err := d.GetSetting(ctx, key)
170 + if err != nil {
171 + return fallback
172 + }
173 + b, err := strconv.ParseBool(val)
174 + if err != nil {
175 + return fallback
176 + }
177 + return b
178 +}
179 +
180 +func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error {
181 + return d.SetSetting(ctx, key, strconv.FormatBool(enabled))
182 +}
183 +
184 +func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool {
185 + return d.GetBoolSetting(ctx, "registration_enabled", fallback)
186 +}
187 +
188 +func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error {
189 + return d.SetBoolSetting(ctx, "registration_enabled", enabled)
190 +}
191 +
192 +func (d *DB) IsExploreEnabled(ctx context.Context) bool {
193 + return d.GetBoolSetting(ctx, "explore_enabled", true)
194 +}
195 +
196 +func (d *DB) IsLandingEnabled(ctx context.Context, fallback bool) bool {
197 + return d.GetBoolSetting(ctx, "landing_enabled", fallback)
198 +}
199 +
200 +func (d *DB) IsAnonymousReadEnabled(ctx context.Context) bool {
201 + return d.GetBoolSetting(ctx, "anonymous_read_enabled", true)
202 +}
203 +
204 +func (d *DB) IsIssuesEnabled(ctx context.Context) bool {
205 + return d.GetBoolSetting(ctx, "issues_enabled", true)
206 +}
internal/db/db_test.go
+42 -0
... ... @@ -0,0 +1,42 @@
1 +package db
2 +
3 +import (
4 + "context"
5 + "path/filepath"
6 + "testing"
7 +)
8 +
9 +func TestOpenAndMigrate(t *testing.T) {
10 + ctx := context.Background()
11 + dbPath := filepath.Join(t.TempDir(), "test_ada.db")
12 +
13 + database, err := Open(ctx, dbPath, true)
14 + if err != nil {
15 + t.Fatalf("Open failed: %v", err)
16 + }
17 + defer database.Close()
18 +
19 + // Re-running Migrate must be idempotent
20 + if err := database.Migrate(ctx); err != nil {
21 + t.Fatalf("second Migrate failed: %v", err)
22 + }
23 +
24 + if !database.IsRegistrationEnabled(ctx, false) {
25 + t.Fatalf("expected registration_enabled to be seeded as true")
26 + }
27 +
28 + if err := database.SetRegistrationEnabled(ctx, false); err != nil {
29 + t.Fatalf("SetRegistrationEnabled failed: %v", err)
30 + }
31 + if database.IsRegistrationEnabled(ctx, true) {
32 + t.Fatalf("expected registration_enabled to be false after update")
33 + }
34 +
35 + var fk int
36 + if err := database.QueryRowContext(ctx, "PRAGMA foreign_keys;").Scan(&fk); err != nil {
37 + t.Fatalf("query foreign_keys pragma: %v", err)
38 + }
39 + if fk != 1 {
40 + t.Fatalf("expected foreign_keys = 1, got %d", fk)
41 + }
42 +}
internal/db/migrations/001_init.sql
+54 -0
... ... @@ -0,0 +1,54 @@
1 +CREATE TABLE IF NOT EXISTS users (
2 + id INTEGER PRIMARY KEY AUTOINCREMENT,
3 + username TEXT NOT NULL UNIQUE COLLATE NOCASE,
4 + email TEXT NOT NULL UNIQUE COLLATE NOCASE,
5 + password_hash TEXT NOT NULL,
6 + bio TEXT NOT NULL DEFAULT '',
7 + is_admin INTEGER NOT NULL DEFAULT 0,
8 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
9 +);
10 +
11 +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
12 +
13 +CREATE TABLE IF NOT EXISTS sessions (
14 + id TEXT PRIMARY KEY,
15 + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16 + csrf_token TEXT NOT NULL,
17 + expires_at DATETIME NOT NULL,
18 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
19 +);
20 +
21 +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
22 +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
23 +
24 +CREATE TABLE IF NOT EXISTS access_tokens (
25 + id INTEGER PRIMARY KEY AUTOINCREMENT,
26 + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
27 + name TEXT NOT NULL,
28 + token_prefix TEXT NOT NULL DEFAULT '',
29 + token_hash TEXT NOT NULL UNIQUE,
30 + last_used_at DATETIME,
31 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
32 +);
33 +
34 +CREATE INDEX IF NOT EXISTS idx_access_tokens_user_id ON access_tokens(user_id);
35 +CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens(token_hash);
36 +
37 +CREATE TABLE IF NOT EXISTS repositories (
38 + id INTEGER PRIMARY KEY AUTOINCREMENT,
39 + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
40 + name TEXT NOT NULL COLLATE NOCASE,
41 + description TEXT NOT NULL DEFAULT '',
42 + is_private INTEGER NOT NULL DEFAULT 0,
43 + default_branch TEXT NOT NULL DEFAULT 'main',
44 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
45 + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
46 + UNIQUE(owner_id, name)
47 +);
48 +
49 +CREATE INDEX IF NOT EXISTS idx_repositories_owner_id ON repositories(owner_id);
50 +
51 +CREATE TABLE IF NOT EXISTS settings (
52 + key TEXT PRIMARY KEY,
53 + value TEXT NOT NULL
54 +);
internal/db/migrations/002_features.sql
+29 -0
... ... @@ -0,0 +1,29 @@
1 +ALTER TABLE users ADD COLUMN full_name TEXT NOT NULL DEFAULT '';
2 +ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';
3 +ALTER TABLE users ADD COLUMN location TEXT NOT NULL DEFAULT '';
4 +ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT '';
5 +
6 +CREATE TABLE IF NOT EXISTS issues (
7 + id INTEGER PRIMARY KEY AUTOINCREMENT,
8 + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
9 + number INTEGER NOT NULL,
10 + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11 + title TEXT NOT NULL,
12 + body TEXT NOT NULL DEFAULT '',
13 + is_closed INTEGER NOT NULL DEFAULT 0,
14 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
15 + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
16 + UNIQUE(repo_id, number)
17 +);
18 +
19 +CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id, is_closed, number DESC);
20 +
21 +CREATE TABLE IF NOT EXISTS issue_comments (
22 + id INTEGER PRIMARY KEY AUTOINCREMENT,
23 + issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
24 + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
25 + body TEXT NOT NULL,
26 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
27 +);
28 +
29 +CREATE INDEX IF NOT EXISTS idx_issue_comments_issue_id ON issue_comments(issue_id, created_at ASC);
internal/db/migrations/003_pulls_releases_repo_settings.sql
+49 -0
... ... @@ -0,0 +1,49 @@
1 +ALTER TABLE repositories ADD COLUMN enable_issues INTEGER NOT NULL DEFAULT 1;
2 +ALTER TABLE repositories ADD COLUMN enable_pulls INTEGER NOT NULL DEFAULT 1;
3 +ALTER TABLE repositories ADD COLUMN enable_releases INTEGER NOT NULL DEFAULT 1;
4 +ALTER TABLE repositories ADD COLUMN enable_activity INTEGER NOT NULL DEFAULT 1;
5 +
6 +CREATE TABLE IF NOT EXISTS pull_requests (
7 + id INTEGER PRIMARY KEY AUTOINCREMENT,
8 + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
9 + number INTEGER NOT NULL,
10 + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11 + title TEXT NOT NULL,
12 + body TEXT NOT NULL DEFAULT '',
13 + base_branch TEXT NOT NULL,
14 + head_branch TEXT NOT NULL,
15 + status TEXT NOT NULL DEFAULT 'open',
16 + merged_by_id INTEGER REFERENCES users(id),
17 + merged_at DATETIME,
18 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
19 + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
20 + UNIQUE(repo_id, number)
21 +);
22 +
23 +CREATE INDEX IF NOT EXISTS idx_pulls_repo_id ON pull_requests(repo_id, status, number DESC);
24 +
25 +CREATE TABLE IF NOT EXISTS pull_request_comments (
26 + id INTEGER PRIMARY KEY AUTOINCREMENT,
27 + pull_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
28 + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
29 + body TEXT NOT NULL,
30 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
31 +);
32 +
33 +CREATE INDEX IF NOT EXISTS idx_pull_comments_pull_id ON pull_request_comments(pull_id, created_at ASC);
34 +
35 +CREATE TABLE IF NOT EXISTS releases (
36 + id INTEGER PRIMARY KEY AUTOINCREMENT,
37 + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
38 + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
39 + tag_name TEXT NOT NULL,
40 + title TEXT NOT NULL,
41 + body TEXT NOT NULL DEFAULT '',
42 + is_draft INTEGER NOT NULL DEFAULT 0,
43 + is_prerelease INTEGER NOT NULL DEFAULT 0,
44 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
45 + published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
46 + UNIQUE(repo_id, tag_name)
47 +);
48 +
49 +CREATE INDEX IF NOT EXISTS idx_releases_repo_id ON releases(repo_id, published_at DESC);
internal/db/migrations/004_social.sql
+41 -0
... ... @@ -0,0 +1,41 @@
1 +-- Collaborators: per-repository access grants for other users.
2 +CREATE TABLE IF NOT EXISTS collaborators (
3 + id INTEGER PRIMARY KEY AUTOINCREMENT,
4 + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
5 + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6 + role TEXT NOT NULL DEFAULT 'write',
7 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
8 + UNIQUE(repo_id, user_id)
9 +);
10 +CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id);
11 +
12 +-- Forks.
13 +ALTER TABLE repositories ADD COLUMN forked_from_repo_id INTEGER REFERENCES repositories(id) ON DELETE SET NULL;
14 +
15 +-- Fork-based (cross-repository) pull requests.
16 +ALTER TABLE pull_requests ADD COLUMN head_repo_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE;
17 +
18 +-- Pull request reviews (approve / request changes).
19 +CREATE TABLE IF NOT EXISTS pull_request_reviews (
20 + id INTEGER PRIMARY KEY AUTOINCREMENT,
21 + pull_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
22 + reviewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
23 + state TEXT NOT NULL,
24 + body TEXT NOT NULL DEFAULT '',
25 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
26 + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
27 + UNIQUE(pull_id, reviewer_id)
28 +);
29 +
30 +-- Device authorization grants (RFC 8628) used by the git credential helper.
31 +CREATE TABLE IF NOT EXISTS device_codes (
32 + device_code TEXT PRIMARY KEY,
33 + user_code TEXT NOT NULL UNIQUE,
34 + client_id TEXT NOT NULL DEFAULT '',
35 + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
36 + status TEXT NOT NULL DEFAULT 'pending',
37 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
38 + expires_at DATETIME NOT NULL,
39 + last_polled_at DATETIME
40 +);
41 +CREATE INDEX IF NOT EXISTS idx_device_codes_expires ON device_codes(expires_at);
internal/git/git.go
+1040 -0
... ... @@ -0,0 +1,1040 @@
1 +package git
2 +
3 +import (
4 + "bytes"
5 + "context"
6 + "encoding/binary"
7 + "errors"
8 + "fmt"
9 + "os"
10 + "os/exec"
11 + "path"
12 + "path/filepath"
13 + "sort"
14 + "strconv"
15 + "strings"
16 + "time"
17 + "unicode"
18 + "unicode/utf16"
19 + "unicode/utf8"
20 +)
21 +
22 +var (
23 + ErrInvalidRef = errors.New("invalid git reference")
24 + ErrInvalidPath = errors.New("invalid repository file path")
25 + ErrNotFound = errors.New("git object not found")
26 + ErrEmptyRepo = errors.New("repository is empty")
27 +)
28 +
29 +type TreeEntry struct {
30 + Mode string
31 + Type string // "tree" or "blob"
32 + Hash string
33 + Size int64
34 + Name string
35 + Path string
36 + CommitHash string
37 + CommitMsg string
38 + CommitTime time.Time
39 +}
40 +
41 +type Blob struct {
42 + Name string
43 + Path string
44 + Size int64
45 + Data []byte
46 + IsBinary bool
47 + LineCount int
48 +}
49 +
50 +type Commit struct {
51 + Hash string
52 + ShortHash string
53 + AuthorName string
54 + AuthorEmail string
55 + AuthoredAt time.Time
56 + Subject string
57 + Body string
58 + Parents []string
59 +}
60 +
61 +type DiffLine struct {
62 + Type string // "add", "del", "ctx", "hunk"
63 + Content string
64 + OldNum int
65 + NewNum int
66 +}
67 +
68 +type FileDiff struct {
69 + OldPath string
70 + NewPath string
71 + Status string // "added", "deleted", "modified", "renamed"
72 + Additions int
73 + Deletions int
74 + IsBinary bool
75 + Lines []DiffLine
76 +}
77 +
78 +type CommitDetail struct {
79 + Commit Commit
80 + FilesChanged int
81 + TotalAdditions int
82 + TotalDeletions int
83 + Files []FileDiff
84 +}
85 +
86 +func ValidateRef(ref string) error {
87 + ref = strings.TrimSpace(ref)
88 + if ref == "" || len(ref) > 255 {
89 + return ErrInvalidRef
90 + }
91 + if strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") ||
92 + strings.ContainsAny(ref, " \t\n\r~^:?*[\\`\x00") ||
93 + strings.HasSuffix(ref, ".lock") || strings.HasSuffix(ref, "/") {
94 + return ErrInvalidRef
95 + }
96 + return nil
97 +}
98 +
99 +func CleanSubPath(p string) (string, error) {
100 + p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
101 + p = strings.TrimPrefix(p, "/")
102 + p = strings.TrimSuffix(p, "/")
103 + if p == "" || p == "." {
104 + return "", nil
105 + }
106 + if strings.Contains(p, "\x00") || strings.HasPrefix(p, "-") {
107 + return "", ErrInvalidPath
108 + }
109 + cleaned := path.Clean(p)
110 + if cleaned == "." {
111 + return "", nil
112 + }
113 + if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, "/../") {
114 + return "", ErrInvalidPath
115 + }
116 + return cleaned, nil
117 +}
118 +
119 +func runGit(ctx context.Context, repoPath string, timeout time.Duration, args ...string) ([]byte, error) {
120 + cctx, cancel := context.WithTimeout(ctx, timeout)
121 + defer cancel()
122 +
123 + fullArgs := make([]string, 0, len(args)+2)
124 + if repoPath != "" {
125 + fullArgs = append(fullArgs, "-C", repoPath)
126 + }
127 + fullArgs = append(fullArgs, args...)
128 +
129 + cmd := exec.CommandContext(cctx, "git", fullArgs...)
130 + cmd.Env = append(os.Environ(),
131 + "GIT_TERMINAL_PROMPT=0",
132 + "LC_ALL=C",
133 + )
134 + var stdout, stderr bytes.Buffer
135 + cmd.Stdout = &stdout
136 + cmd.Stderr = &stderr
137 +
138 + if err := cmd.Run(); err != nil {
139 + return nil, fmt.Errorf("git %v: %w (%s)", args, err, strings.TrimSpace(stderr.String()))
140 + }
141 + return stdout.Bytes(), nil
142 +}
143 +
144 +func InitBare(ctx context.Context, repoPath, defaultBranch string) error {
145 + if defaultBranch == "" {
146 + defaultBranch = "main"
147 + }
148 + if err := ValidateRef(defaultBranch); err != nil {
149 + defaultBranch = "main"
150 + }
151 + if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil {
152 + return fmt.Errorf("mkdir repo parent: %w", err)
153 + }
154 +
155 + _, err := runGit(ctx, "", 10*time.Second, "init", "--bare", "--initial-branch="+defaultBranch, repoPath)
156 + if err != nil {
157 + if _, err2 := runGit(ctx, "", 10*time.Second, "init", "--bare", repoPath); err2 != nil {
158 + return fmt.Errorf("init bare repo: %w", err2)
159 + }
160 + }
161 + _, _ = runGit(ctx, repoPath, 5*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch)
162 + _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.receivepack", "true")
163 + _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.uploadpack", "true")
164 + return nil
165 +}
166 +
167 +func EnsureDefaultBranch(ctx context.Context, repoPath, preferred string) (string, bool) {
168 + if preferred == "" {
169 + preferred = "main"
170 + }
171 + branches, err := ListBranches(ctx, repoPath)
172 + if err != nil || len(branches) == 0 {
173 + return preferred, true
174 + }
175 +
176 + out, err := runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "--short", "HEAD")
177 + headBranch := strings.TrimSpace(string(out))
178 + if err == nil && headBranch != "" {
179 + for _, b := range branches {
180 + if b == headBranch {
181 + return headBranch, false
182 + }
183 + }
184 + }
185 +
186 + chosen := branches[0]
187 + for _, b := range branches {
188 + if b == preferred {
189 + chosen = preferred
190 + break
191 + }
192 + }
193 + _, _ = runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+chosen)
194 + return chosen, false
195 +}
196 +
197 +func ListBranches(ctx context.Context, repoPath string) ([]string, error) {
198 + out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
199 + if err != nil {
200 + return nil, err
201 + }
202 + return splitNonEmptyLines(string(out)), nil
203 +}
204 +
205 +func ListTags(ctx context.Context, repoPath string) ([]string, error) {
206 + out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)", "refs/tags/")
207 + if err != nil {
208 + return nil, err
209 + }
210 + return splitNonEmptyLines(string(out)), nil
211 +}
212 +
213 +func ListTree(ctx context.Context, repoPath, ref, subPath string) ([]TreeEntry, error) {
214 + if err := ValidateRef(ref); err != nil {
215 + return nil, err
216 + }
217 + cleanPath, err := CleanSubPath(subPath)
218 + if err != nil {
219 + return nil, err
220 + }
221 +
222 + target := ref
223 + if cleanPath != "" {
224 + target = ref + ":" + cleanPath
225 + }
226 +
227 + out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-l", target)
228 + if err != nil {
229 + if strings.Contains(err.Error(), "Not a valid object name") || strings.Contains(err.Error(), "bad file") {
230 + return nil, ErrNotFound
231 + }
232 + return nil, err
233 + }
234 +
235 + lines := splitNonEmptyLines(string(out))
236 + entries := make([]TreeEntry, 0, len(lines))
237 + for _, line := range lines {
238 + // Format: <mode> <type> <object> <size>\t<file>
239 + tabIdx := strings.IndexByte(line, '\t')
240 + if tabIdx == -1 {
241 + continue
242 + }
243 + meta := strings.Fields(line[:tabIdx])
244 + name := line[tabIdx+1:]
245 + if len(meta) < 4 {
246 + continue
247 + }
248 + var size int64
249 + if meta[3] != "-" {
250 + size, _ = strconv.ParseInt(meta[3], 10, 64)
251 + }
252 + entryPath := name
253 + if cleanPath != "" {
254 + entryPath = cleanPath + "/" + name
255 + }
256 + entries = append(entries, TreeEntry{
257 + Mode: meta[0],
258 + Type: meta[1],
259 + Hash: meta[2],
260 + Size: size,
261 + Name: name,
262 + Path: entryPath,
263 + })
264 + }
265 +
266 + sort.Slice(entries, func(i, j int) bool {
267 + if entries[i].Type != entries[j].Type {
268 + return entries[i].Type == "tree"
269 + }
270 + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
271 + })
272 +
273 + // Populate last commit summary for up to 50 entries
274 + if len(entries) <= 50 {
275 + for i := range entries {
276 + if c, err := GetLatestCommit(ctx, repoPath, ref, entries[i].Path); err == nil && c != nil {
277 + entries[i].CommitHash = c.ShortHash
278 + entries[i].CommitMsg = c.Subject
279 + entries[i].CommitTime = c.AuthoredAt
280 + }
281 + }
282 + }
283 +
284 + return entries, nil
285 +}
286 +
287 +func ListAllFiles(ctx context.Context, repoPath, ref string) ([]string, error) {
288 + if err := ValidateRef(ref); err != nil {
289 + return nil, err
290 + }
291 + out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-r", "--name-only", ref)
292 + if err != nil {
293 + return nil, err
294 + }
295 + files := splitNonEmptyLines(string(out))
296 + if len(files) > 1000 {
297 + files = files[:1000]
298 + }
299 + return files, nil
300 +}
301 +
302 +func ReadBlob(ctx context.Context, repoPath, ref, filePath string) (*Blob, error) {
303 + if err := ValidateRef(ref); err != nil {
304 + return nil, err
305 + }
306 + cleanPath, err := CleanSubPath(filePath)
307 + if err != nil || cleanPath == "" {
308 + return nil, ErrInvalidPath
309 + }
310 +
311 + spec := ref + ":" + cleanPath
312 + typeOut, err := runGit(ctx, repoPath, 5*time.Second, "cat-file", "-t", spec)
313 + if err != nil {
314 + return nil, ErrNotFound
315 + }
316 + if strings.TrimSpace(string(typeOut)) != "blob" {
317 + return nil, ErrNotFound
318 + }
319 +
320 + data, err := runGit(ctx, repoPath, 10*time.Second, "cat-file", "-p", spec)
321 + if err != nil {
322 + return nil, ErrNotFound
323 + }
324 +
325 + data = normalizeTextEncoding(data)
326 +
327 + checkLen := len(data)
328 + if checkLen > 8000 {
329 + checkLen = 8000
330 + }
331 + isBinary := bytes.IndexByte(data[:checkLen], 0) != -1 || !utf8.Valid(data[:checkLen])
332 +
333 + lineCount := 0
334 + if !isBinary && len(data) > 0 {
335 + lineCount = bytes.Count(data, []byte{'\n'})
336 + if data[len(data)-1] != '\n' {
337 + lineCount++
338 + }
339 + }
340 +
341 + return &Blob{
342 + Name: path.Base(cleanPath),
343 + Path: cleanPath,
344 + Size: int64(len(data)),
345 + Data: data,
346 + IsBinary: isBinary,
347 + LineCount: lineCount,
348 + }, nil
349 +}
350 +
351 +func FindReadme(ctx context.Context, repoPath, ref, subPath string) (*Blob, error) {
352 + candidates := []string{"README.md", "readme.md", "Readme.md", "README.markdown", "README.txt", "README"}
353 + cleanSub, err := CleanSubPath(subPath)
354 + if err != nil {
355 + return nil, err
356 + }
357 + for _, name := range candidates {
358 + p := name
359 + if cleanSub != "" {
360 + p = cleanSub + "/" + name
361 + }
362 + if blob, err := ReadBlob(ctx, repoPath, ref, p); err == nil && !blob.IsBinary {
363 + return blob, nil
364 + }
365 + }
366 + return nil, ErrNotFound
367 +}
368 +
369 +// normalizeTextEncoding converts BOM-marked text (UTF-8/UTF-16) to UTF-8 so
370 +// files written by Windows tooling (e.g. PowerShell) render as text instead
371 +// of being misdetected as binary.
372 +func normalizeTextEncoding(data []byte) []byte {
373 + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
374 + return data[3:]
375 + }
376 + if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE {
377 + if len(data) >= 4 && data[2] == 0x00 && data[3] == 0x00 {
378 + return data // UTF-32LE is left untouched
379 + }
380 + return utf16ToUTF8(data[2:], binary.LittleEndian)
381 + }
382 + if len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF {
383 + return utf16ToUTF8(data[2:], binary.BigEndian)
384 + }
385 + return data
386 +}
387 +
388 +func utf16ToUTF8(data []byte, order binary.ByteOrder) []byte {
389 + runes := make([]rune, 0, len(data)/2)
390 + for i := 0; i+1 < len(data); i += 2 {
391 + unit := order.Uint16(data[i : i+2])
392 + if unit == 0 {
393 + break
394 + }
395 + if utf16.IsSurrogate(rune(unit)) && i+3 < len(data) {
396 + next := order.Uint16(data[i+2 : i+4])
397 + if combined := utf16.DecodeRune(rune(unit), rune(next)); combined != unicode.ReplacementChar {
398 + runes = append(runes, combined)
399 + i += 2
400 + continue
401 + }
402 + }
403 + runes = append(runes, rune(unit))
404 + }
405 + return []byte(string(runes))
406 +}
407 +
408 +const commitFormat = "%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1f%P%x1e"
409 +
410 +func GetLatestCommit(ctx context.Context, repoPath, ref, subPath string) (*Commit, error) {
411 + if err := ValidateRef(ref); err != nil {
412 + return nil, err
413 + }
414 + args := []string{"log", "-n", "1", "--format=" + commitFormat, ref}
415 + if subPath != "" {
416 + cleanPath, err := CleanSubPath(subPath)
417 + if err != nil {
418 + return nil, err
419 + }
420 + if cleanPath != "" {
421 + args = append(args, "--", cleanPath)
422 + }
423 + }
424 + out, err := runGit(ctx, repoPath, 5*time.Second, args...)
425 + if err != nil {
426 + return nil, err
427 + }
428 + commits := parseCommits(string(out))
429 + if len(commits) == 0 {
430 + return nil, ErrNotFound
431 + }
432 + return &commits[0], nil
433 +}
434 +
435 +func ListCommits(ctx context.Context, repoPath, ref string, limit, offset int) ([]Commit, error) {
436 + if err := ValidateRef(ref); err != nil {
437 + return nil, err
438 + }
439 + if limit <= 0 || limit > 100 {
440 + limit = 30
441 + }
442 + if offset < 0 {
443 + offset = 0
444 + }
445 + out, err := runGit(ctx, repoPath, 10*time.Second,
446 + "log",
447 + "--max-count="+strconv.Itoa(limit),
448 + "--skip="+strconv.Itoa(offset),
449 + "--format="+commitFormat,
450 + ref,
451 + "--",
452 + )
453 + if err != nil {
454 + return nil, err
455 + }
456 + return parseCommits(string(out)), nil
457 +}
458 +
459 +func GetCommitDiff(ctx context.Context, repoPath, commitHash string) (*CommitDetail, error) {
460 + if err := ValidateRef(commitHash); err != nil {
461 + return nil, err
462 + }
463 +
464 + metaOut, err := runGit(ctx, repoPath, 5*time.Second, "show", "-s", "--format="+commitFormat, commitHash)
465 + if err != nil {
466 + return nil, ErrNotFound
467 + }
468 + commits := parseCommits(string(metaOut))
469 + if len(commits) == 0 {
470 + return nil, ErrNotFound
471 + }
472 + commit := commits[0]
473 +
474 + diffOut, err := runGit(ctx, repoPath, 15*time.Second, "show", "--patch", "--find-renames", "--format=", commitHash)
475 + if err != nil {
476 + return nil, err
477 + }
478 +
479 + files, adds, dels := parseUnifiedDiff(string(diffOut))
480 + return &CommitDetail{
481 + Commit: commit,
482 + FilesChanged: len(files),
483 + TotalAdditions: adds,
484 + TotalDeletions: dels,
485 + Files: files,
486 + }, nil
487 +}
488 +
489 +func parseCommits(raw string) []Commit {
490 + records := strings.Split(raw, "\x1e")
491 + var commits []Commit
492 + for _, rec := range records {
493 + rec = strings.TrimSpace(rec)
494 + if rec == "" {
495 + continue
496 + }
497 + fields := strings.Split(rec, "\x1f")
498 + if len(fields) < 7 {
499 + continue
500 + }
501 + ts, _ := strconv.ParseInt(strings.TrimSpace(fields[4]), 10, 64)
502 + var parents []string
503 + if len(fields) >= 8 && strings.TrimSpace(fields[7]) != "" {
504 + parents = strings.Fields(fields[7])
505 + }
506 + commits = append(commits, Commit{
507 + Hash: strings.TrimSpace(fields[0]),
508 + ShortHash: strings.TrimSpace(fields[1]),
509 + AuthorName: strings.TrimSpace(fields[2]),
510 + AuthorEmail: strings.TrimSpace(fields[3]),
511 + AuthoredAt: time.Unix(ts, 0).UTC(),
512 + Subject: strings.TrimSpace(fields[5]),
513 + Body: strings.TrimSpace(fields[6]),
514 + Parents: parents,
515 + })
516 + }
517 + return commits
518 +}
519 +
520 +func parseUnifiedDiff(raw string) ([]FileDiff, int, int) {
521 + var files []FileDiff
522 + var current *FileDiff
523 + totalAdds := 0
524 + totalDels := 0
525 +
526 + oldLine := 0
527 + newLine := 0
528 +
529 + lines := strings.Split(raw, "\n")
530 + for _, line := range lines {
531 + line = strings.TrimSuffix(line, "\r")
532 + if strings.HasPrefix(line, "diff --git ") {
533 + if current != nil {
534 + files = append(files, *current)
535 + }
536 + oldP, newP := parseDiffGitHeader(line)
537 + current = &FileDiff{
538 + OldPath: oldP,
539 + NewPath: newP,
540 + Status: "modified",
541 + }
542 + continue
543 + }
544 + if current == nil {
545 + continue
546 + }
547 +
548 + if strings.HasPrefix(line, "new file mode ") {
549 + current.Status = "added"
550 + continue
551 + }
552 + if strings.HasPrefix(line, "deleted file mode ") {
553 + current.Status = "deleted"
554 + continue
555 + }
556 + if strings.HasPrefix(line, "rename from ") {
557 + current.Status = "renamed"
558 + current.OldPath = strings.TrimPrefix(line, "rename from ")
559 + continue
560 + }
561 + if strings.HasPrefix(line, "rename to ") {
562 + current.Status = "renamed"
563 + current.NewPath = strings.TrimPrefix(line, "rename to ")
564 + continue
565 + }
566 + if strings.HasPrefix(line, "Binary files ") {
567 + current.IsBinary = true
568 + continue
569 + }
570 + if strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
571 + continue
572 + }
573 +
574 + if strings.HasPrefix(line, "@@ ") {
575 + oldLine, newLine = parseHunkHeader(line)
576 + current.Lines = append(current.Lines, DiffLine{
577 + Type: "hunk",
578 + Content: line,
579 + })
580 + continue
581 + }
582 +
583 + if len(current.Lines) == 0 {
584 + continue
585 + }
586 +
587 + if strings.HasPrefix(line, "+") {
588 + current.Additions++
589 + totalAdds++
590 + current.Lines = append(current.Lines, DiffLine{
591 + Type: "add",
592 + Content: line[1:],
593 + NewNum: newLine,
594 + })
595 + newLine++
596 + } else if strings.HasPrefix(line, "-") {
597 + current.Deletions++
598 + totalDels++
599 + current.Lines = append(current.Lines, DiffLine{
600 + Type: "del",
601 + Content: line[1:],
602 + OldNum: oldLine,
603 + })
604 + oldLine++
605 + } else if strings.HasPrefix(line, " ") {
606 + current.Lines = append(current.Lines, DiffLine{
607 + Type: "ctx",
608 + Content: line[1:],
609 + OldNum: oldLine,
610 + NewNum: newLine,
611 + })
612 + oldLine++
613 + newLine++
614 + }
615 + }
616 +
617 + if current != nil {
618 + files = append(files, *current)
619 + }
620 + return files, totalAdds, totalDels
621 +}
622 +
623 +func parseDiffGitHeader(line string) (string, string) {
624 + // diff --git a/foo b/foo
625 + rest := strings.TrimPrefix(line, "diff --git ")
626 + parts := strings.SplitN(rest, " b/", 2)
627 + if len(parts) == 2 {
628 + return strings.TrimPrefix(parts[0], "a/"), parts[1]
629 + }
630 + return rest, rest
631 +}
632 +
633 +func parseHunkHeader(line string) (int, int) {
634 + // @@ -oldStart,oldCount +newStart,newCount @@
635 + oldStart, newStart := 1, 1
636 + endIdx := strings.Index(line[3:], "@@")
637 + if endIdx == -1 {
638 + return oldStart, newStart
639 + }
640 + header := strings.TrimSpace(line[3 : 3+endIdx])
641 + fields := strings.Fields(header)
642 + for _, f := range fields {
643 + if strings.HasPrefix(f, "-") {
644 + nums := strings.SplitN(f[1:], ",", 2)
645 + if v, err := strconv.Atoi(nums[0]); err == nil {
646 + oldStart = v
647 + }
648 + } else if strings.HasPrefix(f, "+") {
649 + nums := strings.SplitN(f[1:], ",", 2)
650 + if v, err := strconv.Atoi(nums[0]); err == nil {
651 + newStart = v
652 + }
653 + }
654 + }
655 + return oldStart, newStart
656 +}
657 +
658 +func splitNonEmptyLines(s string) []string {
659 + raw := strings.Split(s, "\n")
660 + out := make([]string, 0, len(raw))
661 + for _, l := range raw {
662 + l = strings.TrimSpace(l)
663 + if l != "" {
664 + out = append(out, l)
665 + }
666 + }
667 + return out
668 +}
669 +
670 +type BranchInfo struct {
671 + Name string
672 + Hash string
673 + ShortHash string
674 + Subject string
675 + AuthorName string
676 + UpdatedAt time.Time
677 + IsDefault bool
678 +}
679 +
680 +type TagInfo struct {
681 + Name string
682 + Hash string
683 + ShortHash string
684 + Subject string
685 + CreatedAt time.Time
686 +}
687 +
688 +func ListBranchDetails(ctx context.Context, repoPath, defaultBranch string) ([]BranchInfo, error) {
689 + out, err := runGit(ctx, repoPath, 5*time.Second,
690 + "for-each-ref",
691 + "--sort=-committerdate",
692 + "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(authorname)%00%(committerdate:unix)",
693 + "refs/heads/",
694 + )
695 + if err != nil {
696 + return nil, err
697 + }
698 + lines := splitNonEmptyLines(string(out))
699 + res := make([]BranchInfo, 0, len(lines))
700 + for _, l := range lines {
701 + parts := strings.Split(l, "\x00")
702 + if len(parts) < 6 {
703 + continue
704 + }
705 + ts, _ := strconv.ParseInt(parts[5], 10, 64)
706 + var tm time.Time
707 + if ts > 0 {
708 + tm = time.Unix(ts, 0).UTC()
709 + }
710 + res = append(res, BranchInfo{
711 + Name: parts[0],
712 + Hash: parts[1],
713 + ShortHash: parts[2],
714 + Subject: parts[3],
715 + AuthorName: parts[4],
716 + UpdatedAt: tm,
717 + IsDefault: parts[0] == defaultBranch,
718 + })
719 + }
720 + sort.SliceStable(res, func(i, j int) bool {
721 + if res[i].IsDefault != res[j].IsDefault {
722 + return res[i].IsDefault
723 + }
724 + return res[i].UpdatedAt.After(res[j].UpdatedAt)
725 + })
726 + return res, nil
727 +}
728 +
729 +func ListTagDetails(ctx context.Context, repoPath string) ([]TagInfo, error) {
730 + out, err := runGit(ctx, repoPath, 5*time.Second,
731 + "for-each-ref",
732 + "--sort=-creatordate",
733 + "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(creatordate:unix)",
734 + "refs/tags/",
735 + )
736 + if err != nil {
737 + return nil, err
738 + }
739 + lines := splitNonEmptyLines(string(out))
740 + res := make([]TagInfo, 0, len(lines))
741 + for _, l := range lines {
742 + parts := strings.Split(l, "\x00")
743 + if len(parts) < 5 {
744 + continue
745 + }
746 + ts, _ := strconv.ParseInt(parts[4], 10, 64)
747 + var tm time.Time
748 + if ts > 0 {
749 + tm = time.Unix(ts, 0).UTC()
750 + }
751 + res = append(res, TagInfo{
752 + Name: parts[0],
753 + Hash: parts[1],
754 + ShortHash: parts[2],
755 + Subject: parts[3],
756 + CreatedAt: tm,
757 + })
758 + }
759 + return res, nil
760 +}
761 +
762 +func CreateBranch(ctx context.Context, repoPath, branchName, fromRef string) error {
763 + branchName = strings.TrimSpace(branchName)
764 + fromRef = strings.TrimSpace(fromRef)
765 + if err := ValidateRef(branchName); err != nil {
766 + return err
767 + }
768 + if err := ValidateRef(fromRef); err != nil {
769 + return err
770 + }
771 + _, err := runGit(ctx, repoPath, 5*time.Second, "branch", branchName, fromRef)
772 + return err
773 +}
774 +
775 +func DeleteBranch(ctx context.Context, repoPath, branchName string) error {
776 + branchName = strings.TrimSpace(branchName)
777 + if err := ValidateRef(branchName); err != nil {
778 + return err
779 + }
780 + _, err := runGit(ctx, repoPath, 5*time.Second, "branch", "-D", branchName)
781 + return err
782 +}
783 +
784 +func CreateTag(ctx context.Context, repoPath, tagName, fromRef, message string) error {
785 + tagName = strings.TrimSpace(tagName)
786 + fromRef = strings.TrimSpace(fromRef)
787 + message = strings.TrimSpace(message)
788 + if err := ValidateRef(tagName); err != nil {
789 + return err
790 + }
791 + if err := ValidateRef(fromRef); err != nil {
792 + return err
793 + }
794 + if message != "" {
795 + _, err := runGit(ctx, repoPath, 5*time.Second,
796 + "-c", "user.name=Polar",
797 + "-c", "user.email=polar@localhost",
798 + "tag", "-a", tagName, "-m", message, fromRef,
799 + )
800 + return err
801 + }
802 + _, err := runGit(ctx, repoPath, 5*time.Second, "tag", tagName, fromRef)
803 + return err
804 +}
805 +
806 +func DeleteTag(ctx context.Context, repoPath, tagName string) error {
807 + tagName = strings.TrimSpace(tagName)
808 + if err := ValidateRef(tagName); err != nil {
809 + return err
810 + }
811 + _, err := runGit(ctx, repoPath, 5*time.Second, "tag", "-d", tagName)
812 + return err
813 +}
814 +
815 +func ArchiveZip(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) {
816 + ref = strings.TrimSpace(ref)
817 + if err := ValidateRef(ref); err != nil {
818 + return nil, err
819 + }
820 + prefix = strings.Trim(prefix, "/")
821 + if prefix == "" {
822 + prefix = "repo"
823 + }
824 + return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=zip", "--prefix="+prefix+"/", ref)
825 +}
826 +
827 +func ArchiveTarGz(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) {
828 + ref = strings.TrimSpace(ref)
829 + if err := ValidateRef(ref); err != nil {
830 + return nil, err
831 + }
832 + prefix = strings.Trim(prefix, "/")
833 + if prefix == "" {
834 + prefix = "repo"
835 + }
836 + return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref)
837 +}
838 +
839 +func CompareBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (*CommitDetail, []Commit, error) {
840 + if err := ValidateRef(baseBranch); err != nil {
841 + return nil, nil, err
842 + }
843 + if err := ValidateRef(headBranch); err != nil {
844 + return nil, nil, err
845 + }
846 + out, err := runGit(ctx, repoPath, 10*time.Second, "log", "--format="+commitFormat, baseBranch+".."+headBranch, "--")
847 + if err != nil {
848 + return nil, nil, fmt.Errorf("git log base..head: %w", err)
849 + }
850 + commits := parseCommits(string(out))
851 +
852 + diffOut, err := runGit(ctx, repoPath, 15*time.Second, "diff", "--patch", "--find-renames", baseBranch+"..."+headBranch)
853 + if err != nil {
854 + return nil, nil, fmt.Errorf("git diff base...head: %w", err)
855 + }
856 + files, adds, dels := parseUnifiedDiff(string(diffOut))
857 +
858 + detail := &CommitDetail{
859 + FilesChanged: len(files),
860 + TotalAdditions: adds,
861 + TotalDeletions: dels,
862 + Files: files,
863 + }
864 + return detail, commits, nil
865 +}
866 +
867 +func CanMergeBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (canMerge bool, isFastForward bool, err error) {
868 + if err := ValidateRef(baseBranch); err != nil {
869 + return false, false, err
870 + }
871 + if err := ValidateRef(headBranch); err != nil {
872 + return false, false, err
873 + }
874 +
875 + _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", headBranch, baseBranch)
876 + if err == nil {
877 + return true, true, nil
878 + }
879 +
880 + _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", baseBranch, headBranch)
881 + if err == nil {
882 + return true, true, nil
883 + }
884 +
885 + baseCommit, err := runGit(ctx, repoPath, 5*time.Second, "merge-base", baseBranch, headBranch)
886 + if err != nil {
887 + return false, false, fmt.Errorf("find merge base: %w", err)
888 + }
889 + baseHash := strings.TrimSpace(string(baseCommit))
890 + if baseHash == "" {
891 + return false, false, errors.New("no common ancestor")
892 + }
893 +
894 + treeOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", baseHash, baseBranch, headBranch)
895 + if err != nil {
896 + return false, false, nil
897 + }
898 + if strings.Contains(string(treeOut), "+<<<<<<<") {
899 + return false, false, nil
900 + }
901 + return true, false, nil
902 +}
903 +
904 +func MergeBranches(ctx context.Context, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail string) (string, error) {
905 + if err := ValidateRef(baseBranch); err != nil {
906 + return "", err
907 + }
908 + if err := ValidateRef(headBranch); err != nil {
909 + return "", err
910 + }
911 + return mergeRefs(ctx, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail)
912 +}
913 +
914 +// MergeFromPath fetches headBranch from another local repository (a fork) and merges
915 +// it into baseBranch of repoPath. Used to merge cross-repository pull requests.
916 +func MergeFromPath(ctx context.Context, repoPath, forkPath, baseBranch, headBranch, tempRef, commitMsg, authorName, authorEmail string) (string, error) {
917 + if err := ValidateRef(baseBranch); err != nil {
918 + return "", err
919 + }
920 + if err := ValidateRef(headBranch); err != nil {
921 + return "", err
922 + }
923 + if tempRef == "" {
924 + tempRef = "refs/polar/tmp-merge"
925 + }
926 + if err := FetchToTemp(ctx, repoPath, forkPath, headBranch, tempRef); err != nil {
927 + return "", err
928 + }
929 + defer DeleteRef(context.WithoutCancel(ctx), repoPath, tempRef)
930 + return mergeRefs(ctx, repoPath, baseBranch, tempRef, commitMsg, authorName, authorEmail)
931 +}
932 +
933 +// FetchToTemp fetches a branch from another local repository into a temporary ref.
934 +func FetchToTemp(ctx context.Context, repoPath, srcPath, branch, tempRef string) error {
935 + if err := ValidateRef(branch); err != nil {
936 + return err
937 + }
938 + _, err := runGit(ctx, repoPath, 120*time.Second, "fetch", "--no-tags", "--quiet", srcPath, "+refs/heads/"+branch+":"+tempRef)
939 + if err != nil {
940 + return fmt.Errorf("fetch branch %s: %w", branch, err)
941 + }
942 + return nil
943 +}
944 +
945 +// DeleteRef removes a ref; missing refs are ignored.
946 +func DeleteRef(ctx context.Context, repoPath, ref string) {
947 + _, _ = runGit(ctx, repoPath, 10*time.Second, "update-ref", "-d", ref)
948 +}
949 +
950 +// CloneBare copies a bare repository to a new location (used when creating forks).
951 +func CloneBare(ctx context.Context, srcPath, dstPath string) error {
952 + if _, err := runGit(ctx, "", 180*time.Second, "clone", "--bare", "--quiet", srcPath, dstPath); err != nil {
953 + return fmt.Errorf("clone bare repository: %w", err)
954 + }
955 + _, _ = runGit(ctx, dstPath, 10*time.Second, "remote", "remove", "origin")
956 + return nil
957 +}
958 +
959 +// BranchExists reports whether a local branch exists in a bare repository.
960 +func BranchExists(ctx context.Context, repoPath, branch string) bool {
961 + if err := ValidateRef(branch); err != nil {
962 + return false
963 + }
964 + _, err := runGit(ctx, repoPath, 5*time.Second, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
965 + return err == nil
966 +}
967 +
968 +// CountCommits returns the number of commits reachable from ref (0 on error).
969 +func CountCommits(ctx context.Context, repoPath, ref string) int {
970 + if err := ValidateRef(ref); err != nil {
971 + return 0
972 + }
973 + out, err := runGit(ctx, repoPath, 10*time.Second, "rev-list", "--count", ref)
974 + if err != nil {
975 + return 0
976 + }
977 + n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
978 + return n
979 +}
980 +
981 +func mergeRefs(ctx context.Context, repoPath, baseRef, headRef, commitMsg, authorName, authorEmail string) (string, error) {
982 + canMerge, isFF, err := CanMergeBranches(ctx, repoPath, baseRef, headRef)
983 + if err != nil {
984 + return "", err
985 + }
986 + if !canMerge {
987 + return "", errors.New("cannot merge: conflicting changes detected")
988 + }
989 +
990 + headCommit, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef)
991 + if err != nil {
992 + return "", fmt.Errorf("rev-parse head: %w", err)
993 + }
994 + headHash := strings.TrimSpace(string(headCommit))
995 +
996 + if isFF {
997 + _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, headHash)
998 + if err != nil {
999 + return "", fmt.Errorf("update-ref fast-forward: %w", err)
1000 + }
1001 + return headHash, nil
1002 + }
1003 +
1004 + wtOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", "--write-tree", baseRef, headRef)
1005 + var treeHash string
1006 + if err == nil && len(strings.TrimSpace(string(wtOut))) == 40 {
1007 + treeHash = strings.TrimSpace(string(wtOut))
1008 + } else {
1009 + headTree, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef+"^{tree}")
1010 + treeHash = strings.TrimSpace(string(headTree))
1011 + }
1012 +
1013 + baseHeadHash, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", baseRef)
1014 + baseHashStr := strings.TrimSpace(string(baseHeadHash))
1015 +
1016 + if commitMsg == "" {
1017 + commitMsg = fmt.Sprintf("Merge branch '%s' into %s", headRef, baseRef)
1018 + }
1019 +
1020 + commitArgs := []string{"commit-tree", treeHash, "-p", baseHashStr, "-p", headHash, "-m", commitMsg}
1021 + cmd := exec.CommandContext(ctx, "git", commitArgs...)
1022 + cmd.Dir = repoPath
1023 + cmd.Env = append(os.Environ(),
1024 + "GIT_AUTHOR_NAME="+authorName,
1025 + "GIT_AUTHOR_EMAIL="+authorEmail,
1026 + "GIT_COMMITTER_NAME="+authorName,
1027 + "GIT_COMMITTER_EMAIL="+authorEmail,
1028 + )
1029 + out, err := cmd.CombinedOutput()
1030 + if err != nil {
1031 + return "", fmt.Errorf("commit-tree failed: %s (%w)", string(out), err)
1032 + }
1033 + mergeHash := strings.TrimSpace(string(out))
1034 +
1035 + _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, mergeHash)
1036 + if err != nil {
1037 + return "", fmt.Errorf("update-ref: %w", err)
1038 + }
1039 + return mergeHash, nil
1040 +}
internal/git/git_test.go
+105 -0
... ... @@ -0,0 +1,105 @@
1 +package git
2 +
3 +import (
4 + "context"
5 + "os"
6 + "os/exec"
7 + "path/filepath"
8 + "testing"
9 +)
10 +
11 +func TestValidateRefAndCleanSubPath(t *testing.T) {
12 + if err := ValidateRef("main"); err != nil {
13 + t.Fatalf("expected 'main' to be valid ref: %v", err)
14 + }
15 + if err := ValidateRef("feature/ui-kit"); err != nil {
16 + t.Fatalf("expected 'feature/ui-kit' to be valid ref: %v", err)
17 + }
18 + for _, bad := range []string{"", "-flag", "a..b", "ref.lock", "ref with space"} {
19 + if err := ValidateRef(bad); err == nil {
20 + t.Fatalf("expected ref %q to be rejected", bad)
21 + }
22 + }
23 +
24 + if p, err := CleanSubPath("internal/web/views.go"); err != nil || p != "internal/web/views.go" {
25 + t.Fatalf("CleanSubPath failed: p=%q err=%v", p, err)
26 + }
27 + for _, badPath := range []string{"../secret", "a/../../b", "-flag"} {
28 + if _, err := CleanSubPath(badPath); err == nil {
29 + t.Fatalf("expected path %q to be rejected", badPath)
30 + }
31 + }
32 +}
33 +
34 +func TestBareRepoReadAndDiff(t *testing.T) {
35 + ctx := context.Background()
36 + tmp := t.TempDir()
37 + barePath := filepath.Join(tmp, "repo.git")
38 +
39 + if err := InitBare(ctx, barePath, "main"); err != nil {
40 + t.Fatalf("InitBare failed: %v", err)
41 + }
42 +
43 + _, isEmpty := EnsureDefaultBranch(ctx, barePath, "main")
44 + if !isEmpty {
45 + t.Fatalf("expected newly initialized bare repo to be empty")
46 + }
47 +
48 + // Create a working repo and push a commit into barePath
49 + work := filepath.Join(tmp, "work")
50 + if err := os.MkdirAll(filepath.Join(work, "pkg"), 0o755); err != nil {
51 + t.Fatalf("mkdir work: %v", err)
52 + }
53 +
54 + run := func(args ...string) {
55 + t.Helper()
56 + cmd := exec.CommandContext(ctx, "git", args...)
57 + cmd.Dir = work
58 + if out, err := cmd.CombinedOutput(); err != nil {
59 + t.Fatalf("git %v: %v (%s)", args, err, string(out))
60 + }
61 + }
62 +
63 + run("init", "-b", "main")
64 + run("config", "user.name", "Alice")
65 + run("config", "user.email", "alice@example.com")
66 + _ = os.WriteFile(filepath.Join(work, "README.md"), []byte("# Test Repo\n"), 0o644)
67 + _ = os.WriteFile(filepath.Join(work, "pkg", "hello.go"), []byte("package pkg\n\nfunc Hello() string { return \"hi\" }\n"), 0o644)
68 + run("add", ".")
69 + run("commit", "-m", "Add README and hello.go")
70 + run("push", barePath, "main")
71 +
72 + branch, isEmpty := EnsureDefaultBranch(ctx, barePath, "main")
73 + if isEmpty || branch != "main" {
74 + t.Fatalf("expected non-empty repo on branch main, got branch=%q isEmpty=%v", branch, isEmpty)
75 + }
76 +
77 + entries, err := ListTree(ctx, barePath, "main", "")
78 + if err != nil || len(entries) != 2 {
79 + t.Fatalf("ListTree failed: len=%d err=%v", len(entries), err)
80 + }
81 + // Directory "pkg" should be sorted before file "README.md"
82 + if entries[0].Type != "tree" || entries[0].Name != "pkg" {
83 + t.Fatalf("expected directory 'pkg' first, got %+v", entries[0])
84 + }
85 +
86 + readme, err := FindReadme(ctx, barePath, "main", "")
87 + if err != nil || readme.Name != "README.md" {
88 + t.Fatalf("FindReadme failed: %v", err)
89 + }
90 +
91 + blob, err := ReadBlob(ctx, barePath, "main", "pkg/hello.go")
92 + if err != nil || blob.IsBinary || blob.LineCount != 3 {
93 + t.Fatalf("ReadBlob failed: %+v err=%v", blob, err)
94 + }
95 +
96 + commits, err := ListCommits(ctx, barePath, "main", 10, 0)
97 + if err != nil || len(commits) != 1 {
98 + t.Fatalf("ListCommits failed: len=%d err=%v", len(commits), err)
99 + }
100 +
101 + detail, err := GetCommitDiff(ctx, barePath, commits[0].Hash)
102 + if err != nil || detail.FilesChanged != 2 || detail.TotalAdditions < 4 {
103 + t.Fatalf("GetCommitDiff failed: %+v err=%v", detail, err)
104 + }
105 +}
internal/git/http.go
+85 -0
... ... @@ -0,0 +1,85 @@
1 +package git
2 +
3 +import (
4 + "bytes"
5 + "context"
6 + "errors"
7 + "fmt"
8 + "io"
9 + "os"
10 + "os/exec"
11 + "strings"
12 + "time"
13 +)
14 +
15 +var ErrUnsupportedService = errors.New("unsupported git service")
16 +
17 +func subcommandForService(service string) (string, error) {
18 + switch service {
19 + case "git-upload-pack":
20 + return "upload-pack", nil
21 + case "git-receive-pack":
22 + return "receive-pack", nil
23 + default:
24 + return "", ErrUnsupportedService
25 + }
26 +}
27 +
28 +func pktLine(payload string) string {
29 + return fmt.Sprintf("%04x%s", len(payload)+4, payload)
30 +}
31 +
32 +func AdvertiseRefs(ctx context.Context, repoPath, service string, out io.Writer) error {
33 + subcmd, err := subcommandForService(service)
34 + if err != nil {
35 + return err
36 + }
37 +
38 + cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
39 + defer cancel()
40 +
41 + var stdout, stderr bytes.Buffer
42 + cmd := exec.CommandContext(cctx, "git", subcmd, "--stateless-rpc", "--advertise-refs", repoPath)
43 + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
44 + cmd.Stdout = &stdout
45 + cmd.Stderr = &stderr
46 +
47 + if err := cmd.Run(); err != nil {
48 + return fmt.Errorf("git %s advertise-refs: %w (%s)", subcmd, err, strings.TrimSpace(stderr.String()))
49 + }
50 +
51 + header := pktLine(fmt.Sprintf("# service=%s\n", service)) + "0000"
52 + if _, err := io.WriteString(out, header); err != nil {
53 + return err
54 + }
55 + _, err = out.Write(stdout.Bytes())
56 + return err
57 +}
58 +
59 +func ServiceRPC(ctx context.Context, repoPath, service string, in io.Reader, out io.Writer) error {
60 + subcmd, err := subcommandForService(service)
61 + if err != nil {
62 + return err
63 + }
64 +
65 + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
66 + defer cancel()
67 +
68 + var stderr bytes.Buffer
69 + cmd := exec.CommandContext(cctx, "git", subcmd, "--stateless-rpc", repoPath)
70 + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
71 + cmd.Stdin = in
72 + cmd.Stdout = out
73 + cmd.Stderr = &stderr
74 +
75 + if err := cmd.Run(); err != nil {
76 + return fmt.Errorf("git %s stateless-rpc: %w (%s)", subcmd, err, strings.TrimSpace(stderr.String()))
77 + }
78 +
79 + if service == "git-receive-pack" {
80 + _, _ = EnsureDefaultBranch(ctx, repoPath, "main")
81 + _, _ = runGit(ctx, repoPath, 5*time.Second, "update-server-info")
82 + }
83 +
84 + return nil
85 +}
internal/git/merge_test.go
+80 -0
... ... @@ -0,0 +1,80 @@
1 +package git
2 +
3 +import (
4 + "context"
5 + "os"
6 + "os/exec"
7 + "path/filepath"
8 + "strings"
9 + "testing"
10 +)
11 +
12 +// TestMergeFromPath verifies cross-repository (fork) merging through a
13 +// temporary ref fetched from another local repository.
14 +func TestMergeFromPath(t *testing.T) {
15 + ctx := context.Background()
16 + tmp := t.TempDir()
17 +
18 + basePath := filepath.Join(tmp, "base.git")
19 + if err := InitBare(ctx, basePath, "main"); err != nil {
20 + t.Fatalf("InitBare: %v", err)
21 + }
22 +
23 + work := filepath.Join(tmp, "work")
24 + if err := os.MkdirAll(work, 0o755); err != nil {
25 + t.Fatalf("mkdir: %v", err)
26 + }
27 + run := func(dir string, args ...string) {
28 + t.Helper()
29 + cmd := exec.CommandContext(ctx, "git", args...)
30 + cmd.Dir = dir
31 + if out, err := cmd.CombinedOutput(); err != nil {
32 + t.Fatalf("git %v: %v (%s)", args, err, string(out))
33 + }
34 + }
35 +
36 + run(work, "init", "-b", "main")
37 + run(work, "config", "user.name", "Alice")
38 + run(work, "config", "user.email", "alice@example.com")
39 + if err := os.WriteFile(filepath.Join(work, "file.txt"), []byte("base\n"), 0o644); err != nil {
40 + t.Fatalf("write: %v", err)
41 + }
42 + run(work, "add", ".")
43 + run(work, "commit", "-m", "base commit")
44 + run(work, "push", basePath, "main")
45 +
46 + forkPath := filepath.Join(tmp, "fork.git")
47 + if err := CloneBare(ctx, basePath, forkPath); err != nil {
48 + t.Fatalf("CloneBare: %v", err)
49 + }
50 +
51 + // Add a feature branch on the fork.
52 + run(work, "checkout", "-b", "feature")
53 + if err := os.WriteFile(filepath.Join(work, "feature.txt"), []byte("feature\n"), 0o644); err != nil {
54 + t.Fatalf("write: %v", err)
55 + }
56 + run(work, "add", ".")
57 + run(work, "commit", "-m", "add feature")
58 + run(work, "push", forkPath, "feature")
59 +
60 + hash, err := MergeFromPath(ctx, basePath, forkPath, "main", "feature", "refs/polar/tmp-merge-test", "Merge PR #1", "Alice", "alice@example.com")
61 + if err != nil {
62 + t.Fatalf("MergeFromPath: %v", err)
63 + }
64 + if hash == "" {
65 + t.Fatalf("expected merge commit hash")
66 + }
67 +
68 + out, err := runGit(ctx, basePath, 10_000_000_000, "ls-tree", "-r", "--name-only", "main")
69 + if err != nil {
70 + t.Fatalf("ls-tree: %v", err)
71 + }
72 + if !strings.Contains(string(out), "feature.txt") {
73 + t.Fatalf("expected feature.txt on merged main, got: %s", string(out))
74 + }
75 +
76 + // The temporary ref must be gone after the merge.
77 + if out, err := runGit(ctx, basePath, 10_000_000_000, "show-ref", "--verify", "--quiet", "refs/polar/tmp-merge-test"); err == nil {
78 + t.Fatalf("expected temp ref to be deleted, got %s", string(out))
79 + }
80 +}
internal/git/text_encoding_test.go
+100 -0
... ... @@ -0,0 +1,100 @@
1 +package git
2 +
3 +import (
4 + "context"
5 + "encoding/binary"
6 + "os"
7 + "os/exec"
8 + "path/filepath"
9 + "testing"
10 + "unicode/utf16"
11 +)
12 +
13 +func utf16Bytes(s string, order binary.ByteOrder) []byte {
14 + units := utf16.Encode([]rune(s))
15 + out := make([]byte, 0, len(units)*2)
16 + for _, u := range units {
17 + var pair [2]byte
18 + order.PutUint16(pair[:], u)
19 + out = append(out, pair[:]...)
20 + }
21 + return out
22 +}
23 +
24 +func TestNormalizeTextEncoding(t *testing.T) {
25 + utf8BOM := append([]byte{0xEF, 0xBB, 0xBF}, []byte("# readme")...)
26 + if got := string(normalizeTextEncoding(utf8BOM)); got != "# readme" {
27 + t.Fatalf("utf-8 BOM: got %q", got)
28 + }
29 +
30 + le := append([]byte{0xFF, 0xFE}, utf16Bytes("# readme \u2713", binary.LittleEndian)...)
31 + if got := string(normalizeTextEncoding(le)); got != "# readme \u2713" {
32 + t.Fatalf("utf-16le: got %q", got)
33 + }
34 +
35 + be := append([]byte{0xFE, 0xFF}, utf16Bytes("# readme \u2713", binary.BigEndian)...)
36 + if got := string(normalizeTextEncoding(be)); got != "# readme \u2713" {
37 + t.Fatalf("utf-16be: got %q", got)
38 + }
39 +
40 + plain := []byte("# plain readme")
41 + if got := string(normalizeTextEncoding(plain)); got != "# plain readme" {
42 + t.Fatalf("plain text must stay untouched, got %q", got)
43 + }
44 +
45 + binaryData := []byte{0x00, 0x01, 0x02, 0x03}
46 + if got := normalizeTextEncoding(binaryData); len(got) != len(binaryData) {
47 + t.Fatalf("binary data must stay untouched")
48 + }
49 +}
50 +
51 +// TestReadBlobDecodesUTF16 mirrors README files written by Windows tooling
52 +// (PowerShell writes UTF-16LE by default) and expects them to render as text.
53 +func TestReadBlobDecodesUTF16(t *testing.T) {
54 + ctx := context.Background()
55 + tmp := t.TempDir()
56 + barePath := filepath.Join(tmp, "repo.git")
57 + if err := InitBare(ctx, barePath, "main"); err != nil {
58 + t.Fatalf("InitBare: %v", err)
59 + }
60 +
61 + work := filepath.Join(tmp, "work")
62 + if err := os.MkdirAll(work, 0o755); err != nil {
63 + t.Fatalf("mkdir: %v", err)
64 + }
65 + run := func(args ...string) {
66 + t.Helper()
67 + cmd := exec.CommandContext(ctx, "git", args...)
68 + cmd.Dir = work
69 + if out, err := cmd.CombinedOutput(); err != nil {
70 + t.Fatalf("git %v: %v (%s)", args, err, string(out))
71 + }
72 + }
73 + run("init", "-b", "main")
74 + run("config", "user.name", "Tester")
75 + run("config", "user.email", "tester@example.com")
76 +
77 + content := append([]byte{0xFF, 0xFE}, utf16Bytes("# Windows README\n", binary.LittleEndian)...)
78 + if err := os.WriteFile(filepath.Join(work, "README.md"), content, 0o644); err != nil {
79 + t.Fatalf("write README: %v", err)
80 + }
81 + run("add", ".")
82 + run("commit", "-m", "utf-16 readme")
83 + run("push", barePath, "main")
84 +
85 + blob, err := ReadBlob(ctx, barePath, "main", "README.md")
86 + if err != nil {
87 + t.Fatalf("ReadBlob: %v", err)
88 + }
89 + if blob.IsBinary {
90 + t.Fatalf("BOM-marked UTF-16 file must not be treated as binary")
91 + }
92 + if string(blob.Data) != "# Windows README\n" {
93 + t.Fatalf("unexpected decoded content %q", string(blob.Data))
94 + }
95 +
96 + readme, err := FindReadme(ctx, barePath, "main", "")
97 + if err != nil || readme == nil {
98 + t.Fatalf("FindReadme should find the UTF-16 README, err=%v", err)
99 + }
100 +}
internal/i18n/i18n.go
+963 -0
... ... @@ -0,0 +1,963 @@
1 +package i18n
2 +
3 +type Lang string
4 +
5 +const (
6 + RU Lang = "ru"
7 + EN Lang = "en"
8 +)
9 +
10 +func Normalize(code string) Lang {
11 + switch code {
12 + case "en", "EN":
13 + return EN
14 + default:
15 + return RU
16 + }
17 +}
18 +
19 +var translations = map[Lang]map[string]string{
20 + RU: {
21 + // Навигация и общее
22 + "app.tagline": "Лёгкий self-hosted Git-хостинг",
23 + "nav.home": "Главная",
24 + "nav.repositories": "Репозитории",
25 + "nav.explore": "Обзор",
26 + "nav.settings": "Настройки",
27 + "nav.admin": "Админка",
28 + "nav.login": "Войти",
29 + "nav.register": "Регистрация",
30 + "nav.logout": "Выйти",
31 + "nav.new_repo": "Создать",
32 + "nav.command_palette": "Поиск и команды",
33 + "nav.search_placeholder": "Поиск...",
34 + "nav.theme": "Тема",
35 + "nav.lang": "Язык",
36 + "common.copy": "Копировать",
37 + "common.copied": "Скопировано",
38 + "common.cancel": "Отмена",
39 + "common.save": "Сохранить",
40 + "common.delete": "Удалить",
41 + "common.close": "Закрыть",
42 + "common.never": "Никогда",
43 + "common.newer": "Новее",
44 + "common.older": "Старее",
45 + "common.up": "Наверх",
46 +
47 + // Темы (только 3)
48 + "theme.light": "Светлая",
49 + "theme.dark": "Тёмная",
50 + "theme.system": "Авто",
51 +
52 + // Лендинг
53 + "landing.badge": "Один бинарник · SQLite · Ноль зависимостей",
54 + "landing.hero.title": "Лёгкий self-hosted Git-хостинг",
55 + "landing.hero.subtitle": "Один бинарник на Go со встроенным SQLite. Поднимается за минуту, потребляет минимум памяти и не перегружен лишним.",
56 + "landing.hero.cta_start": "Начать",
57 + "landing.features.title": "Принципы",
58 + "landing.feat1.title": "Один бинарник",
59 + "landing.feat1.desc": "Шаблоны, стили, шрифты, миграции и иконки вшиты через go:embed.",
60 + "landing.feat2.title": "SQLite (WAL)",
61 + "landing.feat2.desc": "Вся база — один файл data/polar.db без CGO. Бэкап простым копированием.",
62 + "landing.feat3.title": "Быстрый отклик",
63 + "landing.feat3.desc": "Мгновенный старт, серверный рендеринг и < 30 МБ памяти в простое.",
64 + "landing.feat4.title": "Приватность",
65 + "landing.feat4.desc": "Публичные и приватные репозитории, Argon2id и токены доступа для Git HTTP.",
66 + // Авторизация
67 + "auth.login.title": "Вход",
68 + "auth.register.title": "Регистрация",
69 + "auth.register.disabled": "Публичная регистрация отключена администратором.",
70 + "auth.username": "Имя пользователя",
71 + "auth.username_hint": "2–32 символа: латиница, цифры, дефис, подчёркивание",
72 + "auth.username_or_email": "Логин или Email",
73 + "auth.email": "Email",
74 + "auth.password": "Пароль",
75 + "auth.password_hint": "Минимум 8 символов",
76 + "auth.submit_login": "Войти",
77 + "auth.submit_register": "Создать аккаунт",
78 + "auth.no_account": "Нет аккаунта?",
79 + "auth.have_account": "Уже есть аккаунт?",
80 + "auth.invalid_creds": "Неверный логин или пароль",
81 +
82 + // Репозитории
83 + "repo.new.title": "Новый репозиторий",
84 + "repo.name": "Название",
85 + "repo.name_hint": "Латинские буквы, цифры, точка, дефис, подчёркивание",
86 + "repo.description": "Описание",
87 + "repo.description_ph": "Краткое описание проекта (необязательно)",
88 + "repo.visibility": "Доступ",
89 + "repo.public": "Публичный",
90 + "repo.public_desc": "Виден всем, клонирование без авторизации",
91 + "repo.private": "Приватный",
92 + "repo.private_desc": "Доступен только владельцу",
93 + "repo.create_btn": "Создать репозиторий",
94 + "repo.filter_ph": "Фильтр репозиториев...",
95 + "repo.empty_list": "Репозиториев пока нет",
96 + "repo.empty_list_sub": "Создайте первый репозиторий, чтобы отправить код.",
97 + "repo.empty.title": "Репозиторий пуст",
98 + "repo.empty.new_cmd": "Создать новый репозиторий в командной строке",
99 + "repo.empty.push_cmd": "Отправить существующий репозиторий",
100 + "repo.tab.code": "Код",
101 + "repo.tab.commits": "Коммиты",
102 + "repo.tab.branches": "Ветки и теги",
103 + "repo.tab.issues": "Задачи",
104 + "repo.tab.pulls": "Пул-реквесты",
105 + "repo.tab.releases": "Релизы",
106 + "repo.tab.activity": "Активность",
107 + "repo.tab.settings": "Настройки",
108 + "repo.features": "Возможности репозитория",
109 + "repo.clone": "Клонировать",
110 + "repo.download_zip": "Скачать ZIP",
111 + "repo.branches": "Ветки",
112 + "repo.tags": "Теги",
113 + "repo.default_branch": "Ветка по умолчанию",
114 + "repo.find_file": "Поиск файла...",
115 + "repo.files": "Файлы",
116 + "repo.readme": "README",
117 + "repo.raw": "Raw",
118 + "repo.history": "История",
119 + "repo.lines": "строк",
120 + "repo.lines_one": "строка",
121 + "repo.lines_few": "строки",
122 + "repo.lines_many": "строк",
123 + "repo.binary_notice": "Бинарный файл — предпросмотр недоступен",
124 + "repo.download_raw": "Скачать файл",
125 + "repo.browse_at_commit": "Файлы на момент коммита",
126 + "repo.files_changed": "файлов изменено",
127 + "repo.files_changed_one": "файл изменён",
128 + "repo.files_changed_few": "файла изменено",
129 + "repo.files_changed_many": "файлов изменено",
130 + "repo.no_commits": "Коммитов пока нет",
131 + "repo.danger_zone": "Опасная зона",
132 + "repo.delete": "Удалить репозиторий",
133 + "repo.delete_desc": "Удаление необратимо: все файлы и история git будут стёрты.",
134 + "repo.delete_confirm": "Введите название репозитория для подтверждения",
135 + "repo.rename": "Переименовать",
136 + "repo.save": "Сохранить",
137 + "repo.branch.new": "Новая ветка",
138 + "repo.branch.name_ph": "Имя ветки (напр. feat/ui)",
139 + "repo.branch.from": "От ветки / коммита",
140 + "repo.branch.create": "Создать ветку",
141 + "repo.branch.default": "по умолчанию",
142 + "repo.tag.new": "Новый тег / релиз",
143 + "repo.tag.name_ph": "Тег (напр. v1.0.0)",
144 + "repo.tag.msg_ph": "Описание тега (необязательно)",
145 + "repo.tag.create": "Создать тег",
146 + "repo.tags.empty": "Тегов пока нет",
147 +
148 + // Pull Requests
149 + "pulls.title": "Пул-реквесты",
150 + "pulls.new": "Новый пул-реквест",
151 + "pulls.empty": "Пул-реквестов пока нет",
152 + "pulls.open": "Открыт",
153 + "pulls.closed": "Закрыт",
154 + "pulls.merged": "Слит",
155 + "pulls.compare": "Сравнение веток",
156 + "pulls.base": "Базовая ветка",
157 + "pulls.head": "Ветка изменений",
158 + "pulls.title_ph": "Заголовок пул-реквеста...",
159 + "pulls.desc_ph": "Опишите предлагаемые изменения (Markdown)...",
160 + "pulls.create_btn": "Создать пул-реквест",
161 + "pulls.merge_btn": "Слить пул-реквест",
162 + "pulls.can_merge": "Ветки можно объединить автоматически.",
163 + "pulls.cannot_merge": "Невозможно объединить автоматически: обнаружены конфликты.",
164 + "pulls.already_merged": "Ветка изменений уже объединена с базовой.",
165 + "pulls.close_btn": "Закрыть PR",
166 + "pulls.reopen_btn": "Открыть заново",
167 + "pulls.comment_btn": "Прокомментировать",
168 + "pulls.conversation": "Обсуждение",
169 + "pulls.files_changed": "Изменения",
170 + "pulls.commits": "Коммиты",
171 + "pulls.able_to_merge": "Можно слить автоматически",
172 + "pulls.body": "Описание",
173 + "pulls.body_ph": "Опишите предлагаемые изменения (Markdown)...",
174 + "pulls.branches_identical": "Ветки идентичны — сравнивать нечего",
175 + "pulls.cannot_merge_warning": "Обнаружены конфликты — автоматическое слияние невозможно",
176 + "pulls.closed_desc": "Ветка изменений не была объединена с базовой.",
177 + "pulls.closed_message": "Пул-реквест закрыт",
178 + "pulls.comment_ph": "Напишите комментарий (поддерживается Markdown)...",
179 + "pulls.compare_branches": "Сравнение веток",
180 + "pulls.conflicts_desc": "Разрешите конфликты локально и запушьте изменения в ветку.",
181 + "pulls.conflicts_title": "Есть конфликты слияния",
182 + "pulls.into": "в",
183 + "pulls.merge_clean": "Конфликтов нет — изменения можно объединить.",
184 + "pulls.merged_message": "Пул-реквест слит",
185 + "pulls.no_changes": "Файлы не изменены",
186 + "pulls.no_commits": "В этом пул-реквесте пока нет коммитов",
187 + "pulls.only_write_merge": "Сливать пул-реквесты могут только пользователи с правом записи.",
188 + "pulls.status.closed": "Закрыт",
189 + "pulls.status.merged": "Слит",
190 + "pulls.status.open": "Открыт",
191 + "pulls.wants_to_merge": "хочет слить",
192 +
193 + // Releases
194 + "releases.title": "Релизы",
195 + "releases.new": "Новый релиз",
196 + "releases.empty": "Релизов пока нет",
197 + "releases.tag_select": "Тег",
198 + "releases.title_ph": "Название релиза",
199 + "releases.desc_ph": "Описание релиза, список изменений (Markdown)...",
200 + "releases.prerelease": "Пре-релиз (нестабильная сборка)",
201 + "releases.publish": "Опубликовать релиз",
202 + "releases.delete": "Удалить релиз",
203 + "releases.assets": "Файлы",
204 + "releases.source_zip": "Исходный код (zip)",
205 + "releases.source_tar": "Исходный код (tar.gz)",
206 + "releases.body": "Описание",
207 + "releases.body_ph": "Опишите релиз, список изменений (Markdown)...",
208 + "releases.count": "релизов",
209 + "releases.count_one": "релиз",
210 + "releases.count_few": "релиза",
211 + "releases.count_many": "релизов",
212 + "releases.delete_confirm": "Удалить этот релиз? Действие необратимо.",
213 + "releases.is_prerelease": "Пре-релиз (нестабильная сборка)",
214 + "releases.publish_btn": "Опубликовать релиз",
215 + "releases.tag_desc": "Существующий тег или новый — он будет создан автоматически.",
216 + "releases.tag_name": "Тег",
217 + "releases.tags": "тегов",
218 + "releases.tags_one": "тег",
219 + "releases.tags_few": "тега",
220 + "releases.tags_many": "тегов",
221 + "releases.target": "Ветка",
222 + "releases.target_desc": "Ветка или коммит, на который будет указывать тег.",
223 +
224 + // Activity
225 + "activity.title": "Активность",
226 + "activity.pulse": "Сводка активности",
227 + "activity.empty": "Активности пока нет",
228 + "activity.commits": "коммитов",
229 + "activity.open_prs": "открытых PR",
230 + "activity.merged_prs": "слитых PR",
231 + "activity.open_issues": "открытых задач",
232 + "activity.releases": "релизов",
233 + "activity.issues": "открыто и закрыто",
234 + "activity.on_default_branch": "в ветке по умолчанию",
235 + "activity.published_releases": "опубликованные релизы",
236 + "activity.pull_requests": "открыто и слито",
237 + "activity.recent_feed": "Лента активности",
238 + "activity.badge_commit": "коммит",
239 + "activity.badge_release": "релиз",
240 +
241 + // Задачи (Issues)
242 + "issue.new": "Новая задача",
243 + "issue.title": "Заголовок",
244 + "issue.title_ph": "Кратко опишите задачу или проблему",
245 + "issue.body": "Описание",
246 + "issue.body_ph": "Подробное описание (поддерживается Markdown)...",
247 + "issue.create": "Создать задачу",
248 + "issue.open": "Открытые",
249 + "issue.closed": "Закрытые",
250 + "issue.status.open": "Открыта",
251 + "issue.status.closed": "Закрыта",
252 + "issue.close_btn": "Закрыть задачу",
253 + "issue.reopen_btn": "Переоткрыть",
254 + "issue.comment_ph": "Напишите комментарий (поддерживается Markdown)...",
255 + "issue.comment_btn": "Отправить",
256 + "issue.empty": "Задач в этом списке нет",
257 +
258 + // Настройки пользователя, профиль и токены
259 + "settings.title": "Настройки",
260 + "settings.profile": "Профиль",
261 + "settings.avatar": "Аватар",
262 + "settings.avatar_upload": "Выбрать изображение",
263 + "settings.avatar_remove": "Удалить аватар",
264 + "settings.avatar_hint": "PNG, JPG, WebP или GIF — автоматически кадрируется до 256×256",
265 + "settings.full_name": "Отображаемое имя",
266 + "settings.full_name_ph": "Ваше имя или ник",
267 + "settings.bio": "О себе",
268 + "settings.bio_ph": "Короткая подпись в профиле",
269 + "settings.location": "Локация",
270 + "settings.location_ph": "Город, страна",
271 + "settings.website": "Сайт",
272 + "settings.website_ph": "https://example.com",
273 + "profile.edit": "Редактировать профиль",
274 + "profile.joined": "Регистрация",
275 + "profile.public_repos": "Публичные",
276 + "profile.private_repos": "Приватные",
277 + "profile.total_repos": "Репозитории",
278 + "settings.tokens": "Токены доступа",
279 + "settings.tokens.desc": "Используйте токен вместо пароля для git clone и git push по HTTP.",
280 + "settings.token.name": "Название токена (например, Ноутбук)",
281 + "settings.token.create": "Создать",
282 + "settings.token.revoke": "Отозвать",
283 + "settings.token.created_once": "Скопируйте токен сейчас — он показывается только один раз:",
284 + "settings.token.col_name": "Название",
285 + "settings.token.col_prefix": "Префикс",
286 + "settings.token.col_created": "Создан",
287 + "settings.token.col_used": "Использован",
288 + "settings.token.empty": "Токенов доступа пока нет",
289 + "settings.password.current": "Текущий пароль",
290 + "settings.password.new": "Новый пароль",
291 + "settings.password.update": "Обновить пароль",
292 +
293 + // Админка
294 + "admin.title": "Администрирование",
295 + "admin.features": "Модули и доступ",
296 + "admin.users": "Пользователи",
297 + "admin.registration": "Публичная регистрация",
298 + "admin.registration.desc": "Разрешить самостоятельную регистрацию новых аккаунтов",
299 + "admin.explore": "Раздел «Обзор» (Explore)",
300 + "admin.explore.desc": "Показывать каталог публичных репозиториев в сайдбаре и по адресу /explore",
301 + "admin.landing": "Приветственная страница",
302 + "admin.landing.desc": "Показывать лендинг на главной странице (если отключено — переход сразу к репозиториям или входу)",
303 + "admin.anon_read": "Гостевой просмотр",
304 + "admin.anon_read.desc": "Разрешить неавторизованным пользователям просматривать публичные репозитории и профили",
305 + "admin.issues": "Трекер задач (Issues)",
306 + "admin.issues.desc": "Включить вкладку «Задачи» в репозиториях",
307 + "admin.enabled": "Включено",
308 + "admin.disabled": "Отключено",
309 + "admin.enable_btn": "Включить",
310 + "admin.disable_btn": "Отключить",
311 + "admin.col.user": "Пользователь",
312 + "admin.col.repos": "Репозитории",
313 + "admin.col.role": "Роль",
314 + "admin.col.joined": "Дата",
315 + "admin.role.admin": "Админ",
316 + "admin.role.user": "Участник",
317 +
318 + // Командная палитра и хоткеи
319 + "palette.placeholder": "Поиск репозитория, файла или команды...",
320 + "palette.group.nav": "Навигация",
321 + "palette.group.themes": "Тема оформления",
322 + "palette.group.repos": "Репозитории",
323 + "shortcuts.title": "Горячие клавиши",
324 + "shortcuts.palette": "Командная палитра",
325 + "shortcuts.search": "Фокус на поиск",
326 + "shortcuts.home": "На главную",
327 + "shortcuts.repos": "Мои репозитории",
328 + "shortcuts.files": "Фильтр файлов",
329 + "shortcuts.sidebar": "Скрыть / показать сайдбар",
330 + "nav.toggle_sidebar": "Сайдбар (Ctrl+B)",
331 +
332 + // Флеш-сообщения
333 + "flash.welcome": "Добро пожаловать в Polar, @%s!",
334 + "flash.profile_updated": "Профиль обновлён.",
335 + "flash.password_updated": "Пароль успешно изменён.",
336 + "flash.token_revoked": "Токен доступа отозван.",
337 + "flash.settings_updated": "Настройки сохранены.",
338 + "flash.error": "Ошибка: %s",
339 + "flash.comment_error": "Не удалось отправить комментарий: %s",
340 + "flash.pr_created": "Пул-реквест #%d создан.",
341 + "flash.pr_closed": "Пул-реквест #%d закрыт.",
342 + "flash.pr_reopened": "Пул-реквест #%d открыт заново.",
343 + "flash.pr_merged": "Пул-реквест #%d слит.",
344 + "flash.pr_merged_state": "Нельзя изменить состояние слитого пул-реквеста.",
345 + "flash.pr_merge_open_only": "Слить можно только открытый пул-реквест.",
346 + "flash.pr_merge_failed": "Не удалось слить: %s",
347 + "flash.release_tag_empty": "Имя тега не может быть пустым.",
348 + "flash.release_tag_failed": "Не удалось создать тег: %s",
349 + "flash.release_create_failed": "Не удалось создать релиз: %s",
350 + "flash.release_published": "Релиз %s опубликован.",
351 + "flash.release_delete_failed": "Не удалось удалить релиз: %s",
352 + "flash.release_deleted": "Релиз удалён.",
353 + "flash.repo_settings_saved": "Настройки репозитория сохранены.",
354 + "flash.repo_rename_failed": "Не удалось переименовать: %s",
355 + "flash.repo_renamed": "Репозиторий переименован в %s.",
356 + "flash.repo_confirm_mismatch": "Введённое имя не совпадает с названием репозитория.",
357 + "flash.repo_deleted": "Репозиторий %s удалён.",
358 + "flash.branch_created": "Ветка %s создана.",
359 + "flash.branch_create_failed": "Не удалось создать ветку: %s",
360 + "flash.branch_default_delete": "Нельзя удалить ветку по умолчанию.",
361 + "flash.branch_deleted": "Ветка %s удалена.",
362 + "flash.branch_delete_failed": "Не удалось удалить ветку: %s",
363 + "flash.tag_created": "Тег %s создан.",
364 + "flash.tag_create_failed": "Не удалось создать тег: %s",
365 + "flash.tag_deleted": "Тег %s удалён.",
366 + "flash.tag_delete_failed": "Не удалось удалить тег: %s",
367 + "flash.issue_closed": "Задача #%d закрыта.",
368 + "flash.issue_reopened": "Задача #%d открыта заново.",
369 +
370 + // Ошибочные страницы
371 + "err.not_found": "Не найдено",
372 + "err.forbidden": "Доступ запрещён",
373 + "err.session_error": "Ошибка сессии",
374 + "err.session_create": "Не удалось создать сессию.",
375 + "err.settings_error": "Ошибка настроек",
376 + "err.settings_save": "Не удалось сохранить настройку.",
377 + "err.explore_disabled": "Раздел «Обзор» отключён на этом сервере.",
378 + "err.activity_disabled": "Активность отключена для этого репозитория.",
379 + "err.pulls_disabled": "Пул-реквесты отключены для этого репозитория.",
380 + "err.releases_disabled": "Релизы отключены для этого репозитория.",
381 + "err.issues_disabled": "Задачи отключены для этого репозитория.",
382 + "err.repo_not_found": "Репозиторий не найден",
383 + "err.repo_not_found_desc": "Репозиторий не существует или у вас нет к нему доступа.",
384 + "err.repo_private_desc": "Репозиторий не существует или является приватным.",
385 + "err.access_denied": "Доступ запрещён",
386 + "err.repo_write_denied": "У вас нет прав на изменение этого репозитория.",
387 + "err.invalid_repo_path": "Некорректный путь репозитория",
388 + "err.path_not_found": "Путь не найден",
389 + "err.path_not_found_desc": "Не удалось найти путь в репозитории для ссылки %s",
390 + "err.file_not_found": "Файл не найден",
391 + "err.file_not_found_desc": "Запрошенный файл не найден в %s",
392 + "err.commit_not_found": "Коммит не найден",
393 + "err.commit_not_found_desc": "Не удалось найти коммит %s",
394 + "err.pr_not_found": "Пул-реквест не найден",
395 + "err.pr_not_found_desc": "Не удалось найти пул-реквест.",
396 + "err.pr_state_forbidden": "Вы не можете изменить состояние этого пул-реквеста.",
397 + "err.pr_merge_forbidden": "У вас нет прав на слияние пул-реквестов.",
398 + "err.issue_not_found": "Задача не найдена",
399 + "err.issue_not_found_desc": "Не удалось найти задачу.",
400 + "err.issue_state_forbidden": "Вы не можете изменить состояние этой задачи.",
401 + "err.delete_error": "Ошибка удаления",
402 + "err.repo_delete_failed": "Не удалось удалить репозиторий.",
403 + "err.user_not_found": "Пользователь @%s не существует.",
404 + "err.archive_error": "Ошибка архива",
405 + "err.archive_tar_failed": "Не удалось создать tar.gz архив для %s",
406 + "err.archive_zip_failed": "Не удалось создать zip архив для %s",
407 +
408 + // Форки
409 + "fork.forked_from": "форк от",
410 + "fork.forks": "Форки",
411 + "fork.forks_btn": "Форки",
412 + "fork.fork_btn": "Форк",
413 + "fork.fork_btn_desc": "Создать форк этого репозитория",
414 + "fork.your_fork": "Ваш форк",
415 + "fork.your_fork_desc": "Перейти к вашему форку",
416 + "forks.title": "Форки",
417 + "forks.empty": "Пока никто не форкал этот репозиторий.",
418 +
419 + // Коллабораторы
420 + "collab.title": "Коллабораторы",
421 + "collab.desc": "Люди с доступом к репозиторию",
422 + "collab.owner": "Владелец",
423 + "collab.role": "Роль",
424 + "collab.role_read": "Чтение",
425 + "collab.role_write": "Запись",
426 + "collab.username": "Пользователь",
427 + "collab.username_ph": "username",
428 + "collab.add": "Добавить",
429 + "collab.remove": "Убрать доступ",
430 + "collab.empty": "Коллабораторов пока нет.",
431 +
432 + // Ревью
433 + "reviews.title": "Ревью",
434 + "reviews.approved": "Одобрено",
435 + "reviews.changes_requested": "Запрошены изменения",
436 + "reviews.body_ph": "Комментарий к ревью (необязательно)",
437 + "reviews.approve": "Одобрить",
438 + "reviews.request_changes": "Запросить изменения",
439 +
440 + // Device flow (RFC 8628)
441 + "device.title": "Подключение устройства",
442 + "device.badge": "Git",
443 + "device.desc": "Введите код, который показал git или скрипт-помощник, чтобы выдать устройству доступ к вашему аккаунту.",
444 + "device.check": "Проверить",
445 + "device.authorize_for": "Выдать доступ по коду",
446 + "device.scope_hint": "Доступ даётся на чтение и запись репозиториев, к которым у вас есть права, и действует только для этого устройства.",
447 + "device.approve": "Разрешить",
448 + "device.deny": "Отклонить",
449 + "device.signin_to_approve": "Войдите, чтобы подтвердить доступ",
450 + "device.helper_hint": "Подсказка: настройте credential helper, чтобы код запрашивался автоматически — см. README.",
451 + "device.code_unknown": "Код не найден. Проверьте, что он введён верно.",
452 + "device.code_used": "Этот код уже использован или истёк.",
453 + "device.approved_now": "Готово! Устройство подключено — вернитесь в терминал.",
454 + "device.denied_now": "Запрос отклонён. Устройство не получит доступ.",
455 +
456 + // Flash: форки, коллабораторы, ревью, device flow
457 + "flash.fork_self": "Это ваш репозиторий — форк не нужен.",
458 + "flash.fork_exists": "У вас уже есть форк этого репозитория.",
459 + "flash.fork_created": "Форк создан.",
460 + "flash.fork_failed": "Не удалось создать форк: %s",
461 + "flash.collab_added": "@%s добавлен в коллабораторы.",
462 + "flash.collab_removed": "Доступ коллаборатора отозван.",
463 + "flash.collab_failed": "Не удалось изменить коллаборатора: %s",
464 + "flash.review_self": "Нельзя ревьюить собственный пул-реквест.",
465 + "flash.review_closed": "Ревью доступно только для открытых пул-реквестов.",
466 + "flash.review_failed": "Не удалось сохранить ревью: %s",
467 + "flash.review_approved": "Ревью сохранено: одобрено.",
468 + "flash.review_changes_requested": "Ревью сохранено: запрошены изменения.",
469 + "flash.device_code_required": "Введите код устройства.",
470 + "flash.device_code_invalid": "Код не найден, истёк или уже использован.",
471 + "flash.device_approved": "Устройство авторизовано — можно возвращаться к git.",
472 + "flash.device_denied": "Запрос отклонён.",
473 +
474 + // Прочее
475 + "issues.new": "Новая задача",
476 + "pulls.head_repo": "Из репозитория",
477 + "repo.about": "О репозитории",
478 + "repo.clone_title": "Клонирование",
479 + "repo.created": "Создан",
480 + "repo.updated": "Обновлён",
481 + "repo.view_file": "Открыть",
482 + "err.collab_forbidden": "Управлять коллабораторами может только владелец или администратор.",
483 + "err.pr_head_repo_invalid": "Выбран недопустимый репозиторий-источник."},
484 + EN: {
485 + // Navigation & Common
486 + "app.tagline": "Lightweight self-hosted Git hosting",
487 + "nav.home": "Home",
488 + "nav.repositories": "Repositories",
489 + "nav.explore": "Explore",
490 + "nav.settings": "Settings",
491 + "nav.admin": "Admin",
492 + "nav.login": "Sign in",
493 + "nav.register": "Sign up",
494 + "nav.logout": "Sign out",
495 + "nav.new_repo": "New",
496 + "nav.command_palette": "Search & commands",
497 + "nav.search_placeholder": "Search...",
498 + "nav.theme": "Theme",
499 + "nav.lang": "Language",
500 + "common.copy": "Copy",
501 + "common.copied": "Copied",
502 + "common.cancel": "Cancel",
503 + "common.save": "Save",
504 + "common.delete": "Delete",
505 + "common.close": "Close",
506 + "common.never": "Never",
507 + "common.newer": "Newer",
508 + "common.older": "Older",
509 + "common.up": "Up",
510 +
511 + // Themes (only 3)
512 + "theme.light": "Light",
513 + "theme.dark": "Dark",
514 + "theme.system": "System",
515 +
516 + // Landing
517 + "landing.badge": "Single Binary · SQLite · Zero Dependencies",
518 + "landing.hero.title": "Lightweight self-hosted Git hosting",
519 + "landing.hero.subtitle": "A single Go binary backed by SQLite. Deploys in a minute, uses minimal memory, and stays out of your way.",
520 + "landing.hero.cta_start": "Get Started",
521 + "landing.features.title": "Principles",
522 + "landing.feat1.title": "Single Binary",
523 + "landing.feat1.desc": "Templates, styles, fonts, migrations, and icons are compiled in via go:embed.",
524 + "landing.feat2.title": "SQLite (WAL)",
525 + "landing.feat2.desc": "Your entire database is a single data/polar.db file without CGO. Back up by copying.",
526 + "landing.feat3.title": "Fast & Quiet",
527 + "landing.feat3.desc": "Instant cold start, server-side rendering, and < 30 MB idle memory.",
528 + "landing.feat4.title": "Private by Default",
529 + "landing.feat4.desc": "Public and private repositories, Argon2id passwords, and Git HTTP access tokens.",
530 +
531 + // Auth
532 + "auth.login.title": "Sign in",
533 + "auth.register.title": "Create account",
534 + "auth.register.disabled": "Public registration is disabled by the administrator.",
535 + "auth.username": "Username",
536 + "auth.username_hint": "2–32 characters: letters, digits, hyphen, underscore",
537 + "auth.username_or_email": "Username or Email",
538 + "auth.email": "Email",
539 + "auth.password": "Password",
540 + "auth.password_hint": "At least 8 characters",
541 + "auth.submit_login": "Sign in",
542 + "auth.submit_register": "Create account",
543 + "auth.no_account": "No account?",
544 + "auth.have_account": "Have an account?",
545 + "auth.invalid_creds": "Invalid username/email or password",
546 +
547 + // Repositories
548 + "repo.new.title": "New repository",
549 + "repo.name": "Name",
550 + "repo.name_hint": "Letters, digits, '.', '-', '_'",
551 + "repo.description": "Description",
552 + "repo.description_ph": "Short description (optional)",
553 + "repo.visibility": "Visibility",
554 + "repo.public": "Public",
555 + "repo.public_desc": "Anyone can view and clone this repository",
556 + "repo.private": "Private",
557 + "repo.private_desc": "Accessible only to you",
558 + "repo.create_btn": "Create repository",
559 + "repo.filter_ph": "Filter repositories...",
560 + "repo.empty_list": "No repositories yet",
561 + "repo.empty_list_sub": "Create your first repository to push code.",
562 + "repo.empty.title": "Repository is empty",
563 + "repo.empty.new_cmd": "Create a new repository on the command line",
564 + "repo.empty.push_cmd": "Push an existing repository",
565 + "repo.tab.code": "Code",
566 + "repo.tab.commits": "Commits",
567 + "repo.tab.branches": "Branches & Tags",
568 + "repo.tab.issues": "Issues",
569 + "repo.tab.pulls": "Pull requests",
570 + "repo.tab.releases": "Releases",
571 + "repo.tab.activity": "Activity",
572 + "repo.tab.settings": "Settings",
573 + "repo.features": "Repository Features",
574 + "repo.clone": "Clone",
575 + "repo.download_zip": "Download ZIP",
576 + "repo.branches": "Branches",
577 + "repo.tags": "Tags",
578 + "repo.default_branch": "Default branch",
579 + "repo.find_file": "Filter files...",
580 + "repo.files": "Files",
581 + "repo.readme": "README",
582 + "repo.raw": "Raw",
583 + "repo.history": "History",
584 + "repo.lines": "lines",
585 + "repo.lines_one": "line",
586 + "repo.lines_few": "lines",
587 + "repo.lines_many": "lines",
588 + "repo.binary_notice": "Binary file — inline preview unavailable",
589 + "repo.download_raw": "Download file",
590 + "repo.browse_at_commit": "Browse files",
591 + "repo.files_changed": "files changed",
592 + "repo.files_changed_one": "file changed",
593 + "repo.files_changed_few": "files changed",
594 + "repo.files_changed_many": "files changed",
595 + "repo.no_commits": "No commits yet",
596 + "repo.danger_zone": "Danger Zone",
597 + "repo.delete": "Delete repository",
598 + "repo.delete_desc": "Once deleted, the repository and its git history cannot be recovered.",
599 + "repo.delete_confirm": "Type the repository name to confirm",
600 + "repo.rename": "Rename",
601 + "repo.save": "Save",
602 + "repo.branch.new": "New branch",
603 + "repo.branch.name_ph": "Branch name (e.g. feat/ui)",
604 + "repo.branch.from": "From branch / ref",
605 + "repo.branch.create": "Create branch",
606 + "repo.branch.default": "default",
607 + "repo.tag.new": "New tag / release",
608 + "repo.tag.name_ph": "Tag (e.g. v1.0.0)",
609 + "repo.tag.msg_ph": "Tag note (optional)",
610 + "repo.tag.create": "Create tag",
611 + "repo.tags.empty": "No tags yet",
612 +
613 + // Pull Requests
614 + "pulls.title": "Pull requests",
615 + "pulls.new": "New pull request",
616 + "pulls.empty": "No pull requests yet",
617 + "pulls.open": "Open",
618 + "pulls.closed": "Closed",
619 + "pulls.merged": "Merged",
620 + "pulls.compare": "Comparing changes",
621 + "pulls.base": "base",
622 + "pulls.head": "compare",
623 + "pulls.title_ph": "Pull request title...",
624 + "pulls.desc_ph": "Describe your changes (Markdown supported)...",
625 + "pulls.create_btn": "Create pull request",
626 + "pulls.merge_btn": "Merge pull request",
627 + "pulls.can_merge": "Able to merge automatically.",
628 + "pulls.cannot_merge": "Can't automatically merge: conflicts detected.",
629 + "pulls.already_merged": "The head branch is already merged into base.",
630 + "pulls.close_btn": "Close PR",
631 + "pulls.reopen_btn": "Reopen PR",
632 + "pulls.comment_btn": "Comment",
633 + "pulls.conversation": "Conversation",
634 + "pulls.files_changed": "Files changed",
635 + "pulls.commits": "Commits",
636 + "pulls.able_to_merge": "Able to merge automatically",
637 + "pulls.body": "Description",
638 + "pulls.body_ph": "Describe your changes (Markdown supported)...",
639 + "pulls.branches_identical": "Branches are identical — nothing to compare",
640 + "pulls.cannot_merge_warning": "Conflicts detected — automatic merge is not possible",
641 + "pulls.closed_desc": "The head branch was not merged into base.",
642 + "pulls.closed_message": "Pull request closed",
643 + "pulls.comment_ph": "Write a comment (Markdown supported)...",
644 + "pulls.compare_branches": "Comparing branches",
645 + "pulls.conflicts_desc": "Resolve the conflicts locally and push to the head branch.",
646 + "pulls.conflicts_title": "Merge conflicts detected",
647 + "pulls.into": "into",
648 + "pulls.merge_clean": "No conflicts — changes can be merged.",
649 + "pulls.merged_message": "Pull request merged",
650 + "pulls.no_changes": "No files changed",
651 + "pulls.no_commits": "No commits in this pull request yet",
652 + "pulls.only_write_merge": "Only users with write access can merge pull requests.",
653 + "pulls.status.closed": "Closed",
654 + "pulls.status.merged": "Merged",
655 + "pulls.status.open": "Open",
656 + "pulls.wants_to_merge": "wants to merge",
657 +
658 + // Releases
659 + "releases.title": "Releases",
660 + "releases.new": "Draft a new release",
661 + "releases.empty": "No releases yet",
662 + "releases.tag_select": "Tag",
663 + "releases.title_ph": "Release title",
664 + "releases.desc_ph": "Describe this release, release notes (Markdown)...",
665 + "releases.prerelease": "This is a pre-release",
666 + "releases.publish": "Publish release",
667 + "releases.delete": "Delete release",
668 + "releases.assets": "Assets",
669 + "releases.source_zip": "Source code (zip)",
670 + "releases.source_tar": "Source code (tar.gz)",
671 + "releases.body": "Description",
672 + "releases.body_ph": "Describe this release, release notes (Markdown)...",
673 + "releases.count": "releases",
674 + "releases.count_one": "release",
675 + "releases.count_few": "releases",
676 + "releases.count_many": "releases",
677 + "releases.delete_confirm": "Delete this release? This cannot be undone.",
678 + "releases.is_prerelease": "This is a pre-release",
679 + "releases.publish_btn": "Publish release",
680 + "releases.tag_desc": "An existing tag or a new one — it will be created automatically.",
681 + "releases.tag_name": "Tag",
682 + "releases.tags": "tags",
683 + "releases.tags_one": "tag",
684 + "releases.tags_few": "tags",
685 + "releases.tags_many": "tags",
686 + "releases.target": "Branch",
687 + "releases.target_desc": "Branch or commit the tag will point to.",
688 +
689 + // Activity
690 + "activity.title": "Activity",
691 + "activity.pulse": "Activity overview",
692 + "activity.empty": "No recent activity recorded.",
693 + "activity.commits": "commits",
694 + "activity.open_prs": "open PRs",
695 + "activity.merged_prs": "merged PRs",
696 + "activity.open_issues": "open issues",
697 + "activity.releases": "releases",
698 + "activity.issues": "opened and closed",
699 + "activity.on_default_branch": "on the default branch",
700 + "activity.published_releases": "published releases",
701 + "activity.pull_requests": "opened and merged",
702 + "activity.recent_feed": "Activity feed",
703 + "activity.badge_commit": "commit",
704 + "activity.badge_release": "release",
705 +
706 + // Issues
707 + "issue.new": "New issue",
708 + "issue.title": "Title",
709 + "issue.title_ph": "Brief summary of the issue",
710 + "issue.body": "Description",
711 + "issue.body_ph": "Detailed description (Markdown supported)...",
712 + "issue.create": "Create issue",
713 + "issue.open": "Open",
714 + "issue.closed": "Closed",
715 + "issue.status.open": "Open",
716 + "issue.status.closed": "Closed",
717 + "issue.close_btn": "Close issue",
718 + "issue.reopen_btn": "Reopen",
719 + "issue.comment_ph": "Write a comment (Markdown supported)...",
720 + "issue.comment_btn": "Comment",
721 + "issue.empty": "No issues in this list",
722 +
723 + // Settings, Profile & Tokens
724 + "settings.title": "Settings",
725 + "settings.profile": "Profile",
726 + "settings.avatar": "Avatar",
727 + "settings.avatar_upload": "Choose image",
728 + "settings.avatar_remove": "Remove avatar",
729 + "settings.avatar_hint": "PNG, JPG, WebP, or GIF — automatically cropped to 256×256",
730 + "settings.full_name": "Display name",
731 + "settings.full_name_ph": "Your name",
732 + "settings.bio": "Bio",
733 + "settings.bio_ph": "Short profile bio",
734 + "settings.location": "Location",
735 + "settings.location_ph": "City, Country",
736 + "settings.website": "Website",
737 + "settings.website_ph": "https://example.com",
738 + "profile.edit": "Edit profile",
739 + "profile.joined": "Joined",
740 + "profile.public_repos": "Public",
741 + "profile.private_repos": "Private",
742 + "profile.total_repos": "Repositories",
743 + "settings.tokens": "Access Tokens",
744 + "settings.tokens.desc": "Use a token instead of your password for git clone and git push over HTTP.",
745 + "settings.token.name": "Token name (e.g., Laptop)",
746 + "settings.token.create": "Create",
747 + "settings.token.revoke": "Revoke",
748 + "settings.token.created_once": "Copy this token now — it will only be shown once:",
749 + "settings.token.col_name": "Name",
750 + "settings.token.col_prefix": "Prefix",
751 + "settings.token.col_created": "Created",
752 + "settings.token.col_used": "Last used",
753 + "settings.token.empty": "No access tokens yet",
754 + "settings.password.current": "Current password",
755 + "settings.password.new": "New password",
756 + "settings.password.update": "Update password",
757 +
758 + // Admin
759 + "admin.title": "Administration",
760 + "admin.features": "Features & Access",
761 + "admin.users": "Users",
762 + "admin.registration": "Public registration",
763 + "admin.registration.desc": "Allow new users to create accounts",
764 + "admin.explore": "Explore page",
765 + "admin.explore.desc": "Show public repository directory in sidebar and at /explore",
766 + "admin.landing": "Landing page",
767 + "admin.landing.desc": "Show landing page on root URL (if disabled, redirects to repos or sign in)",
768 + "admin.anon_read": "Anonymous read access",
769 + "admin.anon_read.desc": "Allow unauthenticated guests to browse public repositories and profiles",
770 + "admin.issues": "Issue tracker",
771 + "admin.issues.desc": "Enable the Issues tab inside repositories",
772 + "admin.enabled": "Enabled",
773 + "admin.disabled": "Disabled",
774 + "admin.enable_btn": "Enable",
775 + "admin.disable_btn": "Disable",
776 + "admin.col.user": "User",
777 + "admin.col.repos": "Repos",
778 + "admin.col.role": "Role",
779 + "admin.col.joined": "Joined",
780 + "admin.role.admin": "Admin",
781 + "admin.role.user": "User",
782 +
783 + // Command Palette & Shortcuts
784 + "palette.placeholder": "Search repositories, files, or commands...",
785 + "palette.group.nav": "Navigation",
786 + "palette.group.themes": "Theme",
787 + "palette.group.repos": "Repositories",
788 + "shortcuts.title": "Keyboard shortcuts",
789 + "shortcuts.palette": "Command palette",
790 + "shortcuts.search": "Focus search",
791 + "shortcuts.home": "Go home",
792 + "shortcuts.repos": "Repositories",
793 + "shortcuts.files": "Filter files",
794 + "shortcuts.sidebar": "Toggle sidebar",
795 + "nav.toggle_sidebar": "Sidebar (Ctrl+B)",
796 +
797 + // Flash messages
798 + "flash.welcome": "Welcome to Polar, @%s!",
799 + "flash.profile_updated": "Profile updated.",
800 + "flash.password_updated": "Password changed successfully.",
801 + "flash.token_revoked": "Access token revoked.",
802 + "flash.settings_updated": "Settings saved.",
803 + "flash.error": "Error: %s",
804 + "flash.comment_error": "Could not post the comment: %s",
805 + "flash.pr_created": "Pull request #%d created.",
806 + "flash.pr_closed": "Pull request #%d closed.",
807 + "flash.pr_reopened": "Pull request #%d reopened.",
808 + "flash.pr_merged": "Pull request #%d merged.",
809 + "flash.pr_merged_state": "Cannot change the state of a merged pull request.",
810 + "flash.pr_merge_open_only": "Only an open pull request can be merged.",
811 + "flash.pr_merge_failed": "Could not merge: %s",
812 + "flash.release_tag_empty": "Tag name cannot be empty.",
813 + "flash.release_tag_failed": "Could not create the tag: %s",
814 + "flash.release_create_failed": "Could not create the release: %s",
815 + "flash.release_published": "Release %s published.",
816 + "flash.release_delete_failed": "Could not delete the release: %s",
817 + "flash.release_deleted": "Release deleted.",
818 + "flash.repo_settings_saved": "Repository settings saved.",
819 + "flash.repo_rename_failed": "Could not rename: %s",
820 + "flash.repo_renamed": "Repository renamed to %s.",
821 + "flash.repo_confirm_mismatch": "The name you typed does not match the repository name.",
822 + "flash.repo_deleted": "Repository %s deleted.",
823 + "flash.branch_created": "Branch %s created.",
824 + "flash.branch_create_failed": "Could not create the branch: %s",
825 + "flash.branch_default_delete": "The default branch cannot be deleted.",
826 + "flash.branch_deleted": "Branch %s deleted.",
827 + "flash.branch_delete_failed": "Could not delete the branch: %s",
828 + "flash.tag_created": "Tag %s created.",
829 + "flash.tag_create_failed": "Could not create the tag: %s",
830 + "flash.tag_deleted": "Tag %s deleted.",
831 + "flash.tag_delete_failed": "Could not delete the tag: %s",
832 + "flash.issue_closed": "Issue #%d closed.",
833 + "flash.issue_reopened": "Issue #%d reopened.",
834 +
835 + // Error pages
836 + "err.not_found": "Not found",
837 + "err.forbidden": "Access denied",
838 + "err.session_error": "Session error",
839 + "err.session_create": "Could not create a session.",
840 + "err.settings_error": "Settings error",
841 + "err.settings_save": "Could not save the setting.",
842 + "err.explore_disabled": "Explore is disabled on this server.",
843 + "err.activity_disabled": "Activity is disabled for this repository.",
844 + "err.pulls_disabled": "Pull requests are disabled for this repository.",
845 + "err.releases_disabled": "Releases are disabled for this repository.",
846 + "err.issues_disabled": "Issues are disabled for this repository.",
847 + "err.repo_not_found": "Repository not found",
848 + "err.repo_not_found_desc": "The repository does not exist or you do not have access to it.",
849 + "err.repo_private_desc": "The repository does not exist or is private.",
850 + "err.access_denied": "Access denied",
851 + "err.repo_write_denied": "You do not have permission to modify this repository.",
852 + "err.invalid_repo_path": "Invalid repository path",
853 + "err.path_not_found": "Path not found",
854 + "err.path_not_found_desc": "Could not find the path in the repository for ref %s",
855 + "err.file_not_found": "File not found",
856 + "err.file_not_found_desc": "The requested file was not found in %s",
857 + "err.commit_not_found": "Commit not found",
858 + "err.commit_not_found_desc": "Could not find commit %s",
859 + "err.pr_not_found": "Pull request not found",
860 + "err.pr_not_found_desc": "Could not find the pull request.",
861 + "err.pr_state_forbidden": "You cannot change the state of this pull request.",
862 + "err.pr_merge_forbidden": "You do not have permission to merge pull requests.",
863 + "err.issue_not_found": "Issue not found",
864 + "err.issue_not_found_desc": "Could not find the issue.",
865 + "err.issue_state_forbidden": "You cannot change the state of this issue.",
866 + "err.delete_error": "Delete error",
867 + "err.repo_delete_failed": "Could not delete the repository.",
868 + "err.user_not_found": "User @%s does not exist.",
869 + "err.archive_error": "Archive error",
870 + "err.archive_tar_failed": "Could not create a tar.gz archive for %s",
871 + "err.archive_zip_failed": "Could not create a zip archive for %s",
872 +
873 + // Forks
874 + "fork.forked_from": "forked from",
875 + "fork.forks": "Forks",
876 + "fork.forks_btn": "Forks",
877 + "fork.fork_btn": "Fork",
878 + "fork.fork_btn_desc": "Create a fork of this repository",
879 + "fork.your_fork": "Your fork",
880 + "fork.your_fork_desc": "Go to your fork",
881 + "forks.title": "Forks",
882 + "forks.empty": "Nobody has forked this repository yet.",
883 +
884 + // Collaborators
885 + "collab.title": "Collaborators",
886 + "collab.desc": "People with access to this repository",
887 + "collab.owner": "Owner",
888 + "collab.role": "Role",
889 + "collab.role_read": "Read",
890 + "collab.role_write": "Write",
891 + "collab.username": "User",
892 + "collab.username_ph": "username",
893 + "collab.add": "Add",
894 + "collab.remove": "Revoke access",
895 + "collab.empty": "No collaborators yet.",
896 +
897 + // Reviews
898 + "reviews.title": "Reviews",
899 + "reviews.approved": "Approved",
900 + "reviews.changes_requested": "Changes requested",
901 + "reviews.body_ph": "Review comment (optional)",
902 + "reviews.approve": "Approve",
903 + "reviews.request_changes": "Request changes",
904 +
905 + // Device flow (RFC 8628)
906 + "device.title": "Device authorization",
907 + "device.badge": "Git",
908 + "device.desc": "Enter the code shown by git or the credential helper to grant this device access to your account.",
909 + "device.check": "Check",
910 + "device.authorize_for": "Grant access to code",
911 + "device.scope_hint": "Access covers repositories you can already reach and is bound to this device only.",
912 + "device.approve": "Approve",
913 + "device.deny": "Deny",
914 + "device.signin_to_approve": "Sign in to approve access",
915 + "device.helper_hint": "Tip: configure the credential helper so the code is requested automatically — see the README.",
916 + "device.code_unknown": "Code not found. Double-check the code you entered.",
917 + "device.code_used": "This code has already been used or expired.",
918 + "device.approved_now": "All set! The device is authorized — return to your terminal.",
919 + "device.denied_now": "Request denied. The device will not get access.",
920 +
921 + // Flash: forks, collaborators, reviews, device flow
922 + "flash.fork_self": "This repository is yours — no fork needed.",
923 + "flash.fork_exists": "You already have a fork of this repository.",
924 + "flash.fork_created": "Fork created.",
925 + "flash.fork_failed": "Could not create fork: %s",
926 + "flash.collab_added": "@%s added as a collaborator.",
927 + "flash.collab_removed": "Collaborator access revoked.",
928 + "flash.collab_failed": "Could not update collaborator: %s",
929 + "flash.review_self": "You cannot review your own pull request.",
930 + "flash.review_closed": "Reviews are only available on open pull requests.",
931 + "flash.review_failed": "Could not save the review: %s",
932 + "flash.review_approved": "Review saved: approved.",
933 + "flash.review_changes_requested": "Review saved: changes requested.",
934 + "flash.device_code_required": "Enter the device code.",
935 + "flash.device_code_invalid": "Code not found, expired, or already used.",
936 + "flash.device_approved": "Device authorized — you can return to git.",
937 + "flash.device_denied": "Request denied.",
938 +
939 + // Misc
940 + "issues.new": "New issue",
941 + "pulls.head_repo": "From repository",
942 + "repo.about": "About",
943 + "repo.clone_title": "Clone",
944 + "repo.created": "Created",
945 + "repo.updated": "Updated",
946 + "repo.view_file": "Open",
947 + "err.collab_forbidden": "Only the owner or an administrator can manage collaborators.",
948 + "err.pr_head_repo_invalid": "The selected source repository is not allowed."},
949 +}
950 +
951 +func T(lang Lang, key string) string {
952 + dict, ok := translations[Normalize(string(lang))]
953 + if !ok {
954 + dict = translations[RU]
955 + }
956 + if val, ok := dict[key]; ok {
957 + return val
958 + }
959 + if val, ok := translations[RU][key]; ok {
960 + return val
961 + }
962 + return key
963 +}
internal/i18n/i18n_test.go
+21 -0
... ... @@ -0,0 +1,21 @@
1 +package i18n
2 +
3 +import "testing"
4 +
5 +// TestTranslationParity ensures both dictionaries expose the same keys.
6 +func TestTranslationParity(t *testing.T) {
7 + ru, en := translations[RU], translations[EN]
8 + for k := range ru {
9 + if _, ok := en[k]; !ok {
10 + t.Errorf("EN is missing key %q", k)
11 + }
12 + }
13 + for k := range en {
14 + if _, ok := ru[k]; !ok {
15 + t.Errorf("RU is missing key %q", k)
16 + }
17 + }
18 + if len(ru) == 0 {
19 + t.Fatal("empty dictionary")
20 + }
21 +}
internal/repo/activity.go
+136 -0
... ... @@ -0,0 +1,136 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "sort"
7 + "time"
8 +
9 + "polar/internal/git"
10 +)
11 +
12 +type ActivityItem struct {
13 + Type string // "commit", "pull", "issue", "release"
14 + Title string
15 + Subtitle string
16 + AuthorUsername string
17 + AuthorAvatar string
18 + Link string
19 + Icon string
20 + Badge string
21 + CreatedAt time.Time
22 +}
23 +
24 +type ActivityStats struct {
25 + CommitCount int
26 + OpenIssues int
27 + ClosedIssues int
28 + OpenPRs int
29 + MergedPRs int
30 + ReleaseCount int
31 +}
32 +
33 +func (s *Service) GetRepoActivity(ctx context.Context, r *Repository, diskPath string) ([]ActivityItem, *ActivityStats, error) {
34 + var items []ActivityItem
35 +
36 + // 1. Recent commits from default branch
37 + commits, _ := git.ListCommits(ctx, diskPath, r.DefaultBranch, 25, 0)
38 + for _, c := range commits {
39 + items = append(items, ActivityItem{
40 + Type: "commit",
41 + Title: c.Subject,
42 + Subtitle: c.ShortHash,
43 + AuthorUsername: c.AuthorName,
44 + AuthorAvatar: "",
45 + Link: fmt.Sprintf("/%s/%s/commit/%s", r.OwnerUsername, r.Name, c.Hash),
46 + Icon: "git-commit",
47 + Badge: "commit",
48 + CreatedAt: c.AuthoredAt,
49 + })
50 + }
51 +
52 + // 2. Recent Pull Requests
53 + pulls, _ := s.ListPullRequests(ctx, r.ID, "all")
54 + var openPRs, mergedPRs int
55 + for _, pr := range pulls {
56 + switch pr.Status {
57 + case "open":
58 + openPRs++
59 + case "merged":
60 + mergedPRs++
61 + }
62 + badge := pr.Status
63 + icon := "git-pull-request"
64 + items = append(items, ActivityItem{
65 + Type: "pull",
66 + Title: fmt.Sprintf("#%d %s", pr.Number, pr.Title),
67 + Subtitle: fmt.Sprintf("%s → %s", pr.HeadBranch, pr.BaseBranch),
68 + AuthorUsername: pr.AuthorUsername,
69 + AuthorAvatar: pr.AuthorAvatar,
70 + Link: fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number),
71 + Icon: icon,
72 + Badge: badge,
73 + CreatedAt: pr.CreatedAt,
74 + })
75 + }
76 +
77 + // 3. Recent Issues
78 + openIssList, _ := s.ListIssues(ctx, r.ID, false)
79 + closedIssList, _ := s.ListIssues(ctx, r.ID, true)
80 + openIssues := len(openIssList)
81 + closedIssues := len(closedIssList)
82 + allIssues := append(openIssList, closedIssList...)
83 + for _, iss := range allIssues {
84 + badge := "open"
85 + if iss.IsClosed {
86 + badge = "closed"
87 + }
88 + items = append(items, ActivityItem{
89 + Type: "issue",
90 + Title: fmt.Sprintf("#%d %s", iss.Number, iss.Title),
91 + Subtitle: "",
92 + AuthorUsername: iss.AuthorUsername,
93 + AuthorAvatar: iss.AuthorAvatar,
94 + Link: fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number),
95 + Icon: "issue",
96 + Badge: badge,
97 + CreatedAt: iss.CreatedAt,
98 + })
99 + }
100 +
101 + // 4. Recent Releases
102 + releases, _ := s.ListReleases(ctx, r.ID)
103 + for _, rel := range releases {
104 + items = append(items, ActivityItem{
105 + Type: "release",
106 + Title: fmt.Sprintf("%s (%s)", rel.Title, rel.TagName),
107 + Subtitle: "",
108 + AuthorUsername: rel.AuthorUsername,
109 + AuthorAvatar: rel.AuthorAvatar,
110 + Link: fmt.Sprintf("/%s/%s/releases#%s", r.OwnerUsername, r.Name, rel.TagName),
111 + Icon: "tag",
112 + Badge: "release",
113 + CreatedAt: rel.PublishedAt,
114 + })
115 + }
116 +
117 + // Sort combined timeline in reverse chronological order
118 + sort.Slice(items, func(i, j int) bool {
119 + return items[i].CreatedAt.After(items[j].CreatedAt)
120 + })
121 +
122 + if len(items) > 50 {
123 + items = items[:50]
124 + }
125 +
126 + stats := &ActivityStats{
127 + CommitCount: len(commits),
128 + OpenIssues: openIssues,
129 + ClosedIssues: closedIssues,
130 + OpenPRs: openPRs,
131 + MergedPRs: mergedPRs,
132 + ReleaseCount: len(releases),
133 + }
134 +
135 + return items, stats, nil
136 +}
internal/repo/collab.go
+152 -0
... ... @@ -0,0 +1,152 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "database/sql"
6 + "errors"
7 + "fmt"
8 + "strings"
9 + "time"
10 +
11 + "polar/internal/user"
12 +)
13 +
14 +const (
15 + RoleRead = "read"
16 + RoleWrite = "write"
17 +)
18 +
19 +var (
20 + ErrCollabUserNotFound = errors.New("user not found")
21 + ErrCannotAddOwner = errors.New("repository owner cannot be added as a collaborator")
22 +)
23 +
24 +type Collaborator struct {
25 + UserID int64
26 + Username string
27 + FullName string
28 + AvatarURL string
29 + Role string
30 + CreatedAt time.Time
31 +}
32 +
33 +func normalizeRole(role string) string {
34 + if strings.TrimSpace(role) == RoleRead {
35 + return RoleRead
36 + }
37 + return RoleWrite
38 +}
39 +
40 +func (s *Service) AddCollaborator(ctx context.Context, r *Repository, username, role string) error {
41 + username = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(username), "@"))
42 + if username == "" {
43 + return ErrCollabUserNotFound
44 + }
45 +
46 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
47 + defer cancel()
48 +
49 + var userID int64
50 + err := s.db.QueryRowContext(cctx, `SELECT id FROM users WHERE LOWER(username) = LOWER(?)`, username).Scan(&userID)
51 + if err != nil {
52 + if errors.Is(err, sql.ErrNoRows) {
53 + return ErrCollabUserNotFound
54 + }
55 + return fmt.Errorf("lookup collaborator user: %w", err)
56 + }
57 + if userID == r.OwnerID {
58 + return ErrCannotAddOwner
59 + }
60 +
61 + now := time.Now().UTC()
62 + _, err = s.db.ExecContext(cctx, `
63 + INSERT INTO collaborators (repo_id, user_id, role, created_at)
64 + VALUES (?, ?, ?, ?)
65 + ON CONFLICT(repo_id, user_id) DO UPDATE SET role = excluded.role
66 + `, r.ID, userID, normalizeRole(role), now)
67 + if err != nil {
68 + return fmt.Errorf("upsert collaborator: %w", err)
69 + }
70 + return nil
71 +}
72 +
73 +func (s *Service) RemoveCollaborator(ctx context.Context, repoID, userID int64) error {
74 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
75 + defer cancel()
76 +
77 + _, err := s.db.ExecContext(cctx, `DELETE FROM collaborators WHERE repo_id = ? AND user_id = ?`, repoID, userID)
78 + if err != nil {
79 + return fmt.Errorf("remove collaborator: %w", err)
80 + }
81 + return nil
82 +}
83 +
84 +func (s *Service) ListCollaborators(ctx context.Context, repoID int64) ([]Collaborator, error) {
85 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
86 + defer cancel()
87 +
88 + rows, err := s.db.QueryContext(cctx, `
89 + SELECT c.user_id, u.username, u.full_name, u.avatar_url, c.role, c.created_at
90 + FROM collaborators c
91 + JOIN users u ON u.id = c.user_id
92 + WHERE c.repo_id = ?
93 + ORDER BY c.created_at ASC
94 + `, repoID)
95 + if err != nil {
96 + return nil, fmt.Errorf("list collaborators: %w", err)
97 + }
98 + defer rows.Close()
99 +
100 + var list []Collaborator
101 + for rows.Next() {
102 + var c Collaborator
103 + if err := rows.Scan(&c.UserID, &c.Username, &c.FullName, &c.AvatarURL, &c.Role, &c.CreatedAt); err != nil {
104 + return nil, fmt.Errorf("scan collaborator: %w", err)
105 + }
106 + list = append(list, c)
107 + }
108 + return list, rows.Err()
109 +}
110 +
111 +func (s *Service) GetCollaboratorRole(ctx context.Context, repoID, userID int64) string {
112 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
113 + defer cancel()
114 +
115 + var role string
116 + err := s.db.QueryRowContext(cctx, `SELECT role FROM collaborators WHERE repo_id = ? AND user_id = ?`, repoID, userID).Scan(&role)
117 + if err != nil {
118 + return ""
119 + }
120 + return role
121 +}
122 +
123 +// CanRead reports whether the user may read the repository (owner, admin,
124 +// collaborator of any role, or anyone for public repositories).
125 +func (s *Service) CanRead(ctx context.Context, r *Repository, u *user.User) bool {
126 + if r == nil {
127 + return false
128 + }
129 + if !r.IsPrivate {
130 + return true
131 + }
132 + if u == nil {
133 + return false
134 + }
135 + if u.ID == r.OwnerID || u.IsAdmin {
136 + return true
137 + }
138 + role := s.GetCollaboratorRole(ctx, r.ID, u.ID)
139 + return role == RoleRead || role == RoleWrite
140 +}
141 +
142 +// CanWrite reports whether the user may push to the repository (owner, admin
143 +// or a collaborator with the write role).
144 +func (s *Service) CanWrite(ctx context.Context, r *Repository, u *user.User) bool {
145 + if r == nil || u == nil {
146 + return false
147 + }
148 + if u.ID == r.OwnerID || u.IsAdmin {
149 + return true
150 + }
151 + return s.GetCollaboratorRole(ctx, r.ID, u.ID) == RoleWrite
152 +}
internal/repo/issues.go
+234 -0
... ... @@ -0,0 +1,234 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "database/sql"
6 + "errors"
7 + "fmt"
8 + "strings"
9 + "time"
10 +)
11 +
12 +var (
13 + ErrIssueNotFound = errors.New("issue not found")
14 + ErrEmptyTitle = errors.New("issue title cannot be empty")
15 + ErrEmptyComment = errors.New("comment cannot be empty")
16 +)
17 +
18 +type Issue struct {
19 + ID int64
20 + RepoID int64
21 + Number int
22 + AuthorID int64
23 + AuthorUsername string
24 + AuthorAvatar string
25 + Title string
26 + Body string
27 + IsClosed bool
28 + CommentCount int
29 + CreatedAt time.Time
30 + UpdatedAt time.Time
31 +}
32 +
33 +type IssueComment struct {
34 + ID int64
35 + IssueID int64
36 + AuthorID int64
37 + AuthorUsername string
38 + AuthorAvatar string
39 + Body string
40 + CreatedAt time.Time
41 +}
42 +
43 +func (s *Service) CreateIssue(ctx context.Context, repoID, authorID int64, title, body string) (*Issue, error) {
44 + title = strings.TrimSpace(title)
45 + body = strings.TrimSpace(body)
46 + if title == "" {
47 + return nil, ErrEmptyTitle
48 + }
49 + if len(title) > 200 {
50 + title = title[:200]
51 + }
52 + if len(body) > 20000 {
53 + body = body[:20000]
54 + }
55 +
56 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
57 + defer cancel()
58 +
59 + tx, err := s.db.BeginTx(cctx, nil)
60 + if err != nil {
61 + return nil, fmt.Errorf("begin create issue tx: %w", err)
62 + }
63 + defer func() { _ = tx.Rollback() }()
64 +
65 + var nextNum int
66 + err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE repo_id = ?`, repoID).Scan(&nextNum)
67 + if err != nil {
68 + return nil, fmt.Errorf("next issue number: %w", err)
69 + }
70 +
71 + now := time.Now().UTC()
72 + res, err := tx.ExecContext(cctx, `
73 + INSERT INTO issues (repo_id, number, author_id, title, body, is_closed, created_at, updated_at)
74 + VALUES (?, ?, ?, ?, ?, 0, ?, ?)
75 + `, repoID, nextNum, authorID, title, body, now, now)
76 + if err != nil {
77 + return nil, fmt.Errorf("insert issue: %w", err)
78 + }
79 +
80 + id, _ := res.LastInsertId()
81 + if err := tx.Commit(); err != nil {
82 + return nil, fmt.Errorf("commit issue tx: %w", err)
83 + }
84 +
85 + return &Issue{
86 + ID: id,
87 + RepoID: repoID,
88 + Number: nextNum,
89 + AuthorID: authorID,
90 + Title: title,
91 + Body: body,
92 + IsClosed: false,
93 + CreatedAt: now,
94 + UpdatedAt: now,
95 + }, nil
96 +}
97 +
98 +func (s *Service) ListIssues(ctx context.Context, repoID int64, isClosed bool) ([]Issue, error) {
99 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
100 + defer cancel()
101 +
102 + closedInt := 0
103 + if isClosed {
104 + closedInt = 1
105 + }
106 +
107 + rows, err := s.db.QueryContext(cctx, `
108 + SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url,
109 + i.title, i.body, i.is_closed,
110 + (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count,
111 + i.created_at, i.updated_at
112 + FROM issues i
113 + JOIN users u ON u.id = i.author_id
114 + WHERE i.repo_id = ? AND i.is_closed = ?
115 + ORDER BY i.number DESC
116 + `, repoID, closedInt)
117 + if err != nil {
118 + return nil, fmt.Errorf("list issues: %w", err)
119 + }
120 + defer rows.Close()
121 +
122 + var issues []Issue
123 + for rows.Next() {
124 + var iss Issue
125 + var closed int
126 + if err := rows.Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar,
127 + &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt); err != nil {
128 + return nil, fmt.Errorf("scan issue: %w", err)
129 + }
130 + iss.IsClosed = closed == 1
131 + issues = append(issues, iss)
132 + }
133 + return issues, rows.Err()
134 +}
135 +
136 +func (s *Service) CountIssues(ctx context.Context, repoID int64) (openCount int, closedCount int, err error) {
137 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
138 + defer cancel()
139 +
140 + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 0`, repoID).Scan(&openCount)
141 + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND is_closed = 1`, repoID).Scan(&closedCount)
142 + return openCount, closedCount, nil
143 +}
144 +
145 +func (s *Service) GetIssue(ctx context.Context, repoID int64, number int) (*Issue, error) {
146 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
147 + defer cancel()
148 +
149 + var iss Issue
150 + var closed int
151 + err := s.db.QueryRowContext(cctx, `
152 + SELECT i.id, i.repo_id, i.number, i.author_id, u.username, u.avatar_url,
153 + i.title, i.body, i.is_closed,
154 + (SELECT COUNT(*) FROM issue_comments ic WHERE ic.issue_id = i.id) AS comment_count,
155 + i.created_at, i.updated_at
156 + FROM issues i
157 + JOIN users u ON u.id = i.author_id
158 + WHERE i.repo_id = ? AND i.number = ?
159 + `, repoID, number).Scan(&iss.ID, &iss.RepoID, &iss.Number, &iss.AuthorID, &iss.AuthorUsername, &iss.AuthorAvatar,
160 + &iss.Title, &iss.Body, &closed, &iss.CommentCount, &iss.CreatedAt, &iss.UpdatedAt)
161 + if err != nil {
162 + if errors.Is(err, sql.ErrNoRows) {
163 + return nil, ErrIssueNotFound
164 + }
165 + return nil, fmt.Errorf("get issue: %w", err)
166 + }
167 + iss.IsClosed = closed == 1
168 + return &iss, nil
169 +}
170 +
171 +func (s *Service) SetIssueClosed(ctx context.Context, issueID int64, closed bool) error {
172 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
173 + defer cancel()
174 +
175 + val := 0
176 + if closed {
177 + val = 1
178 + }
179 + _, err := s.db.ExecContext(cctx, `
180 + UPDATE issues SET is_closed = ?, updated_at = ? WHERE id = ?
181 + `, val, time.Now().UTC(), issueID)
182 + return err
183 +}
184 +
185 +func (s *Service) CreateIssueComment(ctx context.Context, issueID, authorID int64, body string) error {
186 + body = strings.TrimSpace(body)
187 + if body == "" {
188 + return ErrEmptyComment
189 + }
190 + if len(body) > 20000 {
191 + body = body[:20000]
192 + }
193 +
194 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
195 + defer cancel()
196 +
197 + now := time.Now().UTC()
198 + _, err := s.db.ExecContext(cctx, `
199 + INSERT INTO issue_comments (issue_id, author_id, body, created_at)
200 + VALUES (?, ?, ?, ?)
201 + `, issueID, authorID, body, now)
202 + if err != nil {
203 + return fmt.Errorf("insert comment: %w", err)
204 + }
205 + _, _ = s.db.ExecContext(cctx, `UPDATE issues SET updated_at = ? WHERE id = ?`, now, issueID)
206 + return nil
207 +}
208 +
209 +func (s *Service) ListIssueComments(ctx context.Context, issueID int64) ([]IssueComment, error) {
210 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
211 + defer cancel()
212 +
213 + rows, err := s.db.QueryContext(cctx, `
214 + SELECT ic.id, ic.issue_id, ic.author_id, u.username, u.avatar_url, ic.body, ic.created_at
215 + FROM issue_comments ic
216 + JOIN users u ON u.id = ic.author_id
217 + WHERE ic.issue_id = ?
218 + ORDER BY ic.created_at ASC
219 + `, issueID)
220 + if err != nil {
221 + return nil, fmt.Errorf("list issue comments: %w", err)
222 + }
223 + defer rows.Close()
224 +
225 + var list []IssueComment
226 + for rows.Next() {
227 + var c IssueComment
228 + if err := rows.Scan(&c.ID, &c.IssueID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil {
229 + return nil, fmt.Errorf("scan issue comment: %w", err)
230 + }
231 + list = append(list, c)
232 + }
233 + return list, rows.Err()
234 +}
internal/repo/merge.go
+60 -0
... ... @@ -0,0 +1,60 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 +
8 + "polar/internal/git"
9 +)
10 +
11 +var ErrForkNotFound = errors.New("fork repository not found")
12 +
13 +// MergePullRequest merges an open pull request into its base branch and
14 +// records the merge. Same-repository pull requests merge local branches;
15 +// cross-repository (fork) pull requests are merged through a temporary ref
16 +// fetched from the fork.
17 +func (s *Service) MergePullRequest(ctx context.Context, base *Repository, pr *PullRequest, authorName, authorEmail string, mergedByID int64) error {
18 + if pr == nil || base == nil {
19 + return errors.New("pull request and repository are required")
20 + }
21 + if pr.Status != "open" {
22 + return errors.New("only open pull requests can be merged")
23 + }
24 +
25 + baseDisk, err := s.DiskPath(base.OwnerUsername, base.Name)
26 + if err != nil {
27 + return err
28 + }
29 +
30 + headLabel := pr.HeadBranch
31 + if pr.HeadRepoID != nil && *pr.HeadRepoID != base.ID {
32 + headLabel = fmt.Sprintf("%s/%s:%s", pr.HeadRepoOwner, pr.HeadRepoName, pr.HeadBranch)
33 + }
34 + msg := fmt.Sprintf("Merge pull request #%d from %s\n\n%s", pr.Number, headLabel, pr.Title)
35 +
36 + if pr.HeadRepoID != nil && *pr.HeadRepoID != base.ID {
37 + head, err := s.GetByID(ctx, *pr.HeadRepoID)
38 + if err != nil || head == nil {
39 + return ErrForkNotFound
40 + }
41 + forkDisk, err := s.DiskPath(head.OwnerUsername, head.Name)
42 + if err != nil {
43 + return err
44 + }
45 + tmpRef := fmt.Sprintf("refs/polar/tmp-merge-%d", pr.ID)
46 + if _, err := git.MergeFromPath(ctx, baseDisk, forkDisk, pr.BaseBranch, pr.HeadBranch, tmpRef, msg, authorName, authorEmail); err != nil {
47 + return err
48 + }
49 + } else {
50 + if _, err := git.MergeBranches(ctx, baseDisk, pr.BaseBranch, pr.HeadBranch, msg, authorName, authorEmail); err != nil {
51 + return err
52 + }
53 + }
54 +
55 + if err := s.SetPullRequestStatus(ctx, pr.ID, "merged", &mergedByID); err != nil {
56 + return err
57 + }
58 + s.TouchUpdatedAt(ctx, base.ID)
59 + return nil
60 +}
internal/repo/pulls.go
+331 -0
... ... @@ -0,0 +1,331 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "database/sql"
6 + "errors"
7 + "fmt"
8 + "strings"
9 + "time"
10 +)
11 +
12 +var (
13 + ErrPullNotFound = errors.New("pull request not found")
14 + ErrEmptyPullTitle = errors.New("pull request title cannot be empty")
15 + ErrSameBranch = errors.New("base and head branches cannot be the same")
16 + ErrEmptyBranch = errors.New("branches must be specified")
17 + ErrPullAlreadyOpen = errors.New("a pull request for these branches is already open")
18 +)
19 +
20 +type PullRequest struct {
21 + ID int64
22 + RepoID int64
23 + Number int
24 + AuthorID int64
25 + AuthorUsername string
26 + AuthorAvatar string
27 + Title string
28 + Body string
29 + BaseBranch string
30 + HeadBranch string
31 + Status string // "open", "closed", "merged"
32 + MergedByID *int64
33 + HeadRepoID *int64
34 + HeadRepoOwner string
35 + HeadRepoName string
36 + MergedByUsername string
37 + MergedAt *time.Time
38 + CommentCount int
39 + CreatedAt time.Time
40 + UpdatedAt time.Time
41 +}
42 +
43 +type PullRequestComment struct {
44 + ID int64
45 + PullID int64
46 + AuthorID int64
47 + AuthorUsername string
48 + AuthorAvatar string
49 + Body string
50 + CreatedAt time.Time
51 +}
52 +
53 +func (s *Service) CreatePullRequest(ctx context.Context, repoID, authorID int64, title, body, baseBranch, headBranch string, headRepoID *int64) (*PullRequest, error) {
54 + title = strings.TrimSpace(title)
55 + body = strings.TrimSpace(body)
56 + baseBranch = strings.TrimSpace(baseBranch)
57 + headBranch = strings.TrimSpace(headBranch)
58 +
59 + if title == "" {
60 + return nil, ErrEmptyPullTitle
61 + }
62 + if len(title) > 200 {
63 + title = title[:200]
64 + }
65 + if len(body) > 20000 {
66 + body = body[:20000]
67 + }
68 + if baseBranch == "" || headBranch == "" {
69 + return nil, ErrEmptyBranch
70 + }
71 + if baseBranch == headBranch {
72 + return nil, ErrSameBranch
73 + }
74 +
75 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
76 + defer cancel()
77 +
78 + tx, err := s.db.BeginTx(cctx, nil)
79 + if err != nil {
80 + return nil, fmt.Errorf("begin create pr tx: %w", err)
81 + }
82 + defer func() { _ = tx.Rollback() }()
83 +
84 + var existingID int64
85 + err = tx.QueryRowContext(cctx, `
86 + SELECT id FROM pull_requests
87 + WHERE repo_id = ? AND base_branch = ? AND head_branch = ? AND status = 'open' AND head_repo_id IS ?
88 + `, repoID, baseBranch, headBranch, headRepoID).Scan(&existingID)
89 + if err == nil {
90 + return nil, ErrPullAlreadyOpen
91 + }
92 +
93 + var nextNum int
94 + err = tx.QueryRowContext(cctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE repo_id = ?`, repoID).Scan(&nextNum)
95 + if err != nil {
96 + return nil, fmt.Errorf("next pr number: %w", err)
97 + }
98 +
99 + now := time.Now().UTC()
100 + res, err := tx.ExecContext(cctx, `
101 + INSERT INTO pull_requests (repo_id, number, author_id, title, body, base_branch, head_branch, head_repo_id, status, created_at, updated_at)
102 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
103 + `, repoID, nextNum, authorID, title, body, baseBranch, headBranch, headRepoID, now, now)
104 + if err != nil {
105 + return nil, fmt.Errorf("insert pull request: %w", err)
106 + }
107 +
108 + _ = res
109 + if err := tx.Commit(); err != nil {
110 + return nil, fmt.Errorf("commit pull request tx: %w", err)
111 + }
112 +
113 + return s.GetPullRequest(ctx, repoID, nextNum)
114 +}
115 +
116 +func (s *Service) GetPullRequest(ctx context.Context, repoID int64, number int) (*PullRequest, error) {
117 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
118 + defer cancel()
119 +
120 + var pr PullRequest
121 + var mergedByID sql.NullInt64
122 + var mergedByUsername sql.NullString
123 + var mergedAt sql.NullTime
124 + var headRepoID sql.NullInt64
125 + var headRepoOwner, headRepoName sql.NullString
126 +
127 + err := s.db.QueryRowContext(cctx, `
128 + SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url,
129 + pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status,
130 + pr.merged_by_id, mu.username, pr.merged_at,
131 + pr.head_repo_id, hu.username, hr.name,
132 + (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count,
133 + pr.created_at, pr.updated_at
134 + FROM pull_requests pr
135 + JOIN users u ON u.id = pr.author_id
136 + LEFT JOIN users mu ON mu.id = pr.merged_by_id
137 + LEFT JOIN repositories hr ON hr.id = pr.head_repo_id
138 + LEFT JOIN users hu ON hu.id = hr.owner_id
139 + WHERE pr.repo_id = ? AND pr.number = ?
140 + `, repoID, number).Scan(
141 + &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar,
142 + &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status,
143 + &mergedByID, &mergedByUsername, &mergedAt,
144 + &headRepoID, &headRepoOwner, &headRepoName,
145 + &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt,
146 + )
147 + if err != nil {
148 + if errors.Is(err, sql.ErrNoRows) {
149 + return nil, ErrPullNotFound
150 + }
151 + return nil, fmt.Errorf("get pull request: %w", err)
152 + }
153 +
154 + if mergedByID.Valid {
155 + pr.MergedByID = &mergedByID.Int64
156 + }
157 + if mergedByUsername.Valid {
158 + pr.MergedByUsername = mergedByUsername.String
159 + }
160 + if mergedAt.Valid {
161 + t := mergedAt.Time
162 + pr.MergedAt = &t
163 + }
164 + if headRepoID.Valid {
165 + id := headRepoID.Int64
166 + pr.HeadRepoID = &id
167 + }
168 + pr.HeadRepoOwner = headRepoOwner.String
169 + pr.HeadRepoName = headRepoName.String
170 +
171 + return &pr, nil
172 +}
173 +
174 +func (s *Service) ListPullRequests(ctx context.Context, repoID int64, statusFilter string) ([]PullRequest, error) {
175 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
176 + defer cancel()
177 +
178 + query := `
179 + SELECT pr.id, pr.repo_id, pr.number, pr.author_id, u.username, u.avatar_url,
180 + pr.title, pr.body, pr.base_branch, pr.head_branch, pr.status,
181 + pr.merged_by_id, mu.username, pr.merged_at,
182 + pr.head_repo_id, hu.username, hr.name,
183 + (SELECT COUNT(*) FROM pull_request_comments prc WHERE prc.pull_id = pr.id) AS comment_count,
184 + pr.created_at, pr.updated_at
185 + FROM pull_requests pr
186 + JOIN users u ON u.id = pr.author_id
187 + LEFT JOIN users mu ON mu.id = pr.merged_by_id
188 + LEFT JOIN repositories hr ON hr.id = pr.head_repo_id
189 + LEFT JOIN users hu ON hu.id = hr.owner_id
190 + WHERE pr.repo_id = ?
191 + `
192 + var args []any
193 + args = append(args, repoID)
194 +
195 + switch statusFilter {
196 + case "open":
197 + query += ` AND pr.status = 'open'`
198 + case "closed":
199 + query += ` AND pr.status = 'closed'`
200 + case "merged":
201 + query += ` AND pr.status = 'merged'`
202 + }
203 + query += ` ORDER BY pr.created_at DESC`
204 +
205 + rows, err := s.db.QueryContext(cctx, query, args...)
206 + if err != nil {
207 + return nil, fmt.Errorf("list pull requests: %w", err)
208 + }
209 + defer rows.Close()
210 +
211 + var list []PullRequest
212 + for rows.Next() {
213 + var pr PullRequest
214 + var mergedByID sql.NullInt64
215 + var mergedByUsername sql.NullString
216 + var mergedAt sql.NullTime
217 + var headRepoID sql.NullInt64
218 + var headRepoOwner, headRepoName sql.NullString
219 +
220 + if err := rows.Scan(
221 + &pr.ID, &pr.RepoID, &pr.Number, &pr.AuthorID, &pr.AuthorUsername, &pr.AuthorAvatar,
222 + &pr.Title, &pr.Body, &pr.BaseBranch, &pr.HeadBranch, &pr.Status,
223 + &mergedByID, &mergedByUsername, &mergedAt,
224 + &headRepoID, &headRepoOwner, &headRepoName,
225 + &pr.CommentCount, &pr.CreatedAt, &pr.UpdatedAt,
226 + ); err != nil {
227 + return nil, fmt.Errorf("scan pull request: %w", err)
228 + }
229 + if mergedByID.Valid {
230 + pr.MergedByID = &mergedByID.Int64
231 + }
232 + if mergedByUsername.Valid {
233 + pr.MergedByUsername = mergedByUsername.String
234 + }
235 + if mergedAt.Valid {
236 + t := mergedAt.Time
237 + pr.MergedAt = &t
238 + }
239 + if headRepoID.Valid {
240 + id := headRepoID.Int64
241 + pr.HeadRepoID = &id
242 + }
243 + pr.HeadRepoOwner = headRepoOwner.String
244 + pr.HeadRepoName = headRepoName.String
245 + list = append(list, pr)
246 + }
247 + return list, rows.Err()
248 +}
249 +
250 +func (s *Service) CountPullRequests(ctx context.Context, repoID int64) (openCount int, closedCount int, mergedCount int, err error) {
251 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
252 + defer cancel()
253 +
254 + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'open'`, repoID).Scan(&openCount)
255 + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'closed'`, repoID).Scan(&closedCount)
256 + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_requests WHERE repo_id = ? AND status = 'merged'`, repoID).Scan(&mergedCount)
257 + return openCount, closedCount, mergedCount, nil
258 +}
259 +
260 +func (s *Service) SetPullRequestStatus(ctx context.Context, pullID int64, status string, mergedByID *int64) error {
261 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
262 + defer cancel()
263 +
264 + now := time.Now().UTC()
265 + if status == "merged" {
266 + _, err := s.db.ExecContext(cctx, `
267 + UPDATE pull_requests
268 + SET status = 'merged', merged_by_id = ?, merged_at = ?, updated_at = ?
269 + WHERE id = ?
270 + `, mergedByID, now, now, pullID)
271 + return err
272 + }
273 +
274 + _, err := s.db.ExecContext(cctx, `
275 + UPDATE pull_requests
276 + SET status = ?, merged_by_id = NULL, merged_at = NULL, updated_at = ?
277 + WHERE id = ?
278 + `, status, now, pullID)
279 + return err
280 +}
281 +
282 +func (s *Service) CreatePullRequestComment(ctx context.Context, pullID, authorID int64, body string) error {
283 + body = strings.TrimSpace(body)
284 + if body == "" {
285 + return ErrEmptyComment
286 + }
287 + if len(body) > 20000 {
288 + body = body[:20000]
289 + }
290 +
291 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
292 + defer cancel()
293 +
294 + now := time.Now().UTC()
295 + _, err := s.db.ExecContext(cctx, `
296 + INSERT INTO pull_request_comments (pull_id, author_id, body, created_at)
297 + VALUES (?, ?, ?, ?)
298 + `, pullID, authorID, body, now)
299 + if err != nil {
300 + return fmt.Errorf("insert pr comment: %w", err)
301 + }
302 + _, _ = s.db.ExecContext(cctx, `UPDATE pull_requests SET updated_at = ? WHERE id = ?`, now, pullID)
303 + return nil
304 +}
305 +
306 +func (s *Service) ListPullRequestComments(ctx context.Context, pullID int64) ([]PullRequestComment, error) {
307 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
308 + defer cancel()
309 +
310 + rows, err := s.db.QueryContext(cctx, `
311 + SELECT prc.id, prc.pull_id, prc.author_id, u.username, u.avatar_url, prc.body, prc.created_at
312 + FROM pull_request_comments prc
313 + JOIN users u ON u.id = prc.author_id
314 + WHERE prc.pull_id = ?
315 + ORDER BY prc.created_at ASC
316 + `, pullID)
317 + if err != nil {
318 + return nil, fmt.Errorf("list pr comments: %w", err)
319 + }
320 + defer rows.Close()
321 +
322 + var list []PullRequestComment
323 + for rows.Next() {
324 + var c PullRequestComment
325 + if err := rows.Scan(&c.ID, &c.PullID, &c.AuthorID, &c.AuthorUsername, &c.AuthorAvatar, &c.Body, &c.CreatedAt); err != nil {
326 + return nil, fmt.Errorf("scan pr comment: %w", err)
327 + }
328 + list = append(list, c)
329 + }
330 + return list, rows.Err()
331 +}
internal/repo/releases.go
+198 -0
... ... @@ -0,0 +1,198 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "database/sql"
6 + "errors"
7 + "fmt"
8 + "strings"
9 + "time"
10 +)
11 +
12 +var (
13 + ErrReleaseNotFound = errors.New("release not found")
14 + ErrEmptyReleaseTag = errors.New("release tag cannot be empty")
15 + ErrEmptyReleaseTitle = errors.New("release title cannot be empty")
16 + ErrReleaseAlreadyExist = errors.New("a release for this tag already exists")
17 +)
18 +
19 +type Release struct {
20 + ID int64
21 + RepoID int64
22 + AuthorID int64
23 + AuthorUsername string
24 + AuthorAvatar string
25 + TagName string
26 + Title string
27 + Body string
28 + IsDraft bool
29 + IsPrerelease bool
30 + CreatedAt time.Time
31 + PublishedAt time.Time
32 +}
33 +
34 +func (s *Service) CreateRelease(ctx context.Context, repoID, authorID int64, tagName, title, body string, isDraft, isPrerelease bool) (*Release, error) {
35 + tagName = strings.TrimSpace(tagName)
36 + title = strings.TrimSpace(title)
37 + body = strings.TrimSpace(body)
38 +
39 + if tagName == "" {
40 + return nil, ErrEmptyReleaseTag
41 + }
42 + if title == "" {
43 + title = tagName
44 + }
45 + if len(title) > 200 {
46 + title = title[:200]
47 + }
48 + if len(body) > 50000 {
49 + body = body[:50000]
50 + }
51 +
52 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
53 + defer cancel()
54 +
55 + b2i := func(b bool) int {
56 + if b {
57 + return 1
58 + }
59 + return 0
60 + }
61 +
62 + now := time.Now().UTC()
63 + res, err := s.db.ExecContext(cctx, `
64 + INSERT INTO releases (repo_id, author_id, tag_name, title, body, is_draft, is_prerelease, created_at, published_at)
65 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
66 + `, repoID, authorID, tagName, title, body, b2i(isDraft), b2i(isPrerelease), now, now)
67 + if err != nil {
68 + if strings.Contains(strings.ToLower(err.Error()), "unique") {
69 + return nil, ErrReleaseAlreadyExist
70 + }
71 + return nil, fmt.Errorf("insert release: %w", err)
72 + }
73 +
74 + id, _ := res.LastInsertId()
75 + return s.GetReleaseByID(ctx, repoID, id)
76 +}
77 +
78 +func (s *Service) GetRelease(ctx context.Context, repoID int64, tagName string) (*Release, error) {
79 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
80 + defer cancel()
81 +
82 + var rel Release
83 + var draft, prerelease int
84 + err := s.db.QueryRowContext(cctx, `
85 + SELECT r.id, r.repo_id, r.author_id, u.username, u.avatar_url,
86 + r.tag_name, r.title, r.body, r.is_draft, r.is_prerelease, r.created_at, r.published_at
87 + FROM releases r
88 + JOIN users u ON u.id = r.author_id
89 + WHERE r.repo_id = ? AND r.tag_name = ?
90 + `, repoID, tagName).Scan(
91 + &rel.ID, &rel.RepoID, &rel.AuthorID, &rel.AuthorUsername, &rel.AuthorAvatar,
92 + &rel.TagName, &rel.Title, &rel.Body, &draft, &prerelease, &rel.CreatedAt, &rel.PublishedAt,
93 + )
94 + if err != nil {
95 + if errors.Is(err, sql.ErrNoRows) {
96 + return nil, ErrReleaseNotFound
97 + }
98 + return nil, fmt.Errorf("get release: %w", err)
99 + }
100 + rel.IsDraft = draft == 1
101 + rel.IsPrerelease = prerelease == 1
102 + return &rel, nil
103 +}
104 +
105 +func (s *Service) GetReleaseByID(ctx context.Context, repoID, releaseID int64) (*Release, error) {
106 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
107 + defer cancel()
108 +
109 + var rel Release
110 + var draft, prerelease int
111 + err := s.db.QueryRowContext(cctx, `
112 + SELECT r.id, r.repo_id, r.author_id, u.username, u.avatar_url,
113 + r.tag_name, r.title, r.body, r.is_draft, r.is_prerelease, r.created_at, r.published_at
114 + FROM releases r
115 + JOIN users u ON u.id = r.author_id
116 + WHERE r.repo_id = ? AND r.id = ?
117 + `, repoID, releaseID).Scan(
118 + &rel.ID, &rel.RepoID, &rel.AuthorID, &rel.AuthorUsername, &rel.AuthorAvatar,
119 + &rel.TagName, &rel.Title, &rel.Body, &draft, &prerelease, &rel.CreatedAt, &rel.PublishedAt,
120 + )
121 + if err != nil {
122 + if errors.Is(err, sql.ErrNoRows) {
123 + return nil, ErrReleaseNotFound
124 + }
125 + return nil, fmt.Errorf("get release: %w", err)
126 + }
127 + rel.IsDraft = draft == 1
128 + rel.IsPrerelease = prerelease == 1
129 + return &rel, nil
130 +}
131 +
132 +func (s *Service) ListReleases(ctx context.Context, repoID int64) ([]Release, error) {
133 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
134 + defer cancel()
135 +
136 + rows, err := s.db.QueryContext(cctx, `
137 + SELECT r.id, r.repo_id, r.author_id, u.username, u.avatar_url,
138 + r.tag_name, r.title, r.body, r.is_draft, r.is_prerelease, r.created_at, r.published_at
139 + FROM releases r
140 + JOIN users u ON u.id = r.author_id
141 + WHERE r.repo_id = ?
142 + ORDER BY r.published_at DESC
143 + `, repoID)
144 + if err != nil {
145 + return nil, fmt.Errorf("list releases: %w", err)
146 + }
147 + defer rows.Close()
148 +
149 + var list []Release
150 + for rows.Next() {
151 + var rel Release
152 + var draft, prerelease int
153 + if err := rows.Scan(
154 + &rel.ID, &rel.RepoID, &rel.AuthorID, &rel.AuthorUsername, &rel.AuthorAvatar,
155 + &rel.TagName, &rel.Title, &rel.Body, &draft, &prerelease, &rel.CreatedAt, &rel.PublishedAt,
156 + ); err != nil {
157 + return nil, fmt.Errorf("scan release: %w", err)
158 + }
159 + rel.IsDraft = draft == 1
160 + rel.IsPrerelease = prerelease == 1
161 + list = append(list, rel)
162 + }
163 + return list, rows.Err()
164 +}
165 +
166 +func (s *Service) UpdateRelease(ctx context.Context, releaseID int64, title, body string, isPrerelease bool) error {
167 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
168 + defer cancel()
169 +
170 + title = strings.TrimSpace(title)
171 + body = strings.TrimSpace(body)
172 + if len(title) > 200 {
173 + title = title[:200]
174 + }
175 + if len(body) > 50000 {
176 + body = body[:50000]
177 + }
178 +
179 + preInt := 0
180 + if isPrerelease {
181 + preInt = 1
182 + }
183 +
184 + _, err := s.db.ExecContext(cctx, `
185 + UPDATE releases
186 + SET title = ?, body = ?, is_prerelease = ?
187 + WHERE id = ?
188 + `, title, body, preInt, releaseID)
189 + return err
190 +}
191 +
192 +func (s *Service) DeleteRelease(ctx context.Context, releaseID int64) error {
193 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
194 + defer cancel()
195 +
196 + _, err := s.db.ExecContext(cctx, `DELETE FROM releases WHERE id = ?`, releaseID)
197 + return err
198 +}
internal/repo/repo.go
+591 -0
... ... @@ -0,0 +1,591 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "database/sql"
6 + "errors"
7 + "fmt"
8 + "os"
9 + "path/filepath"
10 + "regexp"
11 + "strings"
12 + "time"
13 +
14 + "polar/internal/db"
15 + "polar/internal/git"
16 + "polar/internal/user"
17 +)
18 +
19 +var (
20 + ErrRepoNotFound = errors.New("repository not found")
21 + ErrRepoAlreadyExists = errors.New("repository with this name already exists")
22 + ErrInvalidRepoName = errors.New("repository name must be 1–64 characters (letters, digits, '.', '-', '_')")
23 + ErrPathTraversal = errors.New("unsafe repository path")
24 +)
25 +
26 +var repoNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
27 +
28 +var reservedRepoNames = map[string]struct{}{
29 + "settings": {}, "commits": {}, "commit": {}, "tree": {}, "blob": {},
30 + "raw": {}, "info": {}, "git-upload-pack": {}, "git-receive-pack": {},
31 + "new": {}, "delete": {}, "rename": {}, "branches": {}, "tags": {},
32 + "issues": {}, "archive": {}, "pulls": {}, "releases": {}, "activity": {},
33 +}
34 +
35 +type Repository struct {
36 + ID int64
37 + OwnerID int64
38 + OwnerUsername string
39 + OwnerAvatarURL string
40 + Name string
41 + Description string
42 + IsPrivate bool
43 + DefaultBranch string
44 + EnableIssues bool
45 + EnablePulls bool
46 + EnableReleases bool
47 + EnableActivity bool
48 +
49 + ForkedFromRepoID *int64
50 + ForkedFromOwner string
51 + ForkedFromName string
52 + ForkCount int
53 +
54 + OpenIssueCount int
55 + OpenPRCount int
56 + ReleaseCount int
57 + CreatedAt time.Time
58 + UpdatedAt time.Time
59 +}
60 +
61 +type Service struct {
62 + db *db.DB
63 + reposDir string
64 +}
65 +
66 +func NewService(database *db.DB, reposDir string) *Service {
67 + return &Service{
68 + db: database,
69 + reposDir: reposDir,
70 + }
71 +}
72 +
73 +func ValidateRepoName(name string) error {
74 + name = strings.TrimSpace(name)
75 + if !repoNameRegex.MatchString(name) {
76 + return ErrInvalidRepoName
77 + }
78 + lower := strings.ToLower(name)
79 + if _, reserved := reservedRepoNames[lower]; reserved {
80 + return ErrInvalidRepoName
81 + }
82 + if strings.Contains(lower, "..") ||
83 + strings.HasSuffix(lower, ".git") ||
84 + strings.HasSuffix(lower, ".lock") ||
85 + strings.HasSuffix(lower, ".") {
86 + return ErrInvalidRepoName
87 + }
88 + return nil
89 +}
90 +
91 +func (s *Service) DiskPath(ownerUsername, repoName string) (string, error) {
92 + if err := user.ValidateUsername(ownerUsername); err != nil {
93 + return "", err
94 + }
95 + if err := ValidateRepoName(repoName); err != nil {
96 + return "", err
97 + }
98 +
99 + baseAbs, err := filepath.Abs(s.reposDir)
100 + if err != nil {
101 + return "", fmt.Errorf("resolve repos dir: %w", err)
102 + }
103 + target := filepath.Join(baseAbs, strings.ToLower(ownerUsername), strings.ToLower(repoName)+".git")
104 + rel, err := filepath.Rel(baseAbs, target)
105 + if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
106 + return "", ErrPathTraversal
107 + }
108 + return target, nil
109 +}
110 +
111 +func (s *Service) Create(ctx context.Context, owner *user.User, name, description string, isPrivate bool) (*Repository, error) {
112 + name = strings.TrimSpace(name)
113 + description = strings.TrimSpace(description)
114 + if len(description) > 500 {
115 + description = description[:500]
116 + }
117 +
118 + if err := ValidateRepoName(name); err != nil {
119 + return nil, err
120 + }
121 +
122 + diskPath, err := s.DiskPath(owner.Username, name)
123 + if err != nil {
124 + return nil, err
125 + }
126 +
127 + cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
128 + defer cancel()
129 +
130 + var exists int
131 + err = s.db.QueryRowContext(cctx, `
132 + SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?)
133 + `, owner.ID, name).Scan(&exists)
134 + if err == nil {
135 + return nil, ErrRepoAlreadyExists
136 + } else if !errors.Is(err, sql.ErrNoRows) {
137 + return nil, fmt.Errorf("check repo exists: %w", err)
138 + }
139 +
140 + defaultBranch := "main"
141 + if err := git.InitBare(cctx, diskPath, defaultBranch); err != nil {
142 + return nil, fmt.Errorf("init bare git repo: %w", err)
143 + }
144 +
145 + privInt := 0
146 + if isPrivate {
147 + privInt = 1
148 + }
149 + now := time.Now().UTC()
150 +
151 + res, err := s.db.ExecContext(cctx, `
152 + INSERT INTO repositories (owner_id, name, description, is_private, default_branch, created_at, updated_at)
153 + VALUES (?, ?, ?, ?, ?, ?, ?)
154 + `, owner.ID, name, description, privInt, defaultBranch, now, now)
155 + if err != nil {
156 + _ = os.RemoveAll(diskPath)
157 + if strings.Contains(strings.ToLower(err.Error()), "unique") {
158 + return nil, ErrRepoAlreadyExists
159 + }
160 + return nil, fmt.Errorf("insert repository: %w", err)
161 + }
162 +
163 + id, _ := res.LastInsertId()
164 + return &Repository{
165 + ID: id,
166 + OwnerID: owner.ID,
167 + OwnerUsername: owner.Username,
168 + OwnerAvatarURL: owner.AvatarURL,
169 + Name: name,
170 + Description: description,
171 + IsPrivate: isPrivate,
172 + DefaultBranch: defaultBranch,
173 + EnableIssues: true,
174 + EnablePulls: true,
175 + EnableReleases: true,
176 + EnableActivity: true,
177 + CreatedAt: now,
178 + UpdatedAt: now,
179 + }, nil
180 +}
181 +
182 +const repoSelectFields = `
183 + SELECT r.id, r.owner_id, u.username, u.avatar_url, r.name, r.description, r.is_private, r.default_branch,
184 + r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity,
185 + r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name,
186 + (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count,
187 + (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.is_closed = 0) AS open_issues,
188 + (SELECT COUNT(*) FROM pull_requests pr WHERE pr.repo_id = r.id AND pr.status = 'open') AS open_prs,
189 + (SELECT COUNT(*) FROM releases rel WHERE rel.repo_id = r.id AND rel.is_draft = 0) AS release_count,
190 + r.created_at, r.updated_at
191 + FROM repositories r
192 + JOIN users u ON u.id = r.owner_id
193 + LEFT JOIN repositories fr ON fr.id = r.forked_from_repo_id
194 + LEFT JOIN users fu ON fu.id = fr.owner_id
195 +`
196 +
197 +func (s *Service) GetByOwnerAndName(ctx context.Context, ownerUsername, repoName string) (*Repository, error) {
198 + ownerUsername = strings.TrimSpace(ownerUsername)
199 + repoName = strings.TrimSuffix(strings.TrimSpace(repoName), ".git")
200 +
201 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
202 + defer cancel()
203 +
204 + row := s.db.QueryRowContext(cctx, repoSelectFields+`
205 + WHERE LOWER(u.username) = LOWER(?) AND LOWER(r.name) = LOWER(?)
206 + `, ownerUsername, repoName)
207 +
208 + return scanRepo(row)
209 +}
210 +
211 +// GetByID loads a repository by primary key.
212 +func (s *Service) GetByID(ctx context.Context, id int64) (*Repository, error) {
213 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
214 + defer cancel()
215 +
216 + row := s.db.QueryRowContext(cctx, repoSelectFields+` WHERE r.id = ? `, id)
217 + return scanRepo(row)
218 +}
219 +func (s *Service) ListByOwner(ctx context.Context, ownerID int64, includePrivate bool) ([]Repository, error) {
220 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
221 + defer cancel()
222 +
223 + query := repoSelectFields + ` WHERE r.owner_id = ?`
224 + if !includePrivate {
225 + query += ` AND r.is_private = 0`
226 + }
227 + query += ` ORDER BY r.updated_at DESC, r.created_at DESC`
228 +
229 + rows, err := s.db.QueryContext(cctx, query, ownerID)
230 + if err != nil {
231 + return nil, fmt.Errorf("list repos by owner: %w", err)
232 + }
233 + defer rows.Close()
234 + return scanRepos(rows)
235 +}
236 +
237 +func (s *Service) ListPublic(ctx context.Context, limit int) ([]Repository, error) {
238 + if limit <= 0 || limit > 200 {
239 + limit = 50
240 + }
241 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
242 + defer cancel()
243 +
244 + rows, err := s.db.QueryContext(cctx, repoSelectFields+`
245 + WHERE r.is_private = 0
246 + ORDER BY r.updated_at DESC, r.created_at DESC
247 + LIMIT ?
248 + `, limit)
249 + if err != nil {
250 + return nil, fmt.Errorf("list public repos: %w", err)
251 + }
252 + defer rows.Close()
253 + return scanRepos(rows)
254 +}
255 +
256 +func (s *Service) ListAccessible(ctx context.Context, viewer *user.User, limit int) ([]Repository, error) {
257 + if viewer == nil {
258 + return s.ListPublic(ctx, limit)
259 + }
260 + if limit <= 0 || limit > 200 {
261 + limit = 100
262 + }
263 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
264 + defer cancel()
265 +
266 + var (
267 + rows *sql.Rows
268 + err error
269 + )
270 + if viewer.IsAdmin {
271 + rows, err = s.db.QueryContext(cctx, repoSelectFields+`
272 + ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC
273 + LIMIT ?
274 + `, viewer.ID, limit)
275 + } else {
276 + rows, err = s.db.QueryContext(cctx, repoSelectFields+`
277 + WHERE r.owner_id = ? OR r.is_private = 0
278 + ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC
279 + LIMIT ?
280 + `, viewer.ID, viewer.ID, limit)
281 + }
282 + if err != nil {
283 + return nil, fmt.Errorf("list accessible repos: %w", err)
284 + }
285 + defer rows.Close()
286 + return scanRepos(rows)
287 +}
288 +
289 +func (s *Service) Update(ctx context.Context, r *Repository, description string, isPrivate bool, defaultBranch string, enableIssues, enablePulls, enableReleases, enableActivity bool) error {
290 + description = strings.TrimSpace(description)
291 + if len(description) > 500 {
292 + description = description[:500]
293 + }
294 + if defaultBranch == "" {
295 + defaultBranch = r.DefaultBranch
296 + }
297 + if err := git.ValidateRef(defaultBranch); err != nil {
298 + return err
299 + }
300 +
301 + privInt := 0
302 + if isPrivate {
303 + privInt = 1
304 + }
305 + b2i := func(b bool) int {
306 + if b {
307 + return 1
308 + }
309 + return 0
310 + }
311 + now := time.Now().UTC()
312 +
313 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
314 + defer cancel()
315 +
316 + _, err := s.db.ExecContext(cctx, `
317 + UPDATE repositories
318 + SET description = ?, is_private = ?, default_branch = ?,
319 + enable_issues = ?, enable_pulls = ?, enable_releases = ?, enable_activity = ?,
320 + updated_at = ?
321 + WHERE id = ?
322 + `, description, privInt, defaultBranch, b2i(enableIssues), b2i(enablePulls), b2i(enableReleases), b2i(enableActivity), now, r.ID)
323 + if err != nil {
324 + return fmt.Errorf("update repository: %w", err)
325 + }
326 + r.Description = description
327 + r.IsPrivate = isPrivate
328 + r.DefaultBranch = defaultBranch
329 + r.EnableIssues = enableIssues
330 + r.EnablePulls = enablePulls
331 + r.EnableReleases = enableReleases
332 + r.EnableActivity = enableActivity
333 + r.UpdatedAt = now
334 + return nil
335 +}
336 +
337 +func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) {
338 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
339 + defer cancel()
340 + _, _ = s.db.ExecContext(cctx, `UPDATE repositories SET updated_at = ? WHERE id = ?`, time.Now().UTC(), repoID)
341 +}
342 +
343 +func (s *Service) Rename(ctx context.Context, r *Repository, newName string) error {
344 + newName = strings.TrimSpace(newName)
345 + if err := ValidateRepoName(newName); err != nil {
346 + return err
347 + }
348 + if strings.EqualFold(r.Name, newName) && r.Name == newName {
349 + return nil
350 + }
351 +
352 + oldPath, err := s.DiskPath(r.OwnerUsername, r.Name)
353 + if err != nil {
354 + return err
355 + }
356 + newPath, err := s.DiskPath(r.OwnerUsername, newName)
357 + if err != nil {
358 + return err
359 + }
360 +
361 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
362 + defer cancel()
363 +
364 + if !strings.EqualFold(r.Name, newName) {
365 + var exists int
366 + err := s.db.QueryRowContext(cctx, `
367 + SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) AND id != ?
368 + `, r.OwnerID, newName, r.ID).Scan(&exists)
369 + if err == nil {
370 + return ErrRepoAlreadyExists
371 + }
372 + }
373 +
374 + if oldPath != newPath {
375 + if err := os.Rename(oldPath, newPath); err != nil {
376 + return fmt.Errorf("rename repository directory: %w", err)
377 + }
378 + }
379 +
380 + now := time.Now().UTC()
381 + _, err = s.db.ExecContext(cctx, `
382 + UPDATE repositories SET name = ?, updated_at = ? WHERE id = ?
383 + `, newName, now, r.ID)
384 + if err != nil {
385 + if oldPath != newPath {
386 + _ = os.Rename(newPath, oldPath)
387 + }
388 + return fmt.Errorf("update repository name: %w", err)
389 + }
390 +
391 + r.Name = newName
392 + r.UpdatedAt = now
393 + return nil
394 +}
395 +
396 +func (s *Service) Delete(ctx context.Context, r *Repository) error {
397 + diskPath, err := s.DiskPath(r.OwnerUsername, r.Name)
398 + if err != nil {
399 + return err
400 + }
401 +
402 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
403 + defer cancel()
404 +
405 + if _, err := s.db.ExecContext(cctx, `DELETE FROM repositories WHERE id = ?`, r.ID); err != nil {
406 + return fmt.Errorf("delete repository record: %w", err)
407 + }
408 + if err := os.RemoveAll(diskPath); err != nil {
409 + return fmt.Errorf("remove repository directory: %w", err)
410 + }
411 + return nil
412 +}
413 +
414 +func boolInt(b bool) int {
415 + if b {
416 + return 1
417 + }
418 + return 0
419 +}
420 +
421 +// FindForkByOwner returns the owner's fork of the given repository, if any.
422 +func (s *Service) FindForkByOwner(ctx context.Context, srcRepoID, ownerID int64) (*Repository, error) {
423 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
424 + defer cancel()
425 +
426 + row := s.db.QueryRowContext(cctx, repoSelectFields+`
427 + WHERE r.forked_from_repo_id = ? AND r.owner_id = ?
428 + ORDER BY r.created_at ASC
429 + LIMIT 1
430 + `, srcRepoID, ownerID)
431 + r, err := scanRepo(row)
432 + if errors.Is(err, ErrRepoNotFound) {
433 + return nil, nil
434 + }
435 + return r, err
436 +}
437 +
438 +// Fork copies a repository into the owner's namespace.
439 +func (s *Service) Fork(ctx context.Context, src *Repository, owner *user.User, name string) (*Repository, error) {
440 + name = strings.TrimSpace(name)
441 + if name == "" {
442 + name = src.Name
443 + }
444 + if err := ValidateRepoName(name); err != nil {
445 + return nil, err
446 + }
447 +
448 + srcPath, err := s.DiskPath(src.OwnerUsername, src.Name)
449 + if err != nil {
450 + return nil, err
451 + }
452 + dstPath, err := s.DiskPath(owner.Username, name)
453 + if err != nil {
454 + return nil, err
455 + }
456 +
457 + cctx, cancel := context.WithTimeout(ctx, 120*time.Second)
458 + defer cancel()
459 +
460 + var exists int
461 + err = s.db.QueryRowContext(cctx, `
462 + SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?)
463 + `, owner.ID, name).Scan(&exists)
464 + if err == nil {
465 + return nil, ErrRepoAlreadyExists
466 + } else if !errors.Is(err, sql.ErrNoRows) {
467 + return nil, fmt.Errorf("check fork name: %w", err)
468 + }
469 +
470 + if err := git.CloneBare(cctx, srcPath, dstPath); err != nil {
471 + return nil, fmt.Errorf("copy repository: %w", err)
472 + }
473 +
474 + now := time.Now().UTC()
475 + srcID := src.ID
476 + res, err := s.db.ExecContext(cctx, `
477 + INSERT INTO repositories (owner_id, name, description, is_private, default_branch,
478 + enable_issues, enable_pulls, enable_releases, enable_activity, forked_from_repo_id, created_at, updated_at)
479 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
480 + `, owner.ID, name, src.Description, boolInt(src.IsPrivate), src.DefaultBranch,
481 + boolInt(src.EnableIssues), boolInt(src.EnablePulls), boolInt(src.EnableReleases), boolInt(src.EnableActivity),
482 + srcID, now, now)
483 + if err != nil {
484 + _ = os.RemoveAll(dstPath)
485 + if strings.Contains(strings.ToLower(err.Error()), "unique") {
486 + return nil, ErrRepoAlreadyExists
487 + }
488 + return nil, fmt.Errorf("insert fork: %w", err)
489 + }
490 +
491 + id, _ := res.LastInsertId()
492 + return &Repository{
493 + ID: id,
494 + OwnerID: owner.ID,
495 + OwnerUsername: owner.Username,
496 + OwnerAvatarURL: owner.AvatarURL,
497 + Name: name,
498 + Description: src.Description,
499 + IsPrivate: src.IsPrivate,
500 + DefaultBranch: src.DefaultBranch,
501 + EnableIssues: src.EnableIssues,
502 + EnablePulls: src.EnablePulls,
503 + EnableReleases: src.EnableReleases,
504 + EnableActivity: src.EnableActivity,
505 + ForkedFromRepoID: &srcID,
506 + ForkedFromOwner: src.OwnerUsername,
507 + ForkedFromName: src.Name,
508 + CreatedAt: now,
509 + UpdatedAt: now,
510 + }, nil
511 +}
512 +
513 +// ListForks returns repositories forked from the given repository.
514 +func (s *Service) ListForks(ctx context.Context, srcRepoID int64, limit int) ([]Repository, error) {
515 + if limit <= 0 || limit > 100 {
516 + limit = 20
517 + }
518 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
519 + defer cancel()
520 +
521 + rows, err := s.db.QueryContext(cctx, repoSelectFields+`
522 + WHERE r.forked_from_repo_id = ?
523 + ORDER BY r.updated_at DESC
524 + LIMIT ?
525 + `, srcRepoID, limit)
526 + if err != nil {
527 + return nil, fmt.Errorf("list forks: %w", err)
528 + }
529 + defer rows.Close()
530 + return scanRepos(rows)
531 +}
532 +
533 +func scanRepo(row *sql.Row) (*Repository, error) {
534 + var r Repository
535 + var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
536 + var forkedFromID sql.NullInt64
537 + var forkedFromOwner, forkedFromName sql.NullString
538 + err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
539 + &enableIssues, &enablePulls, &enableReleases, &enableActivity,
540 + &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
541 + &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
542 + &r.CreatedAt, &r.UpdatedAt)
543 + if err != nil {
544 + if errors.Is(err, sql.ErrNoRows) {
545 + return nil, ErrRepoNotFound
546 + }
547 + return nil, fmt.Errorf("scan repository: %w", err)
548 + }
549 + r.IsPrivate = isPriv == 1
550 + r.EnableIssues = enableIssues == 1
551 + r.EnablePulls = enablePulls == 1
552 + r.EnableReleases = enableReleases == 1
553 + r.EnableActivity = enableActivity == 1
554 + if forkedFromID.Valid {
555 + id := forkedFromID.Int64
556 + r.ForkedFromRepoID = &id
557 + }
558 + r.ForkedFromOwner = forkedFromOwner.String
559 + r.ForkedFromName = forkedFromName.String
560 + return &r, nil
561 +}
562 +
563 +func scanRepos(rows *sql.Rows) ([]Repository, error) {
564 + var list []Repository
565 + for rows.Next() {
566 + var r Repository
567 + var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
568 + var forkedFromID sql.NullInt64
569 + var forkedFromOwner, forkedFromName sql.NullString
570 + if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
571 + &enableIssues, &enablePulls, &enableReleases, &enableActivity,
572 + &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
573 + &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
574 + &r.CreatedAt, &r.UpdatedAt); err != nil {
575 + return nil, fmt.Errorf("scan repository row: %w", err)
576 + }
577 + r.IsPrivate = isPriv == 1
578 + r.EnableIssues = enableIssues == 1
579 + r.EnablePulls = enablePulls == 1
580 + r.EnableReleases = enableReleases == 1
581 + r.EnableActivity = enableActivity == 1
582 + if forkedFromID.Valid {
583 + id := forkedFromID.Int64
584 + r.ForkedFromRepoID = &id
585 + }
586 + r.ForkedFromOwner = forkedFromOwner.String
587 + r.ForkedFromName = forkedFromName.String
588 + list = append(list, r)
589 + }
590 + return list, rows.Err()
591 +}
internal/repo/repo_test.go
+98 -0
... ... @@ -0,0 +1,98 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "os"
6 + "path/filepath"
7 + "testing"
8 +
9 + "polar/internal/db"
10 + "polar/internal/user"
11 +)
12 +
13 +func TestValidateRepoName(t *testing.T) {
14 + valid := []string{"alice", "my-repo_1", "v1.0.0", "x", "core.go"}
15 + for _, n := range valid {
16 + if err := ValidateRepoName(n); err != nil {
17 + t.Errorf("expected repo name %q to be valid, got %v", n, err)
18 + }
19 + }
20 +
21 + invalid := []string{
22 + "", ".", "..", ".hidden", "-dash", "repo.git", "repo.lock",
23 + "repo.", "a..b", "foo/bar", `foo\bar`, "settings", "commits",
24 + "commit", "tree", "blob", "raw", "info", "git-upload-pack",
25 + }
26 + for _, n := range invalid {
27 + if err := ValidateRepoName(n); err == nil {
28 + t.Errorf("expected repo name %q to be rejected", n)
29 + }
30 + }
31 +}
32 +
33 +func TestRepoCRUDAndAccessControl(t *testing.T) {
34 + ctx := context.Background()
35 + tmp := t.TempDir()
36 + database, err := db.Open(ctx, filepath.Join(tmp, "repo.db"), true)
37 + if err != nil {
38 + t.Fatalf("open db: %v", err)
39 + }
40 + defer database.Close()
41 +
42 + userSvc := user.NewService(database)
43 + owner, err := userSvc.Register(ctx, "alice", "alice@example.com", "password123", true)
44 + if err != nil {
45 + t.Fatalf("register owner: %v", err)
46 + }
47 + stranger, err := userSvc.Register(ctx, "bob", "bob@example.com", "password123", true)
48 + if err != nil {
49 + t.Fatalf("register stranger: %v", err)
50 + }
51 +
52 + repoSvc := NewService(database, filepath.Join(tmp, "repos"))
53 +
54 + privRepo, err := repoSvc.Create(ctx, owner, "secret-notes", "Private repo", true)
55 + if err != nil {
56 + t.Fatalf("Create private repo failed: %v", err)
57 + }
58 +
59 + // Verify bare git repository exists on disk
60 + diskPath, err := repoSvc.DiskPath(owner.Username, privRepo.Name)
61 + if err != nil {
62 + t.Fatalf("DiskPath failed: %v", err)
63 + }
64 + if _, err := os.Stat(filepath.Join(diskPath, "HEAD")); err != nil {
65 + t.Fatalf("expected bare repo HEAD on disk: %v", err)
66 + }
67 +
68 + // Private repo access checks
69 + if repoSvc.CanRead(ctx, privRepo, nil) {
70 + t.Fatalf("anonymous user must NOT read private repo")
71 + }
72 + if repoSvc.CanRead(ctx, privRepo, stranger) {
73 + t.Fatalf("other regular user must NOT read private repo")
74 + }
75 + if !repoSvc.CanRead(ctx, privRepo, owner) {
76 + t.Fatalf("owner must read private repo")
77 + }
78 + if !repoSvc.CanWrite(ctx, privRepo, owner) || repoSvc.CanWrite(ctx, privRepo, stranger) {
79 + t.Fatalf("unexpected CanWrite result")
80 + }
81 +
82 + // Rename repository
83 + if err := repoSvc.Rename(ctx, privRepo, "renamed-notes"); err != nil {
84 + t.Fatalf("Rename failed: %v", err)
85 + }
86 + newDiskPath, _ := repoSvc.DiskPath(owner.Username, "renamed-notes")
87 + if _, err := os.Stat(filepath.Join(newDiskPath, "HEAD")); err != nil {
88 + t.Fatalf("expected renamed bare repo on disk: %v", err)
89 + }
90 +
91 + // Delete repository
92 + if err := repoSvc.Delete(ctx, privRepo); err != nil {
93 + t.Fatalf("Delete failed: %v", err)
94 + }
95 + if _, err := os.Stat(newDiskPath); !os.IsNotExist(err) {
96 + t.Fatalf("expected bare repo directory to be deleted from disk")
97 + }
98 +}
internal/repo/reviews.go
+117 -0
... ... @@ -0,0 +1,117 @@
1 +package repo
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "strings"
8 + "time"
9 +)
10 +
11 +const (
12 + ReviewApproved = "approved"
13 + ReviewChangesRequested = "changes_requested"
14 +)
15 +
16 +var ErrInvalidReviewState = errors.New("invalid review state")
17 +
18 +type Review struct {
19 + ID int64
20 + PullID int64
21 + ReviewerID int64
22 + ReviewerUsername string
23 + ReviewerAvatar string
24 + State string
25 + Body string
26 + CreatedAt time.Time
27 + UpdatedAt time.Time
28 +}
29 +
30 +// UpsertReview records or updates the reviewer's verdict on a pull request.
31 +func (s *Service) UpsertReview(ctx context.Context, pullID, reviewerID int64, state, body string) error {
32 + state = strings.TrimSpace(state)
33 + if state != ReviewApproved && state != ReviewChangesRequested {
34 + return ErrInvalidReviewState
35 + }
36 + body = strings.TrimSpace(body)
37 + if len(body) > 10000 {
38 + body = body[:10000]
39 + }
40 +
41 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
42 + defer cancel()
43 +
44 + now := time.Now().UTC()
45 + _, err := s.db.ExecContext(cctx, `
46 + INSERT INTO pull_request_reviews (pull_id, reviewer_id, state, body, created_at, updated_at)
47 + VALUES (?, ?, ?, ?, ?, ?)
48 + ON CONFLICT(pull_id, reviewer_id) DO UPDATE SET
49 + state = excluded.state,
50 + body = excluded.body,
51 + updated_at = excluded.updated_at
52 + `, pullID, reviewerID, state, body, now, now)
53 + if err != nil {
54 + return fmt.Errorf("upsert review: %w", err)
55 + }
56 + return nil
57 +}
58 +
59 +// ListReviews returns all reviews left on a pull request, oldest first.
60 +func (s *Service) ListReviews(ctx context.Context, pullID int64) ([]Review, error) {
61 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
62 + defer cancel()
63 +
64 + rows, err := s.db.QueryContext(cctx, `
65 + SELECT rv.id, rv.pull_id, rv.reviewer_id, u.username, u.avatar_url, rv.state, rv.body, rv.created_at, rv.updated_at
66 + FROM pull_request_reviews rv
67 + JOIN users u ON u.id = rv.reviewer_id
68 + WHERE rv.pull_id = ?
69 + ORDER BY rv.updated_at ASC
70 + `, pullID)
71 + if err != nil {
72 + return nil, fmt.Errorf("list reviews: %w", err)
73 + }
74 + defer rows.Close()
75 +
76 + var list []Review
77 + for rows.Next() {
78 + var rv Review
79 + if err := rows.Scan(&rv.ID, &rv.PullID, &rv.ReviewerID, &rv.ReviewerUsername, &rv.ReviewerAvatar,
80 + &rv.State, &rv.Body, &rv.CreatedAt, &rv.UpdatedAt); err != nil {
81 + return nil, fmt.Errorf("scan review: %w", err)
82 + }
83 + list = append(list, rv)
84 + }
85 + return list, rows.Err()
86 +}
87 +
88 +// GetReview returns the review left by a specific user, if any.
89 +func (s *Service) GetReview(ctx context.Context, pullID, reviewerID int64) (*Review, error) {
90 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
91 + defer cancel()
92 +
93 + var rv Review
94 + err := s.db.QueryRowContext(cctx, `
95 + SELECT rv.id, rv.pull_id, rv.reviewer_id, u.username, u.avatar_url, rv.state, rv.body, rv.created_at, rv.updated_at
96 + FROM pull_request_reviews rv
97 + JOIN users u ON u.id = rv.reviewer_id
98 + WHERE rv.pull_id = ? AND rv.reviewer_id = ?
99 + `, pullID, reviewerID).Scan(&rv.ID, &rv.PullID, &rv.ReviewerID, &rv.ReviewerUsername, &rv.ReviewerAvatar,
100 + &rv.State, &rv.Body, &rv.CreatedAt, &rv.UpdatedAt)
101 + if err != nil {
102 + return nil, err
103 + }
104 + return &rv, nil
105 +}
106 +
107 +// ReviewCounts returns how many reviewers approved and how many requested changes.
108 +func (s *Service) ReviewCounts(ctx context.Context, pullID int64) (approved int, changesRequested int) {
109 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
110 + defer cancel()
111 +
112 + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_request_reviews WHERE pull_id = ? AND state = ?`,
113 + pullID, ReviewApproved).Scan(&approved)
114 + _ = s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM pull_request_reviews WHERE pull_id = ? AND state = ?`,
115 + pullID, ReviewChangesRequested).Scan(&changesRequested)
116 + return approved, changesRequested
117 +}
internal/user/user.go
+370 -0
... ... @@ -0,0 +1,370 @@
1 +package user
2 +
3 +import (
4 + "context"
5 + "database/sql"
6 + "errors"
7 + "fmt"
8 + "net/mail"
9 + "regexp"
10 + "strings"
11 + "time"
12 +
13 + "polar/internal/auth"
14 + "polar/internal/db"
15 +)
16 +
17 +var (
18 + ErrUserNotFound = errors.New("user not found")
19 + ErrUserAlreadyExists = errors.New("username or email already taken")
20 + ErrRegistrationDisabled = errors.New("public registration is disabled")
21 + ErrInvalidUsername = errors.New("username must be 2–32 alphanumeric characters or single hyphens/underscores")
22 + ErrReservedUsername = errors.New("this username is reserved by the system")
23 + ErrInvalidEmail = errors.New("invalid email address")
24 + ErrWeakPassword = errors.New("password must be at least 8 characters")
25 +)
26 +
27 +var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$`)
28 +
29 +var reservedUsernames = map[string]struct{}{
30 + "login": {}, "register": {}, "logout": {}, "settings": {}, "admin": {},
31 + "new": {}, "explore": {}, "repos": {}, "api": {}, "static": {},
32 + "assets": {}, "healthz": {}, "_ui": {}, "git": {}, "raw": {},
33 + "tree": {}, "blob": {}, "commits": {}, "commit": {}, "branches": {},
34 + "tags": {}, "user": {}, "users": {}, "org": {}, "orgs": {},
35 + "help": {}, "about": {}, "pricing": {}, "security": {}, "terms": {},
36 + "privacy": {}, "favicon.ico": {}, "robots.txt": {}, "search": {},
37 + "avatars": {}, "issues": {}, "archive": {},
38 +}
39 +
40 +type User struct {
41 + ID int64
42 + Username string
43 + Email string
44 + PasswordHash string
45 + FullName string
46 + Bio string
47 + AvatarURL string
48 + Location string
49 + Website string
50 + IsAdmin bool
51 + RepoCount int
52 + CreatedAt time.Time
53 +}
54 +
55 +type Service struct {
56 + db *db.DB
57 +}
58 +
59 +func NewService(database *db.DB) *Service {
60 + return &Service{db: database}
61 +}
62 +
63 +func ValidateUsername(username string) error {
64 + username = strings.TrimSpace(username)
65 + if !usernameRegex.MatchString(username) {
66 + return ErrInvalidUsername
67 + }
68 + lower := strings.ToLower(username)
69 + if _, reserved := reservedUsernames[lower]; reserved {
70 + return ErrReservedUsername
71 + }
72 + if strings.HasSuffix(lower, ".git") || strings.Contains(lower, "..") {
73 + return ErrInvalidUsername
74 + }
75 + return nil
76 +}
77 +
78 +func ValidateEmail(email string) error {
79 + email = strings.TrimSpace(email)
80 + if len(email) < 3 || len(email) > 254 {
81 + return ErrInvalidEmail
82 + }
83 + addr, err := mail.ParseAddress(email)
84 + if err != nil || addr.Address != email || !strings.Contains(email, "@") {
85 + return ErrInvalidEmail
86 + }
87 + return nil
88 +}
89 +
90 +func (s *Service) Count(ctx context.Context) (int, error) {
91 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
92 + defer cancel()
93 + var count int
94 + if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
95 + return 0, fmt.Errorf("count users: %w", err)
96 + }
97 + return count, nil
98 +}
99 +
100 +func (s *Service) Register(ctx context.Context, username, email, password string, regEnabled bool) (*User, error) {
101 + username = strings.TrimSpace(username)
102 + email = strings.ToLower(strings.TrimSpace(email))
103 +
104 + if err := ValidateUsername(username); err != nil {
105 + return nil, err
106 + }
107 + if err := ValidateEmail(email); err != nil {
108 + return nil, err
109 + }
110 + if len(password) < 8 {
111 + return nil, ErrWeakPassword
112 + }
113 +
114 + hash, err := auth.HashPassword(password)
115 + if err != nil {
116 + return nil, fmt.Errorf("hash password: %w", err)
117 + }
118 +
119 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
120 + defer cancel()
121 +
122 + tx, err := s.db.BeginTx(cctx, nil)
123 + if err != nil {
124 + return nil, fmt.Errorf("begin register tx: %w", err)
125 + }
126 + defer func() { _ = tx.Rollback() }()
127 +
128 + var userCount int
129 + if err := tx.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
130 + return nil, fmt.Errorf("count existing users: %w", err)
131 + }
132 +
133 + if userCount > 0 && !regEnabled {
134 + return nil, ErrRegistrationDisabled
135 + }
136 +
137 + var existing int
138 + err = tx.QueryRowContext(cctx, `
139 + SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)
140 + `, username, email).Scan(&existing)
141 + if err == nil {
142 + return nil, ErrUserAlreadyExists
143 + } else if !errors.Is(err, sql.ErrNoRows) {
144 + return nil, fmt.Errorf("check existing user: %w", err)
145 + }
146 +
147 + isAdmin := userCount == 0
148 + isAdminInt := 0
149 + if isAdmin {
150 + isAdminInt = 1
151 + }
152 + now := time.Now().UTC()
153 +
154 + res, err := tx.ExecContext(cctx, `
155 + INSERT INTO users (username, email, password_hash, bio, is_admin, created_at)
156 + VALUES (?, ?, ?, '', ?, ?)
157 + `, username, email, hash, isAdminInt, now)
158 + if err != nil {
159 + if strings.Contains(strings.ToLower(err.Error()), "unique") {
160 + return nil, ErrUserAlreadyExists
161 + }
162 + return nil, fmt.Errorf("insert user: %w", err)
163 + }
164 +
165 + id, _ := res.LastInsertId()
166 + if err := tx.Commit(); err != nil {
167 + return nil, fmt.Errorf("commit register tx: %w", err)
168 + }
169 +
170 + return &User{
171 + ID: id,
172 + Username: username,
173 + Email: email,
174 + PasswordHash: hash,
175 + IsAdmin: isAdmin,
176 + CreatedAt: now,
177 + }, nil
178 +}
179 +
180 +func (s *Service) Authenticate(ctx context.Context, login, password string) (*User, error) {
181 + login = strings.TrimSpace(login)
182 + if login == "" || password == "" {
183 + return nil, auth.ErrInvalidCredentials
184 + }
185 +
186 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
187 + defer cancel()
188 +
189 + u, err := s.scanUser(s.db.QueryRowContext(cctx, `
190 + SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
191 + FROM users
192 + WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)
193 + `, login, login))
194 + if err != nil {
195 + if errors.Is(err, ErrUserNotFound) {
196 + return nil, auth.ErrInvalidCredentials
197 + }
198 + return nil, err
199 + }
200 +
201 + ok, err := auth.VerifyPassword(password, u.PasswordHash)
202 + if err != nil || !ok {
203 + return nil, auth.ErrInvalidCredentials
204 + }
205 + return u, nil
206 +}
207 +
208 +func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) {
209 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
210 + defer cancel()
211 +
212 + return s.scanUser(s.db.QueryRowContext(cctx, `
213 + SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
214 + FROM users WHERE id = ?
215 + `, id))
216 +}
217 +
218 +func (s *Service) GetByUsername(ctx context.Context, username string) (*User, error) {
219 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
220 + defer cancel()
221 +
222 + return s.scanUser(s.db.QueryRowContext(cctx, `
223 + SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
224 + FROM users WHERE LOWER(username) = LOWER(?)
225 + `, strings.TrimSpace(username)))
226 +}
227 +
228 +func (s *Service) UpdateProfile(ctx context.Context, userID int64, email, bio string) error {
229 + u, err := s.GetByID(ctx, userID)
230 + if err != nil {
231 + return err
232 + }
233 + return s.UpdateProfileDetails(ctx, userID, email, u.FullName, bio, u.Location, u.Website)
234 +}
235 +
236 +func (s *Service) UpdateProfileDetails(ctx context.Context, userID int64, email, fullName, bio, location, website string) error {
237 + email = strings.ToLower(strings.TrimSpace(email))
238 + fullName = strings.TrimSpace(fullName)
239 + bio = strings.TrimSpace(bio)
240 + location = strings.TrimSpace(location)
241 + website = strings.TrimSpace(website)
242 +
243 + if len(fullName) > 80 {
244 + fullName = fullName[:80]
245 + }
246 + if len(bio) > 280 {
247 + bio = bio[:280]
248 + }
249 + if len(location) > 80 {
250 + location = location[:80]
251 + }
252 + if len(website) > 200 {
253 + website = website[:200]
254 + }
255 + if website != "" && !strings.HasPrefix(website, "http://") && !strings.HasPrefix(website, "https://") {
256 + website = "https://" + website
257 + }
258 + if err := ValidateEmail(email); err != nil {
259 + return err
260 + }
261 +
262 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
263 + defer cancel()
264 +
265 + _, err := s.db.ExecContext(cctx, `
266 + UPDATE users SET email = ?, full_name = ?, bio = ?, location = ?, website = ? WHERE id = ?
267 + `, email, fullName, bio, location, website, userID)
268 + if err != nil {
269 + if strings.Contains(strings.ToLower(err.Error()), "unique") {
270 + return ErrUserAlreadyExists
271 + }
272 + return fmt.Errorf("update profile: %w", err)
273 + }
274 + return nil
275 +}
276 +
277 +func (s *Service) UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error {
278 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
279 + defer cancel()
280 +
281 + _, err := s.db.ExecContext(cctx, `
282 + UPDATE users SET avatar_url = ? WHERE id = ?
283 + `, strings.TrimSpace(avatarURL), userID)
284 + if err != nil {
285 + return fmt.Errorf("update avatar: %w", err)
286 + }
287 + return nil
288 +}
289 +
290 +func (s *Service) UpdatePassword(ctx context.Context, userID int64, currentPassword, newPassword string) error {
291 + if len(newPassword) < 8 {
292 + return ErrWeakPassword
293 + }
294 + u, err := s.GetByID(ctx, userID)
295 + if err != nil {
296 + return err
297 + }
298 + ok, err := auth.VerifyPassword(currentPassword, u.PasswordHash)
299 + if err != nil || !ok {
300 + return auth.ErrInvalidCredentials
301 + }
302 + newHash, err := auth.HashPassword(newPassword)
303 + if err != nil {
304 + return fmt.Errorf("hash new password: %w", err)
305 + }
306 +
307 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
308 + defer cancel()
309 +
310 + _, err = s.db.ExecContext(cctx, `UPDATE users SET password_hash = ? WHERE id = ?`, newHash, userID)
311 + if err != nil {
312 + return fmt.Errorf("update password: %w", err)
313 + }
314 + return nil
315 +}
316 +
317 +func (s *Service) ListAll(ctx context.Context) ([]User, error) {
318 + cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
319 + defer cancel()
320 +
321 + rows, err := s.db.QueryContext(cctx, `
322 + SELECT u.id, u.username, u.email, u.password_hash, u.full_name, u.bio, u.avatar_url, u.location, u.website, u.is_admin, u.created_at,
323 + COUNT(r.id) AS repo_count
324 + FROM users u
325 + LEFT JOIN repositories r ON r.owner_id = u.id
326 + GROUP BY u.id
327 + ORDER BY u.created_at ASC
328 + `)
329 + if err != nil {
330 + return nil, fmt.Errorf("list users: %w", err)
331 + }
332 + defer rows.Close()
333 +
334 + var users []User
335 + for rows.Next() {
336 + var u User
337 + var isAdmin int
338 + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt, &u.RepoCount); err != nil {
339 + return nil, fmt.Errorf("scan user: %w", err)
340 + }
341 + u.IsAdmin = isAdmin == 1
342 + users = append(users, u)
343 + }
344 + return users, rows.Err()
345 +}
346 +
347 +func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) error {
348 + cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
349 + defer cancel()
350 + val := 0
351 + if isAdmin {
352 + val = 1
353 + }
354 + _, err := s.db.ExecContext(cctx, `UPDATE users SET is_admin = ? WHERE id = ?`, val, userID)
355 + return err
356 +}
357 +
358 +func (s *Service) scanUser(row *sql.Row) (*User, error) {
359 + var u User
360 + var isAdmin int
361 + err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt)
362 + if err != nil {
363 + if errors.Is(err, sql.ErrNoRows) {
364 + return nil, ErrUserNotFound
365 + }
366 + return nil, fmt.Errorf("scan user: %w", err)
367 + }
368 + u.IsAdmin = isAdmin == 1
369 + return &u, nil
370 +}
internal/user/user_test.go
+76 -0
... ... @@ -0,0 +1,76 @@
1 +package user
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "path/filepath"
7 + "testing"
8 +
9 + "polar/internal/db"
10 +)
11 +
12 +func TestValidateUsername(t *testing.T) {
13 + valid := []string{"alice", "rin", "dev_01", "git-user-99", "ab"}
14 + for _, u := range valid {
15 + if err := ValidateUsername(u); err != nil {
16 + t.Errorf("expected username %q to be valid, got %v", u, err)
17 + }
18 + }
19 +
20 + invalid := []string{
21 + "", "a", "-alice", "alice-", "alice..dev", "../etc", "foo/bar",
22 + `foo\bar`, "repo.git", "admin", "login", "register", "settings",
23 + "_ui", "healthz", "explore", "alice--user",
24 + }
25 + for _, u := range invalid {
26 + if err := ValidateUsername(u); err == nil {
27 + t.Errorf("expected username %q to be rejected", u)
28 + }
29 + }
30 +}
31 +
32 +func TestFirstUserAdminAndRegistrationToggle(t *testing.T) {
33 + ctx := context.Background()
34 + database, err := db.Open(ctx, filepath.Join(t.TempDir(), "users.db"), true)
35 + if err != nil {
36 + t.Fatalf("open db: %v", err)
37 + }
38 + defer database.Close()
39 +
40 + svc := NewService(database)
41 +
42 + // First user becomes admin even if regEnabled is false
43 + first, err := svc.Register(ctx, "alice", "alice@example.com", "supersecret123", false)
44 + if err != nil {
45 + t.Fatalf("Register first user failed: %v", err)
46 + }
47 + if !first.IsAdmin {
48 + t.Fatalf("expected first registered user to be admin")
49 + }
50 +
51 + // Second user when regEnabled is false must fail
52 + _, err = svc.Register(ctx, "bob", "bob@example.com", "supersecret123", false)
53 + if !errors.Is(err, ErrRegistrationDisabled) {
54 + t.Fatalf("expected ErrRegistrationDisabled, got %v", err)
55 + }
56 +
57 + // Second user when regEnabled is true succeeds and is NOT admin
58 + second, err := svc.Register(ctx, "bob", "bob@example.com", "supersecret123", true)
59 + if err != nil {
60 + t.Fatalf("Register second user failed: %v", err)
61 + }
62 + if second.IsAdmin {
63 + t.Fatalf("expected second user NOT to be admin")
64 + }
65 +
66 + // Authenticate by username and email
67 + if _, err := svc.Authenticate(ctx, "alice", "supersecret123"); err != nil {
68 + t.Fatalf("Authenticate by username failed: %v", err)
69 + }
70 + if _, err := svc.Authenticate(ctx, "ALICE@example.com", "supersecret123"); err != nil {
71 + t.Fatalf("Authenticate by email failed: %v", err)
72 + }
73 + if _, err := svc.Authenticate(ctx, "alice", "wrongpassword"); err == nil {
74 + t.Fatalf("expected error for wrong password")
75 + }
76 +}
internal/web/handlers/activity.go
+31 -0
... ... @@ -0,0 +1,31 @@
1 +package handlers
2 +
3 +import (
4 + "github.com/gofiber/fiber/v2"
5 +
6 + "polar/internal/web/middleware"
7 +)
8 +
9 +func (h *Handler) RepoActivityPage(c *fiber.Ctx) error {
10 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
11 + if err != nil {
12 + return nil
13 + }
14 + if !r.EnableActivity {
15 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.activity_disabled")
16 + }
17 +
18 + items, stats, _ := h.repoSvc.GetRepoActivity(c.UserContext(), r, diskPath)
19 +
20 + viewer := middleware.CurrentUser(c)
21 + data := h.baseData(c, h.tr(c, "activity.title")+" · "+r.OwnerUsername+"/"+r.Name)
22 + data["Repo"] = r
23 + data["Ref"] = r.DefaultBranch
24 + data["ActiveTab"] = "activity"
25 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
26 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
27 + data["Items"] = items
28 + data["Stats"] = stats
29 +
30 + return h.render(c, fiber.StatusOK, "activity", data)
31 +}
internal/web/handlers/auth.go
+273 -0
... ... @@ -0,0 +1,273 @@
1 +package handlers
2 +
3 +import (
4 + "encoding/base64"
5 + "strconv"
6 + "strings"
7 +
8 + "polar/internal/auth"
9 + "polar/internal/i18n"
10 + "polar/internal/web/middleware"
11 +
12 + "github.com/gofiber/fiber/v2"
13 +)
14 +
15 +func (h *Handler) LoginPage(c *fiber.Ctx) error {
16 + if middleware.CurrentUser(c) != nil {
17 + return c.Redirect("/repos", fiber.StatusSeeOther)
18 + }
19 + lang, _ := c.Locals("lang").(i18n.Lang)
20 + data := h.baseData(c, i18n.T(lang, "nav.login"))
21 + data["Next"] = safeNextPath(c.Query("next"))
22 + return h.render(c, fiber.StatusOK, "login", data)
23 +}
24 +
25 +func (h *Handler) LoginSubmit(c *fiber.Ctx) error {
26 + lang, _ := c.Locals("lang").(i18n.Lang)
27 + login := strings.TrimSpace(c.FormValue("login"))
28 + password := c.FormValue("password")
29 +
30 + u, err := h.userSvc.Authenticate(c.UserContext(), login, password)
31 + if err != nil {
32 + data := h.baseData(c, i18n.T(lang, "nav.login"))
33 + data["FormLogin"] = login
34 + data["Next"] = safeNextPath(c.FormValue("next"))
35 + data["Error"] = i18n.T(lang, "auth.invalid_creds")
36 + return h.render(c, fiber.StatusUnauthorized, "login", data)
37 + }
38 +
39 + sess, err := h.authSvc.CreateSession(c.UserContext(), u.ID)
40 + if err != nil {
41 + h.logger.Error("create session failed", "err", err)
42 + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.session_error", "err.session_create")
43 + }
44 +
45 + c.Cookie(&fiber.Cookie{
46 + Name: middleware.SessionCookieName,
47 + Value: sess.ID,
48 + Path: "/",
49 + Expires: sess.ExpiresAt,
50 + HTTPOnly: true,
51 + SameSite: "Lax",
52 + })
53 +
54 + if next := safeNextPath(c.FormValue("next")); next != "" {
55 + return c.Redirect(next, fiber.StatusSeeOther)
56 + }
57 + return c.Redirect("/repos", fiber.StatusSeeOther)
58 +}
59 +
60 +// safeNextPath returns a same-site path for post-login redirects.
61 +func safeNextPath(next string) string {
62 + next = strings.TrimSpace(next)
63 + if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
64 + return ""
65 + }
66 + return next
67 +}
68 +
69 +func (h *Handler) RegisterPage(c *fiber.Ctx) error {
70 + if middleware.CurrentUser(c) != nil {
71 + return c.Redirect("/repos", fiber.StatusSeeOther)
72 + }
73 + lang, _ := c.Locals("lang").(i18n.Lang)
74 + data := h.baseData(c, i18n.T(lang, "nav.register"))
75 + return h.render(c, fiber.StatusOK, "register", data)
76 +}
77 +
78 +func (h *Handler) RegisterSubmit(c *fiber.Ctx) error {
79 + lang, _ := c.Locals("lang").(i18n.Lang)
80 + username := strings.TrimSpace(c.FormValue("username"))
81 + email := strings.TrimSpace(c.FormValue("email"))
82 + password := c.FormValue("password")
83 +
84 + regAllowed := h.isRegistrationAllowed(c)
85 + u, err := h.userSvc.Register(c.UserContext(), username, email, password, regAllowed)
86 + if err != nil {
87 + data := h.baseData(c, i18n.T(lang, "nav.register"))
88 + data["FormUsername"] = username
89 + data["FormEmail"] = email
90 + data["Error"] = err.Error()
91 + return h.render(c, fiber.StatusBadRequest, "register", data)
92 + }
93 +
94 + sess, err := h.authSvc.CreateSession(c.UserContext(), u.ID)
95 + if err != nil {
96 + h.logger.Error("create session after register failed", "err", err)
97 + return c.Redirect("/login", fiber.StatusSeeOther)
98 + }
99 +
100 + c.Cookie(&fiber.Cookie{
101 + Name: middleware.SessionCookieName,
102 + Value: sess.ID,
103 + Path: "/",
104 + Expires: sess.ExpiresAt,
105 + HTTPOnly: true,
106 + SameSite: "Lax",
107 + })
108 +
109 + h.setFlashT(c, "flash.welcome", u.Username)
110 + return c.Redirect("/repos", fiber.StatusSeeOther)
111 +}
112 +
113 +func (h *Handler) LogoutSubmit(c *fiber.Ctx) error {
114 + sessID := c.Cookies(middleware.SessionCookieName)
115 + if sessID != "" {
116 + _ = h.authSvc.DeleteSession(c.UserContext(), sessID)
117 + }
118 + c.ClearCookie(middleware.SessionCookieName)
119 + return c.Redirect("/", fiber.StatusSeeOther)
120 +}
121 +
122 +func (h *Handler) SettingsPage(c *fiber.Ctx) error {
123 + u := middleware.CurrentUser(c)
124 + lang, _ := c.Locals("lang").(i18n.Lang)
125 + data := h.baseData(c, i18n.T(lang, "settings.title"))
126 +
127 + tokens, err := h.authSvc.ListTokens(c.UserContext(), u.ID)
128 + if err != nil {
129 + h.logger.Error("list tokens failed", "err", err)
130 + }
131 + data["Tokens"] = tokens
132 +
133 + newToken := c.Cookies("polar_new_token")
134 + if newToken != "" {
135 + c.ClearCookie("polar_new_token")
136 + data["NewRawToken"] = newToken
137 + }
138 +
139 + return h.render(c, fiber.StatusOK, "settings", data)
140 +}
141 +
142 +func (h *Handler) UpdateProfileSubmit(c *fiber.Ctx) error {
143 + u := middleware.CurrentUser(c)
144 + email := c.FormValue("email")
145 + fullName := c.FormValue("full_name")
146 + bio := c.FormValue("bio")
147 + location := c.FormValue("location")
148 + website := c.FormValue("website")
149 +
150 + if err := h.userSvc.UpdateProfileDetails(c.UserContext(), u.ID, email, fullName, bio, location, website); err != nil {
151 + lang, _ := c.Locals("lang").(i18n.Lang)
152 + data := h.baseData(c, i18n.T(lang, "settings.title"))
153 + tokens, _ := h.authSvc.ListTokens(c.UserContext(), u.ID)
154 + data["Tokens"] = tokens
155 + data["Error"] = err.Error()
156 + return h.render(c, fiber.StatusBadRequest, "settings", data)
157 + }
158 +
159 + if c.FormValue("remove_avatar") == "true" {
160 + _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, "")
161 + } else if avatarData := strings.TrimSpace(c.FormValue("avatar_data")); avatarData != "" && strings.HasPrefix(avatarData, "data:image/") {
162 + _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, avatarData)
163 + } else if file, err := c.FormFile("avatar_file"); err == nil && file != nil && file.Size > 0 && file.Size <= 2*1024*1024 {
164 + if f, err := file.Open(); err == nil {
165 + defer f.Close()
166 + buf := make([]byte, file.Size)
167 + if _, err := f.Read(buf); err == nil {
168 + mime := file.Header.Get("Content-Type")
169 + if mime == "" {
170 + mime = "image/png"
171 + }
172 + dataURI := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(buf)
173 + _ = h.userSvc.UpdateAvatar(c.UserContext(), u.ID, dataURI)
174 + }
175 + }
176 + }
177 +
178 + h.setFlashT(c, "flash.profile_updated")
179 + return c.Redirect("/settings", fiber.StatusSeeOther)
180 +}
181 +
182 +func (h *Handler) UpdatePasswordSubmit(c *fiber.Ctx) error {
183 + u := middleware.CurrentUser(c)
184 + currentPwd := c.FormValue("current_password")
185 + newPwd := c.FormValue("new_password")
186 +
187 + if err := h.userSvc.UpdatePassword(c.UserContext(), u.ID, currentPwd, newPwd); err != nil {
188 + lang, _ := c.Locals("lang").(i18n.Lang)
189 + data := h.baseData(c, i18n.T(lang, "settings.title"))
190 + tokens, _ := h.authSvc.ListTokens(c.UserContext(), u.ID)
191 + data["Tokens"] = tokens
192 + data["Error"] = err.Error()
193 + return h.render(c, fiber.StatusBadRequest, "settings", data)
194 + }
195 +
196 + h.setFlashT(c, "flash.password_updated")
197 + return c.Redirect("/settings", fiber.StatusSeeOther)
198 +}
199 +
200 +func (h *Handler) CreateTokenSubmit(c *fiber.Ctx) error {
201 + u := middleware.CurrentUser(c)
202 + name := c.FormValue("name")
203 +
204 + rawToken, _, err := h.authSvc.CreateToken(c.UserContext(), u.ID, name)
205 + if err != nil {
206 + lang, _ := c.Locals("lang").(i18n.Lang)
207 + data := h.baseData(c, i18n.T(lang, "settings.title"))
208 + tokens, _ := h.authSvc.ListTokens(c.UserContext(), u.ID)
209 + data["Tokens"] = tokens
210 + data["Error"] = err.Error()
211 + return h.render(c, fiber.StatusBadRequest, "settings", data)
212 + }
213 +
214 + c.Cookie(&fiber.Cookie{
215 + Name: "polar_new_token",
216 + Value: rawToken,
217 + Path: "/settings",
218 + MaxAge: 60,
219 + HTTPOnly: true,
220 + SameSite: "Lax",
221 + })
222 + return c.Redirect("/settings#tokens", fiber.StatusSeeOther)
223 +}
224 +
225 +func (h *Handler) RevokeTokenSubmit(c *fiber.Ctx) error {
226 + u := middleware.CurrentUser(c)
227 + tokenID, err := strconv.ParseInt(c.Params("id"), 10, 64)
228 + if err != nil {
229 + return c.Redirect("/settings#tokens", fiber.StatusSeeOther)
230 + }
231 + _ = h.authSvc.RevokeToken(c.UserContext(), u.ID, tokenID)
232 + h.setFlashT(c, "flash.token_revoked")
233 + return c.Redirect("/settings#tokens", fiber.StatusSeeOther)
234 +}
235 +
236 +func (h *Handler) AdminPage(c *fiber.Ctx) error {
237 + lang, _ := c.Locals("lang").(i18n.Lang)
238 + data := h.baseData(c, i18n.T(lang, "admin.title"))
239 + users, err := h.userSvc.ListAll(c.UserContext())
240 + if err != nil {
241 + h.logger.Error("list users failed", "err", err)
242 + }
243 + data["Users"] = users
244 + return h.render(c, fiber.StatusOK, "admin", data)
245 +}
246 +
247 +func (h *Handler) AdminSettingsSubmit(c *fiber.Ctx) error {
248 + key := c.FormValue("setting_key")
249 + val := c.FormValue("setting_val")
250 +
251 + // Fallback for legacy registration_enabled direct form
252 + if key == "" {
253 + if rVal := c.FormValue("registration_enabled"); rVal != "" {
254 + key = "registration_enabled"
255 + val = rVal
256 + }
257 + }
258 +
259 + if key == "" {
260 + return c.Redirect("/admin", fiber.StatusSeeOther)
261 + }
262 +
263 + enabled := val == "true" || val == "1" || val == "on"
264 + if err := h.db.SetBoolSetting(c.UserContext(), key, enabled); err != nil {
265 + h.logger.Error("update admin setting failed", "key", key, "err", err)
266 + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.settings_error", "err.settings_save")
267 + }
268 +
269 + h.setFlashT(c, "flash.settings_updated")
270 + return c.Redirect("/admin", fiber.StatusSeeOther)
271 +}
272 +
273 +var _ = auth.ErrInvalidCredentials
internal/web/handlers/collaborators.go
+55 -0
... ... @@ -0,0 +1,55 @@
1 +package handlers
2 +
3 +import (
4 + "fmt"
5 + "strconv"
6 + "strings"
7 +
8 + "github.com/gofiber/fiber/v2"
9 +
10 + "polar/internal/web/middleware"
11 +)
12 +
13 +// RepoCollaboratorAddSubmit grants a user access to the repository.
14 +func (h *Handler) RepoCollaboratorAddSubmit(c *fiber.Ctx) error {
15 + r, _, err := h.loadAuthorizedRepo(c, true)
16 + if err != nil {
17 + return nil
18 + }
19 + viewer := middleware.CurrentUser(c)
20 + if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) {
21 + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden")
22 + }
23 +
24 + username := strings.TrimSpace(c.FormValue("username"))
25 + role := strings.TrimSpace(c.FormValue("role"))
26 +
27 + if err := h.repoSvc.AddCollaborator(c.UserContext(), r, username, role); err != nil {
28 + h.setFlashT(c, "flash.collab_failed", err.Error())
29 + } else {
30 + h.setFlashT(c, "flash.collab_added", username)
31 + }
32 + return c.Redirect(fmt.Sprintf("/%s/%s/settings#collaborators", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
33 +}
34 +
35 +// RepoCollaboratorRemoveSubmit revokes a user's access to the repository.
36 +func (h *Handler) RepoCollaboratorRemoveSubmit(c *fiber.Ctx) error {
37 + r, _, err := h.loadAuthorizedRepo(c, true)
38 + if err != nil {
39 + return nil
40 + }
41 + viewer := middleware.CurrentUser(c)
42 + if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) {
43 + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden")
44 + }
45 +
46 + userID, _ := strconv.ParseInt(strings.TrimSpace(c.FormValue("user_id")), 10, 64)
47 + if userID > 0 {
48 + if err := h.repoSvc.RemoveCollaborator(c.UserContext(), r.ID, userID); err != nil {
49 + h.setFlashT(c, "flash.collab_failed", err.Error())
50 + } else {
51 + h.setFlashT(c, "flash.collab_removed")
52 + }
53 + }
54 + return c.Redirect(fmt.Sprintf("/%s/%s/settings#collaborators", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
55 +}
internal/web/handlers/device.go
+171 -0
... ... @@ -0,0 +1,171 @@
1 +package handlers
2 +
3 +import (
4 + "strings"
5 + "time"
6 +
7 + "github.com/gofiber/fiber/v2"
8 +
9 + "polar/internal/auth"
10 + "polar/internal/web/middleware"
11 +)
12 +
13 +func (h *Handler) siteURL(c *fiber.Ctx) string {
14 + base := strings.TrimRight(h.cfg.Server.BaseURL, "/")
15 + if base == "" || strings.Contains(base, "localhost:3000") {
16 + if reqBase := c.BaseURL(); reqBase != "" {
17 + base = strings.TrimRight(reqBase, "/")
18 + }
19 + }
20 + return base
21 +}
22 +
23 +// DeviceCodeAPI starts a device authorization grant (RFC 8628).
24 +// POST /api/v1/device/code
25 +func (h *Handler) DeviceCodeAPI(c *fiber.Ctx) error {
26 + clientID := strings.TrimSpace(c.FormValue("client_id"))
27 + if clientID == "" {
28 + var payload struct {
29 + ClientID string `json:"client_id"`
30 + }
31 + if err := c.BodyParser(&payload); err == nil {
32 + clientID = strings.TrimSpace(payload.ClientID)
33 + }
34 + }
35 + if clientID == "" {
36 + clientID = "polar-git"
37 + }
38 +
39 + dc, err := h.authSvc.CreateDeviceCode(c.UserContext(), clientID)
40 + if err != nil {
41 + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "server_error"})
42 + }
43 +
44 + base := h.siteURL(c)
45 + return c.JSON(fiber.Map{
46 + "device_code": dc.Code,
47 + "user_code": dc.UserCode,
48 + "verification_uri": base + "/login/device",
49 + "verification_uri_complete": base + "/login/device?user_code=" + dc.UserCode,
50 + "expires_in": int(auth.DeviceCodeTTL.Seconds()),
51 + "interval": auth.DeviceCodeInterval,
52 + })
53 +}
54 +
55 +// DeviceTokenAPI exchanges an approved device code for an access token.
56 +// POST /api/v1/device/token
57 +func (h *Handler) DeviceTokenAPI(c *fiber.Ctx) error {
58 + deviceCode := strings.TrimSpace(c.FormValue("device_code"))
59 + if deviceCode == "" {
60 + var payload struct {
61 + DeviceCode string `json:"device_code"`
62 + }
63 + if err := c.BodyParser(&payload); err == nil {
64 + deviceCode = strings.TrimSpace(payload.DeviceCode)
65 + }
66 + }
67 +
68 + dc, err := h.authSvc.PollDeviceCode(c.UserContext(), deviceCode)
69 + if err != nil {
70 + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid_grant"})
71 + }
72 +
73 + switch dc.Status {
74 + case auth.DeviceStatusPending:
75 + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "authorization_pending"})
76 + case auth.DeviceStatusDenied:
77 + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "access_denied"})
78 + case auth.DeviceStatusApproved:
79 + if dc.UserID == nil {
80 + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid_grant"})
81 + }
82 + name := "Device flow"
83 + if dc.ClientID != "" {
84 + name = "Device flow (" + dc.ClientID + ")"
85 + }
86 + raw, _, err := h.authSvc.CreateToken(c.UserContext(), *dc.UserID, name)
87 + if err != nil {
88 + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "server_error"})
89 + }
90 + h.authSvc.ConsumeDeviceCode(c.UserContext(), dc.Code)
91 + username := ""
92 + if u, err := h.userSvc.GetByID(c.UserContext(), *dc.UserID); err == nil && u != nil {
93 + username = u.Username
94 + }
95 + return c.JSON(fiber.Map{
96 + "access_token": raw,
97 + "token_type": "bearer",
98 + "username": username,
99 + "scope": "git",
100 + })
101 + default:
102 + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"})
103 + }
104 +}
105 +
106 +// DeviceVerifyPage renders the device approval page.
107 +// GET /login/device
108 +func (h *Handler) DeviceVerifyPage(c *fiber.Ctx) error {
109 + viewer := middleware.CurrentUser(c)
110 + userCode := auth.NormalizeUserCode(c.Query("user_code"))
111 +
112 + data := h.baseData(c, h.tr(c, "device.title"))
113 + data["UserCode"] = userCode
114 + data["SignedIn"] = viewer != nil
115 +
116 + if userCode != "" && len(userCode) == 9 {
117 + dc, err := h.authSvc.GetDeviceCodeByUserCode(c.UserContext(), userCode)
118 + switch {
119 + case err != nil:
120 + data["CodeError"] = h.tr(c, "device.code_unknown")
121 + case dc.Status == auth.DeviceStatusApproved:
122 + data["CodeApproved"] = true
123 + case dc.Status == auth.DeviceStatusDenied:
124 + data["CodeDenied"] = true
125 + case dc.Status != auth.DeviceStatusPending || time.Now().UTC().After(dc.ExpiresAt):
126 + data["CodeError"] = h.tr(c, "device.code_used")
127 + default:
128 + data["CodePending"] = true
129 + if dc.ClientID != "" {
130 + data["ClientName"] = dc.ClientID
131 + }
132 + }
133 + }
134 +
135 + return h.render(c, fiber.StatusOK, "device", data)
136 +}
137 +
138 +// DeviceVerifySubmit approves or denies a pending grant.
139 +// POST /login/device
140 +func (h *Handler) DeviceVerifySubmit(c *fiber.Ctx) error {
141 + viewer := middleware.CurrentUser(c)
142 + if viewer == nil {
143 + return c.Redirect("/login?next=/login/device", fiber.StatusSeeOther)
144 + }
145 +
146 + userCode := auth.NormalizeUserCode(c.FormValue("user_code"))
147 + action := strings.TrimSpace(c.FormValue("action"))
148 + redirect := "/login/device?user_code=" + userCode
149 +
150 + if userCode == "" {
151 + h.setFlashT(c, "flash.device_code_required")
152 + return c.Redirect("/login/device", fiber.StatusSeeOther)
153 + }
154 +
155 + status := auth.DeviceStatusApproved
156 + if action == "deny" {
157 + status = auth.DeviceStatusDenied
158 + }
159 +
160 + if err := h.authSvc.SetDeviceCodeStatus(c.UserContext(), userCode, viewer.ID, status); err != nil {
161 + h.setFlashT(c, "flash.device_code_invalid")
162 + return c.Redirect("/login/device", fiber.StatusSeeOther)
163 + }
164 +
165 + if status == auth.DeviceStatusApproved {
166 + h.setFlashT(c, "flash.device_approved")
167 + } else {
168 + h.setFlashT(c, "flash.device_denied")
169 + }
170 + return c.Redirect(redirect, fiber.StatusSeeOther)
171 +}
internal/web/handlers/forks.go
+77 -0
... ... @@ -0,0 +1,77 @@
1 +package handlers
2 +
3 +import (
4 + "fmt"
5 + "strings"
6 +
7 + "github.com/gofiber/fiber/v2"
8 +
9 + "polar/internal/web/middleware"
10 +)
11 +
12 +// RepoForkSubmit creates a fork of the repository for the current user.
13 +func (h *Handler) RepoForkSubmit(c *fiber.Ctx) error {
14 + r, _, err := h.loadAuthorizedRepo(c, false)
15 + if err != nil {
16 + return nil
17 + }
18 +
19 + u := middleware.CurrentUser(c)
20 + if u == nil {
21 + return c.Redirect("/login", fiber.StatusSeeOther)
22 + }
23 +
24 + if u.ID == r.OwnerID {
25 + h.setFlashT(c, "flash.fork_self")
26 + return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
27 + }
28 +
29 + if existing, _ := h.repoSvc.FindForkByOwner(c.UserContext(), r.ID, u.ID); existing != nil {
30 + h.setFlashT(c, "flash.fork_exists")
31 + return c.Redirect(fmt.Sprintf("/%s/%s", existing.OwnerUsername, existing.Name), fiber.StatusSeeOther)
32 + }
33 +
34 + name := strings.TrimSpace(c.FormValue("name"))
35 + newFork := r
36 + if r.ForkedFromRepoID != nil {
37 + if parent, parentErr := h.repoSvc.GetByID(c.UserContext(), *r.ForkedFromRepoID); parentErr == nil && parent != nil {
38 + newFork = parent
39 + }
40 + }
41 +
42 + fork, err := h.repoSvc.Fork(c.UserContext(), newFork, u, name)
43 + if err != nil {
44 + h.setFlashT(c, "flash.fork_failed", err.Error())
45 + return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
46 + }
47 +
48 + h.setFlashT(c, "flash.fork_created")
49 + return c.Redirect(fmt.Sprintf("/%s/%s", fork.OwnerUsername, fork.Name), fiber.StatusSeeOther)
50 +}
51 +
52 +// RepoForksPage lists all forks of the repository.
53 +func (h *Handler) RepoForksPage(c *fiber.Ctx) error {
54 + r, _, err := h.loadAuthorizedRepo(c, false)
55 + if err != nil {
56 + return nil
57 + }
58 +
59 + forks, _ := h.repoSvc.ListForks(c.UserContext(), r.ID, 100)
60 +
61 + var upstream map[string]string
62 + if r.ForkedFromRepoID != nil {
63 + upstream = map[string]string{"Owner": r.ForkedFromOwner, "Name": r.ForkedFromName}
64 + }
65 +
66 + viewer := middleware.CurrentUser(c)
67 + data := h.baseData(c, h.tr(c, "forks.title")+" · "+r.OwnerUsername+"/"+r.Name)
68 + data["Repo"] = r
69 + data["Ref"] = r.DefaultBranch
70 + data["ActiveTab"] = "code"
71 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
72 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
73 + data["Forks"] = forks
74 + data["Upstream"] = upstream
75 +
76 + return h.render(c, fiber.StatusOK, "forks", data)
77 +}
internal/web/handlers/git_http.go
+170 -0
... ... @@ -0,0 +1,170 @@
1 +package handlers
2 +
3 +import (
4 + "bytes"
5 + "compress/gzip"
6 + "encoding/base64"
7 + "io"
8 + "strings"
9 +
10 + "polar/internal/git"
11 + "polar/internal/repo"
12 + "polar/internal/user"
13 +
14 + "github.com/gofiber/fiber/v2"
15 +)
16 +
17 +func (h *Handler) authenticateGitBasic(c *fiber.Ctx) *user.User {
18 + authHeader := c.Get(fiber.HeaderAuthorization)
19 + if !strings.HasPrefix(authHeader, "Basic ") {
20 + return nil
21 + }
22 + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
23 + if err != nil {
24 + return nil
25 + }
26 + parts := strings.SplitN(string(decoded), ":", 2)
27 + if len(parts) != 2 {
28 + return nil
29 + }
30 + username := strings.TrimSpace(parts[0])
31 + secret := strings.TrimSpace(parts[1])
32 + if secret == "" {
33 + return nil
34 + }
35 +
36 + // 1. Personal Access Token
37 + if strings.HasPrefix(secret, "polar_") {
38 + userID, err := h.authSvc.VerifyToken(c.UserContext(), secret)
39 + if err != nil {
40 + return nil
41 + }
42 + u, err := h.userSvc.GetByID(c.UserContext(), userID)
43 + if err != nil {
44 + return nil
45 + }
46 + if username != "" && !strings.EqualFold(u.Username, username) && !strings.EqualFold(u.Email, username) {
47 + return nil
48 + }
49 + return u
50 + }
51 +
52 + // 2. Fallback to username + password if provided
53 + if username != "" {
54 + if u, err := h.userSvc.Authenticate(c.UserContext(), username, secret); err == nil {
55 + return u
56 + }
57 + }
58 + return nil
59 +}
60 +
61 +func requestBasicAuth(c *fiber.Ctx) error {
62 + c.Set(fiber.HeaderWWWAuthenticate, `Basic realm="Polar Git"`)
63 + return c.Status(fiber.StatusUnauthorized).SendString("Authentication required")
64 +}
65 +
66 +func (h *Handler) authorizeGitRequest(c *fiber.Ctx, service string) (*repo.Repository, string, error) {
67 + owner := c.Params("owner")
68 + repoName := strings.TrimSuffix(c.Params("repo"), ".git")
69 +
70 + r, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), owner, repoName)
71 + if err != nil {
72 + // If anonymous request to non-existent or private repo, prompt Basic Auth first so git clients send credentials
73 + if c.Get(fiber.HeaderAuthorization) == "" {
74 + return nil, "", requestBasicAuth(c)
75 + }
76 + return nil, "", c.Status(fiber.StatusNotFound).SendString("Repository not found")
77 + }
78 +
79 + authedUser := h.authenticateGitBasic(c)
80 +
81 + switch service {
82 + case "git-upload-pack":
83 + if r.IsPrivate {
84 + if authedUser == nil {
85 + return nil, "", requestBasicAuth(c)
86 + }
87 + if !h.repoSvc.CanRead(c.UserContext(), r, authedUser) {
88 + return nil, "", c.Status(fiber.StatusForbidden).SendString("Forbidden")
89 + }
90 + }
91 + case "git-receive-pack":
92 + if authedUser == nil {
93 + return nil, "", requestBasicAuth(c)
94 + }
95 + if !h.repoSvc.CanWrite(c.UserContext(), r, authedUser) {
96 + return nil, "", c.Status(fiber.StatusForbidden).SendString("Forbidden")
97 + }
98 + default:
99 + return nil, "", c.Status(fiber.StatusBadRequest).SendString("Unsupported git service")
100 + }
101 +
102 + diskPath, err := h.repoSvc.DiskPath(r.OwnerUsername, r.Name)
103 + if err != nil {
104 + return nil, "", c.Status(fiber.StatusBadRequest).SendString("Invalid repository path")
105 + }
106 +
107 + return r, diskPath, nil
108 +}
109 +
110 +func (h *Handler) GitInfoRefs(c *fiber.Ctx) error {
111 + service := c.Query("service")
112 + if service != "git-upload-pack" && service != "git-receive-pack" {
113 + return c.Status(fiber.StatusBadRequest).SendString("Smart HTTP service parameter required")
114 + }
115 +
116 + _, diskPath, err := h.authorizeGitRequest(c, service)
117 + if err != nil || diskPath == "" {
118 + return err
119 + }
120 +
121 + var buf bytes.Buffer
122 + if err := git.AdvertiseRefs(c.UserContext(), diskPath, service, &buf); err != nil {
123 + h.logger.Error("git advertise-refs failed", "service", service, "err", err)
124 + return c.Status(fiber.StatusInternalServerError).SendString("Git advertise-refs error")
125 + }
126 +
127 + c.Set(fiber.HeaderContentType, "application/x-"+service+"-advertisement")
128 + c.Set(fiber.HeaderCacheControl, "no-cache, max-age=0, must-revalidate")
129 + c.Set("Pragma", "no-cache")
130 + return c.Status(fiber.StatusOK).Send(buf.Bytes())
131 +}
132 +
133 +func (h *Handler) GitUploadPack(c *fiber.Ctx) error {
134 + return h.handleGitServiceRPC(c, "git-upload-pack")
135 +}
136 +
137 +func (h *Handler) GitReceivePack(c *fiber.Ctx) error {
138 + return h.handleGitServiceRPC(c, "git-receive-pack")
139 +}
140 +
141 +func (h *Handler) handleGitServiceRPC(c *fiber.Ctx, service string) error {
142 + r, diskPath, err := h.authorizeGitRequest(c, service)
143 + if err != nil || diskPath == "" {
144 + return err
145 + }
146 +
147 + var bodyReader io.Reader = bytes.NewReader(c.Body())
148 + if strings.EqualFold(c.Get(fiber.HeaderContentEncoding), "gzip") {
149 + gz, err := gzip.NewReader(bodyReader)
150 + if err != nil {
151 + return c.Status(fiber.StatusBadRequest).SendString("Invalid gzip body")
152 + }
153 + defer gz.Close()
154 + bodyReader = gz
155 + }
156 +
157 + var out bytes.Buffer
158 + if err := git.ServiceRPC(c.UserContext(), diskPath, service, bodyReader, &out); err != nil {
159 + h.logger.Error("git service rpc failed", "service", service, "err", err)
160 + return c.Status(fiber.StatusInternalServerError).SendString("Git RPC error")
161 + }
162 +
163 + if service == "git-receive-pack" {
164 + h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID)
165 + }
166 +
167 + c.Set(fiber.HeaderContentType, "application/x-"+service+"-result")
168 + c.Set(fiber.HeaderCacheControl, "no-cache, max-age=0, must-revalidate")
169 + return c.Status(fiber.StatusOK).Send(out.Bytes())
170 +}
internal/web/handlers/handlers.go
+351 -0
... ... @@ -0,0 +1,351 @@
1 +package handlers
2 +
3 +import (
4 + "bytes"
5 + "fmt"
6 + "log/slog"
7 + "net/http"
8 + "strings"
9 + "time"
10 +
11 + "polar/internal/auth"
12 + "polar/internal/config"
13 + "polar/internal/db"
14 + "polar/internal/i18n"
15 + "polar/internal/repo"
16 + "polar/internal/user"
17 + "polar/internal/web/middleware"
18 + "polar/internal/web/static"
19 + "polar/internal/web/views"
20 +
21 + "github.com/gofiber/fiber/v2"
22 + "github.com/gofiber/fiber/v2/middleware/filesystem"
23 +)
24 +
25 +type Handler struct {
26 + cfg config.Config
27 + db *db.DB
28 + authSvc *auth.Service
29 + userSvc *user.Service
30 + repoSvc *repo.Service
31 + views *views.Engine
32 + logger *slog.Logger
33 +}
34 +
35 +func New(
36 + cfg config.Config,
37 + database *db.DB,
38 + authSvc *auth.Service,
39 + userSvc *user.Service,
40 + repoSvc *repo.Service,
41 + viewsEngine *views.Engine,
42 + logger *slog.Logger,
43 +) *Handler {
44 + return &Handler{
45 + cfg: cfg,
46 + db: database,
47 + authSvc: authSvc,
48 + userSvc: userSvc,
49 + repoSvc: repoSvc,
50 + views: viewsEngine,
51 + logger: logger,
52 + }
53 +}
54 +
55 +func (h *Handler) RegisterRoutes(app *fiber.App) {
56 + app.Use(middleware.RecoverAndLog(h.logger))
57 + app.Use(middleware.SecurityHeaders())
58 +
59 + app.Get("/healthz", h.Healthz)
60 +
61 + app.Use("/static", filesystem.New(filesystem.Config{
62 + Root: http.FS(static.FS),
63 + Browse: false,
64 + MaxAge: 0,
65 + }))
66 +
67 + app.Use(middleware.LocaleAndTheme(h.cfg.Landing.DefaultLang))
68 + app.Use(middleware.SessionAuth(h.authSvc, h.userSvc))
69 + app.Use(middleware.CSRFProtection())
70 +
71 + authLimiter := middleware.NewRateLimiter(20, time.Minute)
72 +
73 + // Public & Root routes
74 + app.Get("/", h.Home)
75 + app.Get("/explore", h.Explore)
76 +
77 + // Auth routes
78 + app.Get("/login", h.LoginPage)
79 + app.Post("/login", authLimiter.Handler(), h.LoginSubmit)
80 + app.Get("/register", h.RegisterPage)
81 + app.Post("/register", authLimiter.Handler(), h.RegisterSubmit)
82 + app.Post("/logout", h.LogoutSubmit)
83 + app.Get("/login/device", h.DeviceVerifyPage)
84 + app.Post("/login/device", h.DeviceVerifySubmit)
85 + app.Post("/api/v1/device/code", h.DeviceCodeAPI)
86 + app.Post("/api/v1/device/token", h.DeviceTokenAPI)
87 +
88 + // Authenticated user routes
89 + app.Get("/repos", middleware.RequireAuth(), h.MyRepos)
90 + app.Get("/new", middleware.RequireAuth(), h.NewRepoPage)
91 + app.Post("/new", middleware.RequireAuth(), h.NewRepoSubmit)
92 + app.Get("/settings", middleware.RequireAuth(), h.SettingsPage)
93 + app.Post("/settings/profile", middleware.RequireAuth(), h.UpdateProfileSubmit)
94 + app.Post("/settings/password", middleware.RequireAuth(), h.UpdatePasswordSubmit)
95 + app.Post("/settings/tokens", middleware.RequireAuth(), h.CreateTokenSubmit)
96 + app.Post("/settings/tokens/:id/revoke", middleware.RequireAuth(), h.RevokeTokenSubmit)
97 +
98 + // Admin routes
99 + app.Get("/admin", middleware.RequireAdmin(), h.AdminPage)
100 + app.Post("/admin/settings", middleware.RequireAdmin(), h.AdminSettingsSubmit)
101 +
102 + // Git Smart HTTP endpoints (supports both /:owner/:repo.git/* and /:owner/:repo/*)
103 + app.Get("/:owner/:repo/info/refs", h.GitInfoRefs)
104 + app.Post("/:owner/:repo/git-upload-pack", h.GitUploadPack)
105 + app.Post("/:owner/:repo/git-receive-pack", h.GitReceivePack)
106 +
107 + // Repository Web UI routes
108 + app.Get("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsPage)
109 + app.Post("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsSubmit)
110 + app.Post("/:owner/:repo/settings/rename", middleware.RequireAuth(), h.RepoRenameSubmit)
111 + app.Post("/:owner/:repo/settings/delete", middleware.RequireAuth(), h.RepoDeleteSubmit)
112 + app.Post("/:owner/:repo/settings/collaborators/add", middleware.RequireAuth(), h.RepoCollaboratorAddSubmit)
113 + app.Post("/:owner/:repo/settings/collaborators/remove", middleware.RequireAuth(), h.RepoCollaboratorRemoveSubmit)
114 +
115 + app.Get("/:owner/:repo/branches", h.RepoBranchesPage)
116 + app.Post("/:owner/:repo/branches", middleware.RequireAuth(), h.RepoCreateBranchSubmit)
117 + app.Post("/:owner/:repo/branches/delete", middleware.RequireAuth(), h.RepoDeleteBranchSubmit)
118 + app.Post("/:owner/:repo/tags", middleware.RequireAuth(), h.RepoCreateTagSubmit)
119 + app.Post("/:owner/:repo/tags/delete", middleware.RequireAuth(), h.RepoDeleteTagSubmit)
120 + app.Get("/:owner/:repo/archive/*", h.RepoArchiveZip)
121 +
122 + app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit)
123 + app.Get("/:owner/:repo/forks", h.RepoForksPage)
124 +
125 + app.Get("/:owner/:repo/issues", h.RepoIssuesPage)
126 + app.Get("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssuePage)
127 + app.Post("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssueSubmit)
128 + app.Get("/:owner/:repo/issues/:number", h.RepoIssueDetailPage)
129 + app.Post("/:owner/:repo/issues/:number/comments", middleware.RequireAuth(), h.RepoIssueCommentSubmit)
130 + app.Post("/:owner/:repo/issues/:number/state", middleware.RequireAuth(), h.RepoIssueStateSubmit)
131 +
132 + // Pull Requests
133 + app.Get("/:owner/:repo/pulls", h.RepoPullsPage)
134 + app.Get("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullPage)
135 + app.Post("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullSubmit)
136 + app.Get("/:owner/:repo/pulls/:number", h.RepoPullDetailPage)
137 + app.Post("/:owner/:repo/pulls/:number/comments", middleware.RequireAuth(), h.RepoPullCommentSubmit)
138 + app.Post("/:owner/:repo/pulls/:number/state", middleware.RequireAuth(), h.RepoPullStateSubmit)
139 + app.Post("/:owner/:repo/pulls/:number/merge", middleware.RequireAuth(), h.RepoPullMergeSubmit)
140 + app.Post("/:owner/:repo/pulls/:number/review", middleware.RequireAuth(), h.RepoPullReviewSubmit)
141 +
142 + // Releases
143 + app.Get("/:owner/:repo/releases", h.RepoReleasesPage)
144 + app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage)
145 + app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit)
146 + app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit)
147 +
148 + // Activity
149 + app.Get("/:owner/:repo/activity", h.RepoActivityPage)
150 +
151 + app.Get("/:owner/:repo/commits/:ref", h.RepoCommitsPage)
152 + app.Get("/:owner/:repo/commit/:hash", h.RepoCommitDetailPage)
153 + app.Get("/:owner/:repo/tree/:ref/*", h.RepoTreePage)
154 + app.Get("/:owner/:repo/tree/:ref", h.RepoTreePage)
155 + app.Get("/:owner/:repo/blob/:ref/*", h.RepoBlobPage)
156 + app.Get("/:owner/:repo/raw/:ref/*", h.RepoRawBlob)
157 + app.Get("/:owner/:repo", h.RepoHomePage)
158 +
159 + // User profile page
160 + app.Get("/:username", h.UserProfilePage)
161 +}
162 +
163 +func (h *Handler) Healthz(c *fiber.Ctx) error {
164 + if err := h.db.PingContext(c.UserContext()); err != nil {
165 + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
166 + "status": "error",
167 + "db": "unreachable",
168 + })
169 + }
170 + return c.Status(fiber.StatusOK).JSON(fiber.Map{
171 + "status": "ok",
172 + "service": "polar",
173 + })
174 +}
175 +
176 +func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any {
177 + u := middleware.CurrentUser(c)
178 + lang := h.langOf(c)
179 + theme, _ := c.Locals("theme").(string)
180 + if theme == "" {
181 + theme = "system"
182 + }
183 + csrf, _ := c.Locals("csrf_token").(string)
184 +
185 + flash := c.Cookies("polar_flash")
186 + if flash != "" {
187 + c.ClearCookie("polar_flash")
188 + }
189 +
190 + ctx := c.UserContext()
191 + anonRead := h.db.IsAnonymousReadEnabled(ctx)
192 + var paletteRepos []repo.Repository
193 + if u != nil || anonRead {
194 + paletteRepos, _ = h.repoSvc.ListAccessible(ctx, u, 25)
195 + }
196 +
197 + data := map[string]any{
198 + "Title": title,
199 + "Lang": lang,
200 + "Theme": theme,
201 + "CSRFToken": csrf,
202 + "CurrentUser": u,
203 + "Flash": flash,
204 + "RegistrationEnabled": h.isRegistrationAllowed(c),
205 + "ExploreEnabled": h.db.IsExploreEnabled(ctx),
206 + "LandingEnabled": h.db.IsLandingEnabled(ctx, h.cfg.Landing.Enabled),
207 + "AnonymousReadEnabled": anonRead,
208 + "IssuesEnabled": h.db.IsIssuesEnabled(ctx),
209 + "PaletteRepos": paletteRepos,
210 + }
211 + if extra := h.repoNavData(c); extra != nil {
212 + for k, v := range extra {
213 + data[k] = v
214 + }
215 + }
216 + return data
217 +}
218 +
219 +// repoNavData enriches template data for /:owner/:repo routes with fork
220 +// navigation state used by the repository header.
221 +func (h *Handler) repoNavData(c *fiber.Ctx) map[string]any {
222 + owner, name := c.Params("owner"), strings.TrimSuffix(c.Params("repo"), ".git")
223 + if owner == "" || name == "" {
224 + return nil
225 + }
226 + ctx := c.UserContext()
227 + nr, err := h.repoSvc.GetByOwnerAndName(ctx, owner, name)
228 + if err != nil || nr == nil {
229 + return nil
230 + }
231 + u := middleware.CurrentUser(c)
232 + data := map[string]any{"NavRepo": nr}
233 + data["IsRepoOwner"] = u != nil && u.ID == nr.OwnerID
234 + if u != nil && u.ID != nr.OwnerID {
235 + if fork, ferr := h.repoSvc.FindForkByOwner(ctx, nr.ID, u.ID); ferr == nil && fork != nil {
236 + data["ViewerFork"] = fork
237 + }
238 + }
239 + return data
240 +}
241 +
242 +func (h *Handler) setFlash(c *fiber.Ctx, msg string) {
243 + c.Cookie(&fiber.Cookie{
244 + Name: "polar_flash",
245 + Value: msg,
246 + Path: "/",
247 + MaxAge: 30,
248 + HTTPOnly: true,
249 + SameSite: "Lax",
250 + })
251 +}
252 +
253 +// langOf resolves the active UI language for the current request.
254 +func (h *Handler) langOf(c *fiber.Ctx) i18n.Lang {
255 + lang, _ := c.Locals("lang").(i18n.Lang)
256 + if lang == "" {
257 + lang = i18n.Normalize(h.cfg.Landing.DefaultLang)
258 + }
259 + return lang
260 +}
261 +
262 +// tr translates key for the current request and formats it with args.
263 +func (h *Handler) tr(c *fiber.Ctx, key string, args ...any) string {
264 + return fmt.Sprintf(i18n.T(h.langOf(c), key), args...)
265 +}
266 +
267 +// setFlashT stores a localized flash message.
268 +func (h *Handler) setFlashT(c *fiber.Ctx, key string, args ...any) {
269 + h.setFlash(c, h.tr(c, key, args...))
270 +}
271 +
272 +// renderErrorT renders an error page with localized title and message.
273 +func (h *Handler) renderErrorT(c *fiber.Ctx, status int, titleKey, msgKey string, args ...any) error {
274 + return h.renderError(c, status, i18n.T(h.langOf(c), titleKey), h.tr(c, msgKey, args...))
275 +}
276 +
277 +func (h *Handler) isRegistrationAllowed(c *fiber.Ctx) bool {
278 + if cnt, err := h.userSvc.Count(c.UserContext()); err == nil && cnt == 0 {
279 + return true
280 + }
281 + return h.db.IsRegistrationEnabled(c.UserContext(), h.cfg.Auth.RegistrationEnabled)
282 +}
283 +
284 +func (h *Handler) render(c *fiber.Ctx, status int, page string, data map[string]any) error {
285 + var buf bytes.Buffer
286 + if err := h.views.Render(&buf, page, data); err != nil {
287 + h.logger.Error("render template failed", "page", page, "err", err)
288 + return c.Status(fiber.StatusInternalServerError).SendString("Template render error")
289 + }
290 + c.Status(status)
291 + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
292 + return c.Send(buf.Bytes())
293 +}
294 +
295 +func (h *Handler) renderError(c *fiber.Ctx, status int, title, message string) error {
296 + data := h.baseData(c, title)
297 + data["StatusCode"] = status
298 + data["Title"] = title
299 + data["Message"] = message
300 + return h.render(c, status, "error", data)
301 +}
302 +
303 +func (h *Handler) Home(c *fiber.Ctx) error {
304 + if !h.db.IsLandingEnabled(c.UserContext(), h.cfg.Landing.Enabled) {
305 + if middleware.CurrentUser(c) != nil {
306 + return c.Redirect("/repos", fiber.StatusSeeOther)
307 + }
308 + return c.Redirect("/login", fiber.StatusSeeOther)
309 + }
310 + data := h.baseData(c, "")
311 + if h.db.IsExploreEnabled(c.UserContext()) && (middleware.CurrentUser(c) != nil || h.db.IsAnonymousReadEnabled(c.UserContext())) {
312 + repos, _ := h.repoSvc.ListPublic(c.UserContext(), 10)
313 + data["Repos"] = repos
314 + }
315 + return h.render(c, fiber.StatusOK, "landing", data)
316 +}
317 +
318 +func (h *Handler) Explore(c *fiber.Ctx) error {
319 + if !h.db.IsExploreEnabled(c.UserContext()) {
320 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.explore_disabled")
321 + }
322 + if middleware.CurrentUser(c) == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) {
323 + return c.Redirect("/login", fiber.StatusSeeOther)
324 + }
325 + lang, _ := c.Locals("lang").(i18n.Lang)
326 + data := h.baseData(c, i18n.T(lang, "nav.explore"))
327 + repos, _ := h.repoSvc.ListPublic(c.UserContext(), 100)
328 + data["PageHeading"] = i18n.T(lang, "nav.explore")
329 + data["Repos"] = repos
330 + return h.render(c, fiber.StatusOK, "repos", data)
331 +}
332 +
333 +func (h *Handler) MyRepos(c *fiber.Ctx) error {
334 + u := middleware.CurrentUser(c)
335 + lang, _ := c.Locals("lang").(i18n.Lang)
336 + data := h.baseData(c, i18n.T(lang, "nav.repositories"))
337 + repos, _ := h.repoSvc.ListByOwner(c.UserContext(), u.ID, true)
338 + data["PageHeading"] = i18n.T(lang, "nav.repositories")
339 + data["Repos"] = repos
340 + return h.render(c, fiber.StatusOK, "repos", data)
341 +}
342 +
343 +func (h *Handler) cloneURL(c *fiber.Ctx, ownerUsername, repoName string) string {
344 + base := strings.TrimRight(h.cfg.Server.BaseURL, "/")
345 + if base == "" || strings.Contains(base, "localhost:3000") {
346 + if reqBase := c.BaseURL(); reqBase != "" {
347 + base = strings.TrimRight(reqBase, "/")
348 + }
349 + }
350 + return base + "/" + ownerUsername + "/" + repoName + ".git"
351 +}
internal/web/handlers/handlers_test.go
+328 -0
... ... @@ -0,0 +1,328 @@
1 +package handlers
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "io"
7 + "log/slog"
8 + "net"
9 + "net/http"
10 + "net/http/httptest"
11 + "os"
12 + "os/exec"
13 + "path/filepath"
14 + "strings"
15 + "testing"
16 + "time"
17 +
18 + "polar/internal/auth"
19 + "polar/internal/config"
20 + "polar/internal/db"
21 + "polar/internal/repo"
22 + "polar/internal/user"
23 + "polar/internal/web/views"
24 +
25 + "github.com/gofiber/fiber/v2"
26 +)
27 +
28 +func setupTestApp(t *testing.T) (*fiber.App, *auth.Service, *user.Service, *repo.Service) {
29 + t.Helper()
30 + tmp := t.TempDir()
31 +
32 + cfg := config.Default()
33 + cfg.Storage.DataDir = tmp
34 +
35 + ctx := context.Background()
36 + database, err := db.Open(ctx, cfg.DBPath(), true)
37 + if err != nil {
38 + t.Fatalf("open test db: %v", err)
39 + }
40 + t.Cleanup(func() { _ = database.Close() })
41 +
42 + authSvc := auth.NewService(database)
43 + userSvc := user.NewService(database)
44 + repoSvc := repo.NewService(database, cfg.ReposDir())
45 +
46 + viewsEngine, err := views.NewEngine()
47 + if err != nil {
48 + t.Fatalf("new views engine: %v", err)
49 + }
50 +
51 + logger := slog.New(slog.NewTextHandler(io.Discard, nil))
52 + app := fiber.New(fiber.Config{
53 + DisableStartupMessage: true,
54 + BodyLimit: 50 * 1024 * 1024,
55 + })
56 +
57 + h := New(cfg, database, authSvc, userSvc, repoSvc, viewsEngine, logger)
58 + h.RegisterRoutes(app)
59 +
60 + return app, authSvc, userSvc, repoSvc
61 +}
62 +
63 +func TestHealthzLandingAndStatic(t *testing.T) {
64 + app, _, _, _ := setupTestApp(t)
65 +
66 + // 1. /healthz
67 + req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
68 + resp, err := app.Test(req, 5000)
69 + if err != nil {
70 + t.Fatalf("healthz request failed: %v", err)
71 + }
72 + if resp.StatusCode != http.StatusOK {
73 + t.Fatalf("expected 200 on /healthz, got %d", resp.StatusCode)
74 + }
75 + if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
76 + t.Fatalf("missing security header X-Content-Type-Options")
77 + }
78 +
79 + // 2. Landing page (RU & EN) + verify zero external asset requests
80 + reqRU := httptest.NewRequest(http.MethodGet, "/?lang=ru", nil)
81 + respRU, err := app.Test(reqRU, 5000)
82 + if err != nil || respRU.StatusCode != http.StatusOK {
83 + t.Fatalf("landing RU failed: %v", err)
84 + }
85 + bodyRU, _ := io.ReadAll(respRU.Body)
86 + htmlRU := string(bodyRU)
87 + if !strings.Contains(htmlRU, "Polar") || !strings.Contains(htmlRU, "hi-git-branch") {
88 + t.Fatalf("landing RU missing expected content or Hugeicons sprite")
89 + }
90 + if strings.Contains(htmlRU, "https://fonts.googleapis.com") || strings.Contains(htmlRU, "https://unpkg.com") || strings.Contains(htmlRU, "https://cdn.") {
91 + t.Fatalf("landing page must not make external CDN requests")
92 + }
93 +
94 + reqEN := httptest.NewRequest(http.MethodGet, "/?lang=en", nil)
95 + respEN, err := app.Test(reqEN, 5000)
96 + if err != nil || respEN.StatusCode != http.StatusOK {
97 + t.Fatalf("landing EN failed: %v", err)
98 + }
99 + bodyEN, _ := io.ReadAll(respEN.Body)
100 + if !strings.Contains(string(bodyEN), "Lightweight self-hosted Git hosting") {
101 + t.Fatalf("landing EN missing translated hero title")
102 + }
103 +
104 + // 3. Static embedded CSS & JS
105 + for _, asset := range []string{"/static/css/app.css", "/static/js/app.js", "/static/js/htmx.min.js"} {
106 + r := httptest.NewRequest(http.MethodGet, asset, nil)
107 + res, err := app.Test(r, 5000)
108 + if err != nil || res.StatusCode != http.StatusOK {
109 + t.Fatalf("embedded static asset %s failed: status=%d err=%v", asset, res.StatusCode, err)
110 + }
111 + }
112 +}
113 +
114 +func TestEndToEndGitSmartHTTPAndPrivateRepos(t *testing.T) {
115 + app, authSvc, userSvc, repoSvc := setupTestApp(t)
116 + ctx := context.Background()
117 +
118 + // Start real TCP listener for system git CLI
119 + ln, err := net.Listen("tcp", "127.0.0.1:0")
120 + if err != nil {
121 + t.Fatalf("listen tcp: %v", err)
122 + }
123 + defer ln.Close()
124 +
125 + go func() {
126 + _ = app.Listener(ln)
127 + }()
128 +
129 + baseURL := "http://" + ln.Addr().String()
130 +
131 + // Create owner user + personal access token
132 + owner, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true)
133 + if err != nil {
134 + t.Fatalf("register owner: %v", err)
135 + }
136 + rawToken, _, err := authSvc.CreateToken(ctx, owner.ID, "E2E Token")
137 + if err != nil {
138 + t.Fatalf("create token: %v", err)
139 + }
140 +
141 + // Create a public repo and a private repo
142 + pubRepo, err := repoSvc.Create(ctx, owner, "engine", "Analytical Engine", false)
143 + if err != nil {
144 + t.Fatalf("create public repo: %v", err)
145 + }
146 + privRepo, err := repoSvc.Create(ctx, owner, "vault", "Secret Notes", true)
147 + if err != nil {
148 + t.Fatalf("create private repo: %v", err)
149 + }
150 +
151 + // 1. Verify private repo is blocked on Web UI and Git HTTP without credentials
152 + webPrivResp, err := http.Get(baseURL + "/alice/vault")
153 + if err != nil {
154 + t.Fatalf("GET /alice/vault: %v", err)
155 + }
156 + _ = webPrivResp.Body.Close()
157 + if webPrivResp.StatusCode != http.StatusNotFound {
158 + t.Fatalf("expected 404 for unauthenticated private repo web request, got %d", webPrivResp.StatusCode)
159 + }
160 +
161 + gitPrivResp, err := http.Get(baseURL + "/alice/vault.git/info/refs?service=git-upload-pack")
162 + if err != nil {
163 + t.Fatalf("GET private info/refs: %v", err)
164 + }
165 + _ = gitPrivResp.Body.Close()
166 + if gitPrivResp.StatusCode != http.StatusUnauthorized {
167 + t.Fatalf("expected 401 for unauthenticated private git info/refs, got %d", gitPrivResp.StatusCode)
168 + }
169 +
170 + // 2. Push commits to public repo over HTTP using Personal Access Token
171 + workDir := filepath.Join(t.TempDir(), "work")
172 + if err := os.MkdirAll(workDir, 0o755); err != nil {
173 + t.Fatalf("mkdir work: %v", err)
174 + }
175 +
176 + runGitCmd := func(dir string, args ...string) {
177 + t.Helper()
178 + cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
179 + defer cancel()
180 + cmd := exec.CommandContext(cctx, "git", args...)
181 + cmd.Dir = dir
182 + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
183 + out, err := cmd.CombinedOutput()
184 + if err != nil {
185 + t.Fatalf("git %v failed: %v\nOutput: %s", args, err, string(out))
186 + }
187 + }
188 +
189 + runGitCmd(workDir, "init", "-b", "main")
190 + runGitCmd(workDir, "config", "user.name", "Alice Lovelace")
191 + runGitCmd(workDir, "config", "user.email", "alice@example.com")
192 +
193 + if err := os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Analytical Engine\n\nHello from **Alice**!\n"), 0o644); err != nil {
194 + t.Fatalf("write README.md: %v", err)
195 + }
196 + if err := os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Alice\")\n}\n"), 0o644); err != nil {
197 + t.Fatalf("write main.go: %v", err)
198 + }
199 +
200 + runGitCmd(workDir, "add", "README.md", "main.go")
201 + runGitCmd(workDir, "commit", "-m", "Initial commit with README and main.go")
202 +
203 + authedPubURL := fmt.Sprintf("http://%s:%s@%s/alice/%s.git", owner.Username, rawToken, ln.Addr().String(), pubRepo.Name)
204 + runGitCmd(workDir, "push", "-u", authedPubURL, "main")
205 +
206 + // Also push to private repo with token
207 + authedPrivURL := fmt.Sprintf("http://%s:%s@%s/alice/%s.git", owner.Username, rawToken, ln.Addr().String(), privRepo.Name)
208 + runGitCmd(workDir, "push", authedPrivURL, "main")
209 +
210 + // 3. Clone public repo anonymously and private repo with token
211 + clonePubDir := filepath.Join(t.TempDir(), "clone-pub")
212 + runGitCmd(t.TempDir(), "clone", baseURL+"/alice/engine.git", clonePubDir)
213 + if _, err := os.Stat(filepath.Join(clonePubDir, "main.go")); err != nil {
214 + t.Fatalf("cloned public repo missing main.go: %v", err)
215 + }
216 +
217 + clonePrivDir := filepath.Join(t.TempDir(), "clone-priv")
218 + runGitCmd(t.TempDir(), "clone", authedPrivURL, clonePrivDir)
219 + if _, err := os.Stat(filepath.Join(clonePrivDir, "README.md")); err != nil {
220 + t.Fatalf("cloned private repo missing README.md: %v", err)
221 + }
222 +
223 + // 4. Verify Web UI renders repository tree, README Markdown, syntax-highlighted blob, commits, and diff
224 + checkEndpoint := func(urlPath, expectedSubstring string) {
225 + t.Helper()
226 + resp, err := http.Get(baseURL + urlPath)
227 + if err != nil {
228 + t.Fatalf("GET %s failed: %v", urlPath, err)
229 + }
230 + defer resp.Body.Close()
231 + if resp.StatusCode != http.StatusOK {
232 + t.Fatalf("GET %s returned status %d", urlPath, resp.StatusCode)
233 + }
234 + b, _ := io.ReadAll(resp.Body)
235 + if !strings.Contains(string(b), expectedSubstring) {
236 + t.Fatalf("GET %s missing expected %q", urlPath, expectedSubstring)
237 + }
238 + }
239 +
240 + checkEndpoint("/alice/engine", "Analytical Engine")
241 + checkEndpoint("/alice/engine/blob/main/main.go", "Println")
242 + checkEndpoint("/alice/engine/raw/main/main.go", "package main")
243 + checkEndpoint("/alice/engine/commits/main", "Initial commit with README and main.go")
244 + checkEndpoint("/alice/engine/branches", "main")
245 + checkEndpoint("/alice", "alice")
246 +
247 + // 5. Verify ZIP archive download
248 + zipResp, err := http.Get(baseURL + "/alice/engine/archive/main.zip")
249 + if err != nil {
250 + t.Fatalf("GET archive zip failed: %v", err)
251 + }
252 + defer zipResp.Body.Close()
253 + if zipResp.StatusCode != http.StatusOK {
254 + t.Fatalf("GET archive zip status %d", zipResp.StatusCode)
255 + }
256 + zipBytes, _ := io.ReadAll(zipResp.Body)
257 + if len(zipBytes) < 4 || zipBytes[0] != 'P' || zipBytes[1] != 'K' {
258 + t.Fatalf("expected valid ZIP magic bytes, got len=%d", len(zipBytes))
259 + }
260 +}
261 +
262 +func TestFeaturesTogglesAndProfile(t *testing.T) {
263 + app, _, userSvc, repoSvc := setupTestApp(t)
264 + ctx := context.Background()
265 +
266 + // 1. Create a user
267 + u, err := userSvc.Register(ctx, "rin", "rin@example.com", "pass12345", true)
268 + if err != nil {
269 + t.Fatalf("register: %v", err)
270 + }
271 +
272 + // 2. Update user profile with avatar and details
273 + avatarData := "data:image/webp;base64,UklGRkAAAABXRUJQVlA4IDQAAADwAQCdASoBAAEAAQAcJaACdLoAAP7/2wAA"
274 + if err := userSvc.UpdateProfileDetails(ctx, u.ID, "rin@example.com", "Rin Tester", "Developer", "Tokyo", "https://example.com"); err != nil {
275 + t.Fatalf("update profile details: %v", err)
276 + }
277 + if err := userSvc.UpdateAvatar(ctx, u.ID, avatarData); err != nil {
278 + t.Fatalf("update avatar: %v", err)
279 + }
280 +
281 + // Check profile page
282 + reqProfile := httptest.NewRequest(http.MethodGet, "/rin", nil)
283 + respProfile, err := app.Test(reqProfile, 5000)
284 + if err != nil || respProfile.StatusCode != http.StatusOK {
285 + t.Fatalf("profile page failed: status=%d err=%v", respProfile.StatusCode, err)
286 + }
287 + profBody, _ := io.ReadAll(respProfile.Body)
288 + profHTML := string(profBody)
289 + if !strings.Contains(profHTML, "Rin Tester") || !strings.Contains(profHTML, "data:image/webp") {
290 + t.Fatalf("profile page missing updated name or avatar")
291 + }
292 +
293 + // 3. Create a repo and an issue
294 + r, err := repoSvc.Create(ctx, u, "test-repo", "A test repo", false)
295 + if err != nil {
296 + t.Fatalf("create repo: %v", err)
297 + }
298 + issue, err := repoSvc.CreateIssue(ctx, r.ID, u.ID, "First Bug", "Something went wrong")
299 + if err != nil {
300 + t.Fatalf("create issue: %v", err)
301 + }
302 + if issue.Number != 1 {
303 + t.Fatalf("expected issue number 1, got %d", issue.Number)
304 + }
305 + if err := repoSvc.CreateIssueComment(ctx, issue.ID, u.ID, "Fixed in commit 123"); err != nil {
306 + t.Fatalf("add issue comment: %v", err)
307 + }
308 +
309 + reqIssues := httptest.NewRequest(http.MethodGet, "/rin/test-repo/issues", nil)
310 + respIssues, err := app.Test(reqIssues, 5000)
311 + if err != nil || respIssues.StatusCode != http.StatusOK {
312 + t.Fatalf("issues list failed: status=%d err=%v", respIssues.StatusCode, err)
313 + }
314 + issBody, _ := io.ReadAll(respIssues.Body)
315 + if !strings.Contains(string(issBody), "First Bug") {
316 + t.Fatalf("issues list missing issue title")
317 + }
318 +
319 + reqIssueDetail := httptest.NewRequest(http.MethodGet, "/rin/test-repo/issues/1", nil)
320 + respIssueDetail, err := app.Test(reqIssueDetail, 5000)
321 + if err != nil || respIssueDetail.StatusCode != http.StatusOK {
322 + t.Fatalf("issue detail failed: status=%d err=%v", respIssueDetail.StatusCode, err)
323 + }
324 + issDetailBody, _ := io.ReadAll(respIssueDetail.Body)
325 + if !strings.Contains(string(issDetailBody), "Fixed in commit 123") {
326 + t.Fatalf("issue detail missing comment")
327 + }
328 +}
internal/web/handlers/pulls.go
+393 -0
... ... @@ -0,0 +1,393 @@
1 +package handlers
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "strconv"
7 + "strings"
8 +
9 + "github.com/gofiber/fiber/v2"
10 +
11 + "polar/internal/git"
12 + "polar/internal/repo"
13 + "polar/internal/user"
14 + "polar/internal/web/middleware"
15 + "polar/internal/web/views"
16 +)
17 +
18 +// headRepoCandidates lists repositories a viewer may open a pull request from:
19 +// the base repository itself plus the viewer's forks of it.
20 +func (h *Handler) headRepoCandidates(c *fiber.Ctx, r *repo.Repository, viewer *user.User) []*repo.Repository {
21 + candidates := []*repo.Repository{r}
22 + if viewer == nil {
23 + return candidates
24 + }
25 + forks, _ := h.repoSvc.ListForks(c.UserContext(), r.ID, 100)
26 + for _, f := range forks {
27 + if f.OwnerID == viewer.ID {
28 + fork := f
29 + candidates = append(candidates, &fork)
30 + }
31 + }
32 + return candidates
33 +}
34 +
35 +func (h *Handler) findHeadRepo(candidates []*repo.Repository, id int64) *repo.Repository {
36 + for _, cand := range candidates {
37 + if cand.ID == id {
38 + return cand
39 + }
40 + }
41 + return nil
42 +}
43 +
44 +// resolveHeadRef makes headBranch of headRepo visible inside baseDisk.
45 +// For same-repository PRs the branch name is returned unchanged. For
46 +// cross-repository (fork) PRs the branch is fetched into a temporary ref and
47 +// the returned cleanup removes it again.
48 +func (h *Handler) resolveHeadRef(c *fiber.Ctx, r *repo.Repository, baseDisk string, headRepoID int64, headBranch string) (ref string, headRepo *repo.Repository, cleanup func(), err error) {
49 + noop := func() {}
50 + if headRepoID == 0 || headRepoID == r.ID {
51 + return headBranch, r, noop, nil
52 + }
53 + hr, err := h.repoSvc.GetByID(c.UserContext(), headRepoID)
54 + if err != nil || hr == nil {
55 + return "", nil, noop, fmt.Errorf("head repository not found")
56 + }
57 + if hr.ForkedFromRepoID == nil || *hr.ForkedFromRepoID != r.ID {
58 + return "", nil, noop, fmt.Errorf("head repository is not a fork of this repository")
59 + }
60 + forkDisk, err := h.repoSvc.DiskPath(hr.OwnerUsername, hr.Name)
61 + if err != nil {
62 + return "", nil, noop, err
63 + }
64 + tmpRef := fmt.Sprintf("refs/polar/tmp-pr-%d", hr.ID)
65 + if err := git.FetchToTemp(c.UserContext(), baseDisk, forkDisk, headBranch, tmpRef); err != nil {
66 + return "", nil, noop, err
67 + }
68 + cleanup = func() { git.DeleteRef(context.WithoutCancel(c.UserContext()), baseDisk, tmpRef) }
69 + return tmpRef, hr, cleanup, nil
70 +}
71 +
72 +func (h *Handler) RepoPullsPage(c *fiber.Ctx) error {
73 + r, _, err := h.loadAuthorizedRepo(c, false)
74 + if err != nil {
75 + return nil
76 + }
77 + if !r.EnablePulls {
78 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
79 + }
80 +
81 + state := c.Query("state", "open")
82 + if state != "open" && state != "closed" && state != "merged" {
83 + state = "open"
84 + }
85 +
86 + pulls, _ := h.repoSvc.ListPullRequests(c.UserContext(), r.ID, state)
87 + openCount, closedCount, mergedCount, _ := h.repoSvc.CountPullRequests(c.UserContext(), r.ID)
88 +
89 + viewer := middleware.CurrentUser(c)
90 + data := h.baseData(c, h.tr(c, "pulls.title")+" · "+r.OwnerUsername+"/"+r.Name)
91 + data["Repo"] = r
92 + data["Ref"] = r.DefaultBranch
93 + data["ActiveTab"] = "pulls"
94 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
95 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
96 + data["Pulls"] = pulls
97 + data["CurrentState"] = state
98 + data["OpenCount"] = openCount
99 + data["ClosedCount"] = closedCount
100 + data["MergedCount"] = mergedCount
101 +
102 + return h.render(c, fiber.StatusOK, "pulls", data)
103 +}
104 +
105 +func (h *Handler) RepoNewPullPage(c *fiber.Ctx) error {
106 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
107 + if err != nil {
108 + return nil
109 + }
110 + if !r.EnablePulls {
111 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
112 + }
113 +
114 + viewer := middleware.CurrentUser(c)
115 + candidates := h.headRepoCandidates(c, r, viewer)
116 +
117 + headRepo := r
118 + if v := strings.TrimSpace(c.Query("head_repo")); v != "" {
119 + if id, convErr := strconv.ParseInt(v, 10, 64); convErr == nil {
120 + if found := h.findHeadRepo(candidates, id); found != nil {
121 + headRepo = found
122 + }
123 + }
124 + }
125 +
126 + headDisk, err := h.repoSvc.DiskPath(headRepo.OwnerUsername, headRepo.Name)
127 + if err != nil {
128 + headDisk = diskPath
129 + }
130 +
131 + branches, _ := git.ListBranches(c.UserContext(), headDisk)
132 + baseBranches, _ := git.ListBranches(c.UserContext(), diskPath)
133 + baseBranch := strings.TrimSpace(c.Query("base"))
134 + if baseBranch == "" {
135 + baseBranch = r.DefaultBranch
136 + }
137 + headBranch := strings.TrimSpace(c.Query("head"))
138 + if headBranch == "" && len(branches) > 1 {
139 + for _, b := range branches {
140 + if b != baseBranch || headRepo.ID != r.ID {
141 + headBranch = b
142 + break
143 + }
144 + }
145 + }
146 +
147 + data := h.baseData(c, h.tr(c, "pulls.new")+" · "+r.OwnerUsername+"/"+r.Name)
148 + data["Repo"] = r
149 + data["Ref"] = r.DefaultBranch
150 + data["ActiveTab"] = "pulls"
151 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
152 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
153 + data["Branches"] = branches
154 + data["BaseBranches"] = baseBranches
155 + data["BaseBranch"] = baseBranch
156 + data["HeadBranch"] = headBranch
157 + data["HeadRepo"] = headRepo
158 + data["HeadRepos"] = candidates
159 +
160 + if baseBranch != "" && headBranch != "" && (baseBranch != headBranch || headRepo.ID != r.ID) {
161 + headRef, _, cleanup, refErr := h.resolveHeadRef(c, r, diskPath, headRepo.ID, headBranch)
162 + if refErr == nil {
163 + defer cleanup()
164 + diffDetail, cmpCommits, cmpErr := git.CompareBranches(c.UserContext(), diskPath, baseBranch, headRef)
165 + if cmpErr == nil {
166 + data["Compare"] = diffDetail
167 + data["CompareCommits"] = cmpCommits
168 + }
169 + canMerge, _, _ := git.CanMergeBranches(c.UserContext(), diskPath, baseBranch, headRef)
170 + data["CanMerge"] = canMerge
171 + } else {
172 + data["CompareError"] = refErr.Error()
173 + }
174 + }
175 +
176 + return h.render(c, fiber.StatusOK, "pull_new", data)
177 +}
178 +
179 +func (h *Handler) RepoNewPullSubmit(c *fiber.Ctx) error {
180 + r, _, err := h.loadAuthorizedRepo(c, false)
181 + if err != nil {
182 + return nil
183 + }
184 + if !r.EnablePulls {
185 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
186 + }
187 +
188 + u := middleware.CurrentUser(c)
189 + title := strings.TrimSpace(c.FormValue("title"))
190 + body := strings.TrimSpace(c.FormValue("body"))
191 + baseBranch := strings.TrimSpace(c.FormValue("base_branch"))
192 + headBranch := strings.TrimSpace(c.FormValue("head_branch"))
193 + headRepoRaw := strings.TrimSpace(c.FormValue("head_repo"))
194 +
195 + var headRepoID *int64
196 + if headRepoRaw != "" {
197 + if id, convErr := strconv.ParseInt(headRepoRaw, 10, 64); convErr == nil && id != r.ID {
198 + candidates := h.headRepoCandidates(c, r, u)
199 + if h.findHeadRepo(candidates, id) == nil {
200 + h.setFlashT(c, "flash.error", "err.pr_head_repo_invalid")
201 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
202 + }
203 + headRepoID = &id
204 + }
205 + }
206 +
207 + pr, err := h.repoSvc.CreatePullRequest(c.UserContext(), r.ID, u.ID, title, body, baseBranch, headBranch, headRepoID)
208 + if err != nil {
209 + h.setFlashT(c, "flash.error", err.Error())
210 + redirect := fmt.Sprintf("/%s/%s/pulls/new?base=%s&head=%s", r.OwnerUsername, r.Name, baseBranch, headBranch)
211 + if headRepoID != nil {
212 + redirect += fmt.Sprintf("&head_repo=%d", *headRepoID)
213 + }
214 + return c.Redirect(redirect, fiber.StatusSeeOther)
215 + }
216 +
217 + h.setFlashT(c, "flash.pr_created", pr.Number)
218 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther)
219 +}
220 +
221 +func (h *Handler) RepoPullDetailPage(c *fiber.Ctx) error {
222 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
223 + if err != nil {
224 + return nil
225 + }
226 + if !r.EnablePulls {
227 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
228 + }
229 +
230 + num, _ := strconv.Atoi(c.Params("number"))
231 + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num)
232 + if err != nil {
233 + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc")
234 + }
235 +
236 + comments, _ := h.repoSvc.ListPullRequestComments(c.UserContext(), pr.ID)
237 + reviews, _ := h.repoSvc.ListReviews(c.UserContext(), pr.ID)
238 + approved, changesRequested := h.repoSvc.ReviewCounts(c.UserContext(), pr.ID)
239 +
240 + var headRef string
241 + var cleanup func()
242 + if pr.HeadRepoID != nil && *pr.HeadRepoID != r.ID {
243 + ref, _, cl, refErr := h.resolveHeadRef(c, r, diskPath, *pr.HeadRepoID, pr.HeadBranch)
244 + if refErr == nil {
245 + headRef = ref
246 + cleanup = cl
247 + defer cleanup()
248 + }
249 + } else {
250 + headRef = pr.HeadBranch
251 + }
252 +
253 + var diffDetail *git.CommitDetail
254 + var cmpCommits []git.Commit
255 + canMerge := false
256 + if headRef != "" {
257 + diffDetail, cmpCommits, _ = git.CompareBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef)
258 + canMerge, _, _ = git.CanMergeBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef)
259 + }
260 +
261 + viewer := middleware.CurrentUser(c)
262 + canWrite := h.repoSvc.CanWrite(c.UserContext(), r, viewer)
263 + canToggle := viewer != nil && (viewer.ID == pr.AuthorID || canWrite)
264 + canMergeAction := viewer != nil && canWrite && pr.Status == "open" && canMerge
265 + canReview := viewer != nil && viewer.ID != pr.AuthorID && pr.Status == "open"
266 +
267 + subTab := c.Query("tab", "conversation")
268 + if subTab != "conversation" && subTab != "commits" && subTab != "files" {
269 + subTab = "conversation"
270 + }
271 +
272 + data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", pr.Number, pr.Title, r.OwnerUsername, r.Name))
273 + data["Repo"] = r
274 + data["Ref"] = r.DefaultBranch
275 + data["ActiveTab"] = "pulls"
276 + data["CanWrite"] = canWrite
277 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
278 + data["PR"] = pr
279 + data["Comments"] = comments
280 + data["Reviews"] = reviews
281 + data["ApprovedCount"] = approved
282 + data["ChangesRequestedCount"] = changesRequested
283 + data["CanReview"] = canReview
284 + data["Compare"] = diffDetail
285 + data["CompareCommits"] = cmpCommits
286 + data["CanMerge"] = canMerge
287 + data["CanToggle"] = canToggle
288 + data["CanMergeAction"] = canMergeAction
289 + data["SubTab"] = subTab
290 + data["BodyHTML"] = views.RenderMarkdown([]byte(pr.Body))
291 +
292 + return h.render(c, fiber.StatusOK, "pull_detail", data)
293 +}
294 +
295 +func (h *Handler) RepoPullCommentSubmit(c *fiber.Ctx) error {
296 + r, _, err := h.loadAuthorizedRepo(c, false)
297 + if err != nil {
298 + return nil
299 + }
300 + if !r.EnablePulls {
301 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
302 + }
303 +
304 + num, _ := strconv.Atoi(c.Params("number"))
305 + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num)
306 + if err != nil {
307 + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc")
308 + }
309 +
310 + u := middleware.CurrentUser(c)
311 + body := strings.TrimSpace(c.FormValue("body"))
312 + if err := h.repoSvc.CreatePullRequestComment(c.UserContext(), pr.ID, u.ID, body); err != nil {
313 + h.setFlashT(c, "flash.comment_error", err.Error())
314 + }
315 +
316 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther)
317 +}
318 +
319 +func (h *Handler) RepoPullStateSubmit(c *fiber.Ctx) error {
320 + r, _, err := h.loadAuthorizedRepo(c, false)
321 + if err != nil {
322 + return nil
323 + }
324 + if !r.EnablePulls {
325 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
326 + }
327 +
328 + num, _ := strconv.Atoi(c.Params("number"))
329 + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num)
330 + if err != nil {
331 + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc")
332 + }
333 +
334 + viewer := middleware.CurrentUser(c)
335 + if viewer == nil || (viewer.ID != pr.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) {
336 + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_state_forbidden")
337 + }
338 +
339 + if pr.Status == "merged" {
340 + h.setFlashT(c, "flash.pr_merged_state")
341 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther)
342 + }
343 +
344 + targetState := strings.TrimSpace(c.FormValue("state"))
345 + if targetState == "closed" {
346 + _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "closed", nil)
347 + h.setFlashT(c, "flash.pr_closed", pr.Number)
348 + } else if targetState == "open" {
349 + _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "open", nil)
350 + h.setFlashT(c, "flash.pr_reopened", pr.Number)
351 + }
352 +
353 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther)
354 +}
355 +
356 +func (h *Handler) RepoPullMergeSubmit(c *fiber.Ctx) error {
357 + r, _, err := h.loadAuthorizedRepo(c, true)
358 + if err != nil {
359 + return nil
360 + }
361 + if !r.EnablePulls {
362 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
363 + }
364 +
365 + num, _ := strconv.Atoi(c.Params("number"))
366 + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num)
367 + if err != nil {
368 + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc")
369 + }
370 +
371 + if pr.Status != "open" {
372 + h.setFlashT(c, "flash.pr_merge_open_only")
373 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther)
374 + }
375 +
376 + viewer := middleware.CurrentUser(c)
377 + if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) {
378 + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_merge_forbidden")
379 + }
380 +
381 + authorName := viewer.FullName
382 + if authorName == "" {
383 + authorName = viewer.Username
384 + }
385 +
386 + if mergeErr := h.repoSvc.MergePullRequest(c.UserContext(), r, pr, authorName, viewer.Email, viewer.ID); mergeErr != nil {
387 + h.setFlashT(c, "flash.pr_merge_failed", mergeErr.Error())
388 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther)
389 + }
390 +
391 + h.setFlashT(c, "flash.pr_merged", pr.Number)
392 + return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther)
393 +}
internal/web/handlers/releases.go
+150 -0
... ... @@ -0,0 +1,150 @@
1 +package handlers
2 +
3 +import (
4 + "fmt"
5 + "html/template"
6 + "strconv"
7 + "strings"
8 +
9 + "github.com/gofiber/fiber/v2"
10 +
11 + "polar/internal/git"
12 + "polar/internal/repo"
13 + "polar/internal/web/middleware"
14 + "polar/internal/web/views"
15 +)
16 +
17 +type ReleaseViewItem struct {
18 + repo.Release
19 + BodyHTML template.HTML
20 +}
21 +
22 +func (h *Handler) RepoReleasesPage(c *fiber.Ctx) error {
23 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
24 + if err != nil {
25 + return nil
26 + }
27 + if !r.EnableReleases {
28 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
29 + }
30 +
31 + releases, _ := h.repoSvc.ListReleases(c.UserContext(), r.ID)
32 + tags, _ := git.ListTags(c.UserContext(), diskPath)
33 +
34 + var releaseItems []ReleaseViewItem
35 + for _, rel := range releases {
36 + rendered := views.RenderMarkdown([]byte(rel.Body))
37 + releaseItems = append(releaseItems, ReleaseViewItem{
38 + Release: rel,
39 + BodyHTML: rendered,
40 + })
41 + }
42 +
43 + viewer := middleware.CurrentUser(c)
44 + data := h.baseData(c, h.tr(c, "releases.title")+" · "+r.OwnerUsername+"/"+r.Name)
45 + data["Repo"] = r
46 + data["Ref"] = r.DefaultBranch
47 + data["ActiveTab"] = "releases"
48 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
49 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
50 + data["Releases"] = releaseItems
51 + data["TagCount"] = len(tags)
52 +
53 + return h.render(c, fiber.StatusOK, "releases", data)
54 +}
55 +
56 +func (h *Handler) RepoNewReleasePage(c *fiber.Ctx) error {
57 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
58 + if err != nil {
59 + return nil
60 + }
61 + if !r.EnableReleases {
62 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
63 + }
64 +
65 + tags, _ := git.ListTags(c.UserContext(), diskPath)
66 + branches, _ := git.ListBranches(c.UserContext(), diskPath)
67 + selectedTag := strings.TrimSpace(c.Query("tag"))
68 +
69 + viewer := middleware.CurrentUser(c)
70 + data := h.baseData(c, h.tr(c, "releases.new")+" · "+r.OwnerUsername+"/"+r.Name)
71 + data["Repo"] = r
72 + data["Ref"] = r.DefaultBranch
73 + data["ActiveTab"] = "releases"
74 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
75 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
76 + data["Tags"] = tags
77 + data["Branches"] = branches
78 + data["SelectedTag"] = selectedTag
79 +
80 + return h.render(c, fiber.StatusOK, "release_new", data)
81 +}
82 +
83 +func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error {
84 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
85 + if err != nil {
86 + return nil
87 + }
88 + if !r.EnableReleases {
89 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
90 + }
91 +
92 + u := middleware.CurrentUser(c)
93 + tagName := strings.TrimSpace(c.FormValue("tag_name"))
94 + title := strings.TrimSpace(c.FormValue("title"))
95 + body := strings.TrimSpace(c.FormValue("body"))
96 + targetRef := strings.TrimSpace(c.FormValue("target_ref"))
97 + if targetRef == "" {
98 + targetRef = r.DefaultBranch
99 + }
100 + isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1"
101 +
102 + if tagName == "" {
103 + h.setFlashT(c, "flash.release_tag_empty")
104 + return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
105 + }
106 +
107 + // Create git tag if it doesn't exist yet
108 + existingTags, _ := git.ListTags(c.UserContext(), diskPath)
109 + tagExists := false
110 + for _, t := range existingTags {
111 + if t == tagName {
112 + tagExists = true
113 + break
114 + }
115 + }
116 + if !tagExists {
117 + if err := git.CreateTag(c.UserContext(), diskPath, tagName, targetRef, title); err != nil {
118 + h.setFlashT(c, "flash.release_tag_failed", err.Error())
119 + return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
120 + }
121 + }
122 +
123 + if _, err := h.repoSvc.CreateRelease(c.UserContext(), r.ID, u.ID, tagName, title, body, false, isPrerelease); err != nil {
124 + h.setFlashT(c, "flash.release_create_failed", err.Error())
125 + return c.Redirect(fmt.Sprintf("/%s/%s/releases/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
126 + }
127 +
128 + h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID)
129 + h.setFlashT(c, "flash.release_published", tagName)
130 + return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
131 +}
132 +
133 +func (h *Handler) RepoDeleteReleaseSubmit(c *fiber.Ctx) error {
134 + r, _, err := h.loadAuthorizedRepo(c, true)
135 + if err != nil {
136 + return nil
137 + }
138 + if !r.EnableReleases {
139 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled")
140 + }
141 +
142 + relID, _ := strconv.ParseInt(c.Params("id"), 10, 64)
143 + if err := h.repoSvc.DeleteRelease(c.UserContext(), relID); err != nil {
144 + h.setFlashT(c, "flash.release_delete_failed", err.Error())
145 + } else {
146 + h.setFlashT(c, "flash.release_deleted")
147 + }
148 +
149 + return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
150 +}
internal/web/handlers/repo.go
+721 -0
... ... @@ -0,0 +1,721 @@
1 +package handlers
2 +
3 +import (
4 + "fmt"
5 + "net/url"
6 + "path"
7 + "strconv"
8 + "strings"
9 +
10 + "polar/internal/git"
11 + "polar/internal/i18n"
12 + "polar/internal/repo"
13 + "polar/internal/web/middleware"
14 + "polar/internal/web/views"
15 +
16 + "github.com/gofiber/fiber/v2"
17 +)
18 +
19 +type BreadcrumbItem struct {
20 + Name string
21 + Path string
22 + IsLast bool
23 +}
24 +
25 +func buildBreadcrumbs(subPath string) []BreadcrumbItem {
26 + subPath = strings.Trim(subPath, "/")
27 + if subPath == "" {
28 + return nil
29 + }
30 + parts := strings.Split(subPath, "/")
31 + items := make([]BreadcrumbItem, 0, len(parts))
32 + var acc []string
33 + for idx, p := range parts {
34 + acc = append(acc, p)
35 + items = append(items, BreadcrumbItem{
36 + Name: p,
37 + Path: strings.Join(acc, "/"),
38 + IsLast: idx == len(parts)-1,
39 + })
40 + }
41 + return items
42 +}
43 +
44 +func (h *Handler) NewRepoPage(c *fiber.Ctx) error {
45 + lang, _ := c.Locals("lang").(i18n.Lang)
46 + data := h.baseData(c, i18n.T(lang, "repo.new.title"))
47 + return h.render(c, fiber.StatusOK, "repo_new", data)
48 +}
49 +
50 +func (h *Handler) NewRepoSubmit(c *fiber.Ctx) error {
51 + u := middleware.CurrentUser(c)
52 + lang, _ := c.Locals("lang").(i18n.Lang)
53 +
54 + name := strings.TrimSpace(c.FormValue("name"))
55 + desc := strings.TrimSpace(c.FormValue("description"))
56 + isPriv := c.FormValue("visibility") == "private"
57 +
58 + r, err := h.repoSvc.Create(c.UserContext(), u, name, desc, isPriv)
59 + if err != nil {
60 + data := h.baseData(c, i18n.T(lang, "repo.new.title"))
61 + data["FormName"] = name
62 + data["FormDescription"] = desc
63 + data["Error"] = err.Error()
64 + return h.render(c, fiber.StatusBadRequest, "repo_new", data)
65 + }
66 +
67 + return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
68 +}
69 +
70 +func (h *Handler) loadAuthorizedRepo(c *fiber.Ctx, requireWrite bool) (*repo.Repository, string, error) {
71 + owner := c.Params("owner")
72 + repoName := strings.TrimSuffix(c.Params("repo"), ".git")
73 + viewer := middleware.CurrentUser(c)
74 +
75 + if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) {
76 + _ = c.Redirect("/login", fiber.StatusSeeOther)
77 + return nil, "", fiber.ErrUnauthorized
78 + }
79 +
80 + r, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), owner, repoName)
81 + if err != nil {
82 + _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_not_found_desc")
83 + return nil, "", err
84 + }
85 +
86 + if requireWrite {
87 + if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) {
88 + _ = h.renderErrorT(c, fiber.StatusForbidden, "err.access_denied", "err.repo_write_denied")
89 + return nil, "", fiber.ErrForbidden
90 + }
91 + } else {
92 + if !h.repoSvc.CanRead(c.UserContext(), r, viewer) {
93 + _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_private_desc")
94 + return nil, "", fiber.ErrNotFound
95 + }
96 + }
97 +
98 + diskPath, err := h.repoSvc.DiskPath(r.OwnerUsername, r.Name)
99 + if err != nil {
100 + _ = h.renderError(c, fiber.StatusBadRequest, i18n.T(h.langOf(c), "err.invalid_repo_path"), err.Error())
101 + return nil, "", err
102 + }
103 +
104 + return r, diskPath, nil
105 +}
106 +
107 +func (h *Handler) RepoHomePage(c *fiber.Ctx) error {
108 + return h.renderRepoTree(c, "", "")
109 +}
110 +
111 +func (h *Handler) RepoTreePage(c *fiber.Ctx) error {
112 + ref, _ := url.PathUnescape(c.Params("ref"))
113 + subPath, _ := url.PathUnescape(c.Params("*"))
114 + return h.renderRepoTree(c, ref, subPath)
115 +}
116 +
117 +func (h *Handler) renderRepoTree(c *fiber.Ctx, ref, subPath string) error {
118 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
119 + if err != nil {
120 + return nil
121 + }
122 + viewer := middleware.CurrentUser(c)
123 +
124 + headBranch, isEmpty := git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
125 + if ref == "" {
126 + ref = headBranch
127 + }
128 +
129 + cloneURL := h.cloneURL(c, r.OwnerUsername, r.Name)
130 + data := h.baseData(c, r.OwnerUsername+"/"+r.Name)
131 + data["Repo"] = r
132 + data["Ref"] = ref
133 + data["SubPath"] = strings.Trim(subPath, "/")
134 + data["ActiveTab"] = "code"
135 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
136 + data["CloneURL"] = cloneURL
137 + data["IsEmpty"] = isEmpty
138 +
139 + if isEmpty {
140 + data["QuickStartNew"] = fmt.Sprintf(
141 + "echo \"# %s\" >> README.md\ngit init -b %s\ngit add README.md\ngit commit -m \"Initial commit\"\ngit remote add origin %s\ngit push -u origin %s",
142 + r.Name, r.DefaultBranch, cloneURL, r.DefaultBranch,
143 + )
144 + data["QuickStartExisting"] = fmt.Sprintf(
145 + "git remote add origin %s\ngit branch -M %s\ngit push -u origin %s",
146 + cloneURL, r.DefaultBranch, r.DefaultBranch,
147 + )
148 + return h.render(c, fiber.StatusOK, "repo", data)
149 + }
150 +
151 + branches, _ := git.ListBranches(c.UserContext(), diskPath)
152 + tags, _ := git.ListTags(c.UserContext(), diskPath)
153 + entries, err := git.ListTree(c.UserContext(), diskPath, ref, subPath)
154 + if err != nil {
155 + return h.renderErrorT(c, fiber.StatusNotFound, "err.path_not_found", "err.path_not_found_desc", ref)
156 + }
157 +
158 + latestCommit, _ := git.GetLatestCommit(c.UserContext(), diskPath, ref, subPath)
159 + allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref)
160 +
161 + data["Branches"] = branches
162 + data["Tags"] = tags
163 + data["Entries"] = entries
164 + data["LatestCommit"] = latestCommit
165 + data["PaletteFiles"] = allFiles
166 + data["Breadcrumbs"] = buildBreadcrumbs(subPath)
167 + if strings.Trim(subPath, "/") != "" {
168 + data["ParentPath"] = path.Dir(strings.Trim(subPath, "/"))
169 + }
170 +
171 + if readmeBlob, err := git.FindReadme(c.UserContext(), diskPath, ref, subPath); err == nil && readmeBlob != nil {
172 + data["ReadmeName"] = readmeBlob.Name
173 + data["ReadmeHTML"] = views.RenderMarkdown(readmeBlob.Data)
174 + }
175 +
176 + return h.render(c, fiber.StatusOK, "repo", data)
177 +}
178 +
179 +func (h *Handler) RepoBlobPage(c *fiber.Ctx) error {
180 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
181 + if err != nil {
182 + return nil
183 + }
184 + viewer := middleware.CurrentUser(c)
185 +
186 + ref, _ := url.PathUnescape(c.Params("ref"))
187 + subPath, _ := url.PathUnescape(c.Params("*"))
188 +
189 + blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath)
190 + if err != nil {
191 + return h.renderErrorT(c, fiber.StatusNotFound, "err.file_not_found", "err.file_not_found_desc", ref)
192 + }
193 +
194 + branches, _ := git.ListBranches(c.UserContext(), diskPath)
195 + tags, _ := git.ListTags(c.UserContext(), diskPath)
196 + allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref)
197 +
198 + data := h.baseData(c, blob.Name+" · "+r.OwnerUsername+"/"+r.Name)
199 + data["Repo"] = r
200 + data["Ref"] = ref
201 + data["SubPath"] = blob.Path
202 + data["ActiveTab"] = "code"
203 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
204 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
205 + data["Branches"] = branches
206 + data["Tags"] = tags
207 + data["Blob"] = blob
208 + data["Breadcrumbs"] = buildBreadcrumbs(blob.Path)
209 + data["PaletteFiles"] = allFiles
210 +
211 + if !blob.IsBinary {
212 + rawText := string(blob.Data)
213 + data["RawText"] = rawText
214 + data["HighlightedLines"] = views.HighlightCode(blob.Name, rawText)
215 + }
216 +
217 + return h.render(c, fiber.StatusOK, "blob", data)
218 +}
219 +
220 +func (h *Handler) RepoRawBlob(c *fiber.Ctx) error {
221 + _, diskPath, err := h.loadAuthorizedRepo(c, false)
222 + if err != nil {
223 + return nil
224 + }
225 +
226 + ref, _ := url.PathUnescape(c.Params("ref"))
227 + subPath, _ := url.PathUnescape(c.Params("*"))
228 +
229 + blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath)
230 + if err != nil {
231 + return c.Status(fiber.StatusNotFound).SendString("File not found")
232 + }
233 +
234 + if blob.IsBinary {
235 + c.Set(fiber.HeaderContentType, "application/octet-stream")
236 + } else {
237 + c.Set(fiber.HeaderContentType, "text/plain; charset=utf-8")
238 + }
239 + return c.Send(blob.Data)
240 +}
241 +
242 +func (h *Handler) RepoCommitsPage(c *fiber.Ctx) error {
243 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
244 + if err != nil {
245 + return nil
246 + }
247 + viewer := middleware.CurrentUser(c)
248 +
249 + ref, _ := url.PathUnescape(c.Params("ref"))
250 + if ref == "" {
251 + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
252 + }
253 +
254 + page, _ := strconv.Atoi(c.Query("page", "1"))
255 + if page < 1 {
256 + page = 1
257 + }
258 + const perPage = 30
259 + offset := (page - 1) * perPage
260 +
261 + commits, _ := git.ListCommits(c.UserContext(), diskPath, ref, perPage+1, offset)
262 + hasMore := false
263 + if len(commits) > perPage {
264 + hasMore = true
265 + commits = commits[:perPage]
266 + }
267 + branches, _ := git.ListBranches(c.UserContext(), diskPath)
268 +
269 + data := h.baseData(c, h.tr(c, "repo.tab.commits")+" · "+r.OwnerUsername+"/"+r.Name)
270 + data["Repo"] = r
271 + data["Ref"] = ref
272 + data["ActiveTab"] = "commits"
273 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
274 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
275 + data["Branches"] = branches
276 + data["Commits"] = commits
277 + data["Page"] = page
278 + data["HasMore"] = hasMore
279 +
280 + return h.render(c, fiber.StatusOK, "commits", data)
281 +}
282 +
283 +func (h *Handler) RepoCommitDetailPage(c *fiber.Ctx) error {
284 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
285 + if err != nil {
286 + return nil
287 + }
288 + viewer := middleware.CurrentUser(c)
289 + hash := c.Params("hash")
290 +
291 + detail, err := git.GetCommitDiff(c.UserContext(), diskPath, hash)
292 + if err != nil {
293 + return h.renderErrorT(c, fiber.StatusNotFound, "err.commit_not_found", "err.commit_not_found_desc", hash)
294 + }
295 +
296 + data := h.baseData(c, detail.Commit.ShortHash+" · "+r.OwnerUsername+"/"+r.Name)
297 + data["Repo"] = r
298 + data["Ref"] = r.DefaultBranch
299 + data["ActiveTab"] = "commits"
300 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
301 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
302 + data["Detail"] = detail
303 +
304 + return h.render(c, fiber.StatusOK, "commit", data)
305 +}
306 +
307 +func (h *Handler) RepoSettingsPage(c *fiber.Ctx) error {
308 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
309 + if err != nil {
310 + return nil
311 + }
312 + branches, _ := git.ListBranches(c.UserContext(), diskPath)
313 +
314 + data := h.baseData(c, h.tr(c, "repo.tab.settings")+" · "+r.OwnerUsername+"/"+r.Name)
315 + data["Repo"] = r
316 + data["Ref"] = r.DefaultBranch
317 + data["ActiveTab"] = "settings"
318 + data["CanWrite"] = true
319 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
320 + data["Branches"] = branches
321 +
322 + viewer := middleware.CurrentUser(c)
323 + collaborators, _ := h.repoSvc.ListCollaborators(c.UserContext(), r.ID)
324 + data["Collaborators"] = collaborators
325 + data["CanManageCollab"] = viewer != nil && (viewer.ID == r.OwnerID || viewer.IsAdmin)
326 +
327 + return h.render(c, fiber.StatusOK, "repo_settings", data)
328 +}
329 +
330 +func (h *Handler) RepoSettingsSubmit(c *fiber.Ctx) error {
331 + r, _, err := h.loadAuthorizedRepo(c, true)
332 + if err != nil {
333 + return nil
334 + }
335 +
336 + desc := c.FormValue("description")
337 + defaultBranch := c.FormValue("default_branch")
338 + isPriv := c.FormValue("visibility") == "private"
339 +
340 + enableIssues := c.FormValue("enable_issues") == "true" || c.FormValue("enable_issues") == "on" || c.FormValue("enable_issues") == "1"
341 + enablePulls := c.FormValue("enable_pulls") == "true" || c.FormValue("enable_pulls") == "on" || c.FormValue("enable_pulls") == "1"
342 + enableReleases := c.FormValue("enable_releases") == "true" || c.FormValue("enable_releases") == "on" || c.FormValue("enable_releases") == "1"
343 + enableActivity := c.FormValue("enable_activity") == "true" || c.FormValue("enable_activity") == "on" || c.FormValue("enable_activity") == "1"
344 +
345 + if err := h.repoSvc.Update(c.UserContext(), r, desc, isPriv, defaultBranch, enableIssues, enablePulls, enableReleases, enableActivity); err != nil {
346 + h.setFlashT(c, "flash.error", err.Error())
347 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
348 + }
349 +
350 + h.setFlashT(c, "flash.repo_settings_saved")
351 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
352 +}
353 +
354 +func (h *Handler) RepoRenameSubmit(c *fiber.Ctx) error {
355 + r, _, err := h.loadAuthorizedRepo(c, true)
356 + if err != nil {
357 + return nil
358 + }
359 +
360 + newName := strings.TrimSpace(c.FormValue("new_name"))
361 + if err := h.repoSvc.Rename(c.UserContext(), r, newName); err != nil {
362 + h.setFlashT(c, "flash.repo_rename_failed", err.Error())
363 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
364 + }
365 +
366 + h.setFlashT(c, "flash.repo_renamed", r.Name)
367 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
368 +}
369 +
370 +func (h *Handler) RepoDeleteSubmit(c *fiber.Ctx) error {
371 + r, _, err := h.loadAuthorizedRepo(c, true)
372 + if err != nil {
373 + return nil
374 + }
375 +
376 + confirmName := strings.TrimSpace(c.FormValue("confirm_name"))
377 + if !strings.EqualFold(confirmName, r.Name) {
378 + h.setFlashT(c, "flash.repo_confirm_mismatch")
379 + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
380 + }
381 +
382 + if err := h.repoSvc.Delete(c.UserContext(), r); err != nil {
383 + h.logger.Error("delete repo failed", "err", err)
384 + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.delete_error", "err.repo_delete_failed")
385 + }
386 +
387 + h.setFlashT(c, "flash.repo_deleted", r.Name)
388 + return c.Redirect("/repos", fiber.StatusSeeOther)
389 +}
390 +
391 +func (h *Handler) UserProfilePage(c *fiber.Ctx) error {
392 + username := c.Params("username")
393 + profileUser, err := h.userSvc.GetByUsername(c.UserContext(), username)
394 + if err != nil {
395 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.user_not_found", username)
396 + }
397 +
398 + viewer := middleware.CurrentUser(c)
399 + if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) {
400 + return c.Redirect("/login", fiber.StatusSeeOther)
401 + }
402 +
403 + includePrivate := viewer != nil && (viewer.ID == profileUser.ID || viewer.IsAdmin)
404 + repos, _ := h.repoSvc.ListByOwner(c.UserContext(), profileUser.ID, includePrivate)
405 +
406 + publicCount := 0
407 + privateCount := 0
408 + for _, rp := range repos {
409 + if rp.IsPrivate {
410 + privateCount++
411 + } else {
412 + publicCount++
413 + }
414 + }
415 +
416 + lang, _ := c.Locals("lang").(i18n.Lang)
417 + title := "@" + profileUser.Username
418 + if profileUser.FullName != "" {
419 + title = profileUser.FullName + " (@" + profileUser.Username + ")"
420 + }
421 + data := h.baseData(c, title)
422 + data["ProfileUser"] = profileUser
423 + data["PageHeading"] = i18n.T(lang, "nav.repositories")
424 + data["Repos"] = repos
425 + data["PublicRepoCount"] = publicCount
426 + data["PrivateRepoCount"] = privateCount
427 + data["ShowPrivateCount"] = includePrivate
428 + return h.render(c, fiber.StatusOK, "profile", data)
429 +}
430 +
431 +func (h *Handler) RepoBranchesPage(c *fiber.Ctx) error {
432 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
433 + if err != nil {
434 + return nil
435 + }
436 + viewer := middleware.CurrentUser(c)
437 +
438 + branches, _ := git.ListBranchDetails(c.UserContext(), diskPath, r.DefaultBranch)
439 + tags, _ := git.ListTagDetails(c.UserContext(), diskPath)
440 +
441 + data := h.baseData(c, h.tr(c, "repo.tab.branches")+" · "+r.OwnerUsername+"/"+r.Name)
442 + data["Repo"] = r
443 + data["Ref"] = r.DefaultBranch
444 + data["ActiveTab"] = "branches"
445 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
446 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
447 + data["BranchDetails"] = branches
448 + data["TagDetails"] = tags
449 + data["IsEmpty"] = len(branches) == 0
450 +
451 + return h.render(c, fiber.StatusOK, "branches", data)
452 +}
453 +
454 +func (h *Handler) RepoCreateBranchSubmit(c *fiber.Ctx) error {
455 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
456 + if err != nil {
457 + return nil
458 + }
459 + branchName := strings.TrimSpace(c.FormValue("branch_name"))
460 + fromRef := strings.TrimSpace(c.FormValue("from_ref"))
461 + if fromRef == "" {
462 + fromRef = r.DefaultBranch
463 + }
464 +
465 + if err := git.CreateBranch(c.UserContext(), diskPath, branchName, fromRef); err != nil {
466 + h.setFlashT(c, "flash.branch_create_failed", err.Error())
467 + } else {
468 + h.setFlashT(c, "flash.branch_created", branchName)
469 + }
470 + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
471 +}
472 +
473 +func (h *Handler) RepoDeleteBranchSubmit(c *fiber.Ctx) error {
474 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
475 + if err != nil {
476 + return nil
477 + }
478 + branchName := strings.TrimSpace(c.FormValue("branch_name"))
479 + if strings.EqualFold(branchName, r.DefaultBranch) {
480 + h.setFlashT(c, "flash.branch_default_delete")
481 + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
482 + }
483 +
484 + if err := git.DeleteBranch(c.UserContext(), diskPath, branchName); err != nil {
485 + h.setFlashT(c, "flash.branch_delete_failed", err.Error())
486 + } else {
487 + h.setFlashT(c, "flash.branch_deleted", branchName)
488 + }
489 + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
490 +}
491 +
492 +func (h *Handler) RepoCreateTagSubmit(c *fiber.Ctx) error {
493 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
494 + if err != nil {
495 + return nil
496 + }
497 + tagName := strings.TrimSpace(c.FormValue("tag_name"))
498 + fromRef := strings.TrimSpace(c.FormValue("from_ref"))
499 + if fromRef == "" {
500 + fromRef = r.DefaultBranch
501 + }
502 + message := strings.TrimSpace(c.FormValue("message"))
503 +
504 + if err := git.CreateTag(c.UserContext(), diskPath, tagName, fromRef, message); err != nil {
505 + h.setFlashT(c, "flash.tag_create_failed", err.Error())
506 + } else {
507 + h.setFlashT(c, "flash.tag_created", tagName)
508 + }
509 + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
510 +}
511 +
512 +func (h *Handler) RepoDeleteTagSubmit(c *fiber.Ctx) error {
513 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
514 + if err != nil {
515 + return nil
516 + }
517 + tagName := strings.TrimSpace(c.FormValue("tag_name"))
518 +
519 + if err := git.DeleteTag(c.UserContext(), diskPath, tagName); err != nil {
520 + h.setFlashT(c, "flash.tag_delete_failed", err.Error())
521 + } else {
522 + h.setFlashT(c, "flash.tag_deleted", tagName)
523 + }
524 + return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
525 +}
526 +
527 +func (h *Handler) RepoArchiveZip(c *fiber.Ctx) error {
528 + r, diskPath, err := h.loadAuthorizedRepo(c, false)
529 + if err != nil {
530 + return nil
531 + }
532 +
533 + filename := c.Params("*")
534 + isTarGz := strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz")
535 + ref := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(filename, ".tar.gz"), ".tgz"), ".zip")
536 + ref, _ = url.PathUnescape(ref)
537 + if ref == "" {
538 + ref = r.DefaultBranch
539 + }
540 +
541 + prefix := r.Name + "-" + strings.ReplaceAll(ref, "/", "-")
542 + if isTarGz {
543 + tarBytes, err := git.ArchiveTarGz(c.UserContext(), diskPath, ref, prefix)
544 + if err != nil {
545 + h.logger.Error("archive tar.gz failed", "ref", ref, "err", err)
546 + return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_tar_failed", ref)
547 + }
548 + c.Set(fiber.HeaderContentType, "application/gzip")
549 + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.tar.gz"`, prefix))
550 + return c.Send(tarBytes)
551 + }
552 +
553 + zipBytes, err := git.ArchiveZip(c.UserContext(), diskPath, ref, prefix)
554 + if err != nil {
555 + h.logger.Error("archive zip failed", "ref", ref, "err", err)
556 + return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_zip_failed", ref)
557 + }
558 +
559 + c.Set(fiber.HeaderContentType, "application/zip")
560 + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.zip"`, prefix))
561 + return c.Send(zipBytes)
562 +}
563 +
564 +func (h *Handler) RepoIssuesPage(c *fiber.Ctx) error {
565 + r, _, err := h.loadAuthorizedRepo(c, false)
566 + if err != nil {
567 + return nil
568 + }
569 + if !r.EnableIssues {
570 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
571 + }
572 +
573 + state := c.Query("state", "open")
574 + isClosed := state == "closed"
575 +
576 + issues, _ := h.repoSvc.ListIssues(c.UserContext(), r.ID, isClosed)
577 + openCount, closedCount, _ := h.repoSvc.CountIssues(c.UserContext(), r.ID)
578 +
579 + viewer := middleware.CurrentUser(c)
580 + data := h.baseData(c, h.tr(c, "repo.tab.issues")+" · "+r.OwnerUsername+"/"+r.Name)
581 + data["Repo"] = r
582 + data["Ref"] = r.DefaultBranch
583 + data["ActiveTab"] = "issues"
584 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
585 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
586 + data["Issues"] = issues
587 + data["OpenCount"] = openCount
588 + data["ClosedCount"] = closedCount
589 + data["ShowClosed"] = isClosed
590 +
591 + return h.render(c, fiber.StatusOK, "issues", data)
592 +}
593 +
594 +func (h *Handler) RepoNewIssuePage(c *fiber.Ctx) error {
595 + r, _, err := h.loadAuthorizedRepo(c, false)
596 + if err != nil {
597 + return nil
598 + }
599 + if !r.EnableIssues {
600 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
601 + }
602 +
603 + viewer := middleware.CurrentUser(c)
604 + data := h.baseData(c, h.tr(c, "issue.new")+" · "+r.OwnerUsername+"/"+r.Name)
605 + data["Repo"] = r
606 + data["Ref"] = r.DefaultBranch
607 + data["ActiveTab"] = "issues"
608 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
609 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
610 +
611 + return h.render(c, fiber.StatusOK, "issue_new", data)
612 +}
613 +
614 +func (h *Handler) RepoNewIssueSubmit(c *fiber.Ctx) error {
615 + r, _, err := h.loadAuthorizedRepo(c, false)
616 + if err != nil {
617 + return nil
618 + }
619 + if !r.EnableIssues {
620 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
621 + }
622 +
623 + u := middleware.CurrentUser(c)
624 + title := c.FormValue("title")
625 + body := c.FormValue("body")
626 +
627 + iss, err := h.repoSvc.CreateIssue(c.UserContext(), r.ID, u.ID, title, body)
628 + if err != nil {
629 + h.setFlashT(c, "flash.error", err.Error())
630 + return c.Redirect(fmt.Sprintf("/%s/%s/issues/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
631 + }
632 +
633 + return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
634 +}
635 +
636 +func (h *Handler) RepoIssueDetailPage(c *fiber.Ctx) error {
637 + r, _, err := h.loadAuthorizedRepo(c, false)
638 + if err != nil {
639 + return nil
640 + }
641 + if !r.EnableIssues {
642 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
643 + }
644 +
645 + num, _ := strconv.Atoi(c.Params("number"))
646 + iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
647 + if err != nil {
648 + return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
649 + }
650 +
651 + comments, _ := h.repoSvc.ListIssueComments(c.UserContext(), iss.ID)
652 + viewer := middleware.CurrentUser(c)
653 + canToggle := viewer != nil && (viewer.ID == iss.AuthorID || h.repoSvc.CanWrite(c.UserContext(), r, viewer))
654 +
655 + data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", iss.Number, iss.Title, r.OwnerUsername, r.Name))
656 + data["Repo"] = r
657 + data["Ref"] = r.DefaultBranch
658 + data["ActiveTab"] = "issues"
659 + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
660 + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
661 + data["Issue"] = iss
662 + data["Comments"] = comments
663 + data["CanToggleIssue"] = canToggle
664 +
665 + return h.render(c, fiber.StatusOK, "issue_detail", data)
666 +}
667 +
668 +func (h *Handler) RepoIssueCommentSubmit(c *fiber.Ctx) error {
669 + r, _, err := h.loadAuthorizedRepo(c, false)
670 + if err != nil {
671 + return nil
672 + }
673 + if !r.EnableIssues {
674 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
675 + }
676 +
677 + num, _ := strconv.Atoi(c.Params("number"))
678 + iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
679 + if err != nil {
680 + return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
681 + }
682 +
683 + u := middleware.CurrentUser(c)
684 + body := c.FormValue("body")
685 + if err := h.repoSvc.CreateIssueComment(c.UserContext(), iss.ID, u.ID, body); err != nil {
686 + h.setFlashT(c, "flash.comment_error", err.Error())
687 + }
688 + return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
689 +}
690 +
691 +func (h *Handler) RepoIssueStateSubmit(c *fiber.Ctx) error {
692 + r, _, err := h.loadAuthorizedRepo(c, false)
693 + if err != nil {
694 + return nil
695 + }
696 + if !r.EnableIssues {
697 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
698 + }
699 +
700 + num, _ := strconv.Atoi(c.Params("number"))
701 + iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
702 + if err != nil {
703 + return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
704 + }
705 +
706 + viewer := middleware.CurrentUser(c)
707 + if viewer == nil || (viewer.ID != iss.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) {
708 + return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.issue_state_forbidden")
709 + }
710 +
711 + targetState := c.FormValue("state")
712 + isClosed := targetState == "closed"
713 + _ = h.repoSvc.SetIssueClosed(c.UserContext(), iss.ID, isClosed)
714 +
715 + if isClosed {
716 + h.setFlashT(c, "flash.issue_closed", iss.Number)
717 + } else {
718 + h.setFlashT(c, "flash.issue_reopened", iss.Number)
719 + }
720 + return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
721 +}
internal/web/handlers/reviews.go
+55 -0
... ... @@ -0,0 +1,55 @@
1 +package handlers
2 +
3 +import (
4 + "fmt"
5 + "strconv"
6 + "strings"
7 +
8 + "github.com/gofiber/fiber/v2"
9 +
10 + "polar/internal/web/middleware"
11 +)
12 +
13 +// RepoPullReviewSubmit records the reviewer's verdict (approve / request changes).
14 +func (h *Handler) RepoPullReviewSubmit(c *fiber.Ctx) error {
15 + r, _, err := h.loadAuthorizedRepo(c, false)
16 + if err != nil {
17 + return nil
18 + }
19 + if !r.EnablePulls {
20 + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled")
21 + }
22 +
23 + num, _ := strconv.Atoi(c.Params("number"))
24 + pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num)
25 + if err != nil {
26 + return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc")
27 + }
28 +
29 + u := middleware.CurrentUser(c)
30 + redirect := fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number)
31 +
32 + if u == nil || u.ID == pr.AuthorID {
33 + h.setFlashT(c, "flash.review_self")
34 + return c.Redirect(redirect, fiber.StatusSeeOther)
35 + }
36 + if pr.Status != "open" {
37 + h.setFlashT(c, "flash.review_closed")
38 + return c.Redirect(redirect, fiber.StatusSeeOther)
39 + }
40 +
41 + state := strings.TrimSpace(c.FormValue("state"))
42 + body := strings.TrimSpace(c.FormValue("body"))
43 +
44 + if err := h.repoSvc.UpsertReview(c.UserContext(), pr.ID, u.ID, state, body); err != nil {
45 + h.setFlashT(c, "flash.review_failed", err.Error())
46 + return c.Redirect(redirect, fiber.StatusSeeOther)
47 + }
48 +
49 + if state == "approved" {
50 + h.setFlashT(c, "flash.review_approved")
51 + } else {
52 + h.setFlashT(c, "flash.review_changes_requested")
53 + }
54 + return c.Redirect(redirect, fiber.StatusSeeOther)
55 +}
internal/web/handlers/social_test.go
+322 -0
... ... @@ -0,0 +1,322 @@
1 +package handlers
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "io"
7 + "net/http"
8 + "net/http/httptest"
9 + "os"
10 + "os/exec"
11 + "path/filepath"
12 + "strings"
13 + "testing"
14 +
15 + "polar/internal/auth"
16 + "polar/internal/repo"
17 +)
18 +
19 +func postForm(t *testing.T, app interface {
20 + Test(*http.Request, ...int) (*http.Response, error)
21 +}, path, body string) *http.Response {
22 + t.Helper()
23 + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
24 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
25 + resp, err := app.Test(req, 5000)
26 + if err != nil {
27 + t.Fatalf("POST %s: %v", path, err)
28 + }
29 + return resp
30 +}
31 +
32 +func getPage(t *testing.T, app interface {
33 + Test(*http.Request, ...int) (*http.Response, error)
34 +}, path string) (int, string) {
35 + t.Helper()
36 + resp, err := app.Test(httptest.NewRequest(http.MethodGet, path, nil), 5000)
37 + if err != nil {
38 + t.Fatalf("GET %s: %v", path, err)
39 + }
40 + defer resp.Body.Close()
41 + b, _ := io.ReadAll(resp.Body)
42 + return resp.StatusCode, string(b)
43 +}
44 +
45 +// TestDeviceFlowAPI walks through the full RFC 8628 exchange.
46 +func TestDeviceFlowAPI(t *testing.T) {
47 + app, authSvc, userSvc, _ := setupTestApp(t)
48 + ctx := context.Background()
49 +
50 + u, err := userSvc.Register(ctx, "dev", "dev@example.com", "password123", true)
51 + if err != nil {
52 + t.Fatalf("register: %v", err)
53 + }
54 +
55 + resp := postForm(t, app, "/api/v1/device/code", "client_id=git-credential-polar")
56 + if resp.StatusCode != http.StatusOK {
57 + t.Fatalf("device/code status %d", resp.StatusCode)
58 + }
59 + var code struct {
60 + DeviceCode string `json:"device_code"`
61 + UserCode string `json:"user_code"`
62 + VerificationURI string `json:"verification_uri"`
63 + Interval int `json:"interval"`
64 + ExpiresIn int `json:"expires_in"`
65 + }
66 + if err := json.NewDecoder(resp.Body).Decode(&code); err != nil {
67 + t.Fatalf("decode device/code: %v", err)
68 + }
69 + if code.DeviceCode == "" || code.UserCode == "" || code.Interval <= 0 || code.ExpiresIn <= 0 {
70 + t.Fatalf("incomplete device code response: %+v", code)
71 + }
72 + if !strings.Contains(code.VerificationURI, "/login/device") {
73 + t.Fatalf("unexpected verification uri %q", code.VerificationURI)
74 + }
75 +
76 + // Pending exchange.
77 + resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode)
78 + if resp.StatusCode != http.StatusTooManyRequests {
79 + t.Fatalf("expected 429 while pending, got %d", resp.StatusCode)
80 + }
81 + pendingBody, _ := io.ReadAll(resp.Body)
82 + if !strings.Contains(string(pendingBody), "authorization_pending") {
83 + t.Fatalf("expected authorization_pending, got %s", string(pendingBody))
84 + }
85 +
86 + // The verification page shows the pending grant.
87 + status, page := getPage(t, app, "/login/device?user_code="+code.UserCode)
88 + if status != http.StatusOK {
89 + t.Fatalf("device page status %d", status)
90 + }
91 + if !strings.Contains(page, code.UserCode) {
92 + t.Fatalf("device page missing user code")
93 + }
94 +
95 + // Approve and exchange.
96 + if err := authSvc.SetDeviceCodeStatus(ctx, code.UserCode, u.ID, auth.DeviceStatusApproved); err != nil {
97 + t.Fatalf("approve: %v", err)
98 + }
99 + resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode)
100 + if resp.StatusCode != http.StatusOK {
101 + body, _ := io.ReadAll(resp.Body)
102 + t.Fatalf("token exchange status %d: %s", resp.StatusCode, string(body))
103 + }
104 + var token struct {
105 + AccessToken string `json:"access_token"`
106 + TokenType string `json:"token_type"`
107 + Username string `json:"username"`
108 + }
109 + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
110 + t.Fatalf("decode token: %v", err)
111 + }
112 + if !strings.HasPrefix(token.AccessToken, "polar_") || token.Username != "dev" {
113 + t.Fatalf("unexpected token payload: %+v", token)
114 + }
115 +
116 + userID, err := authSvc.VerifyToken(ctx, token.AccessToken)
117 + if err != nil || userID != u.ID {
118 + t.Fatalf("issued token does not verify: id=%d err=%v", userID, err)
119 + }
120 +
121 + // The device code is single use.
122 + resp = postForm(t, app, "/api/v1/device/token", "device_code="+code.DeviceCode)
123 + if resp.StatusCode == http.StatusOK {
124 + t.Fatalf("device code must not be exchangeable twice")
125 + }
126 +}
127 +
128 +func gitRun(t *testing.T, dir string, args ...string) {
129 + t.Helper()
130 + cmd := exec.CommandContext(context.Background(), "git", args...)
131 + cmd.Dir = dir
132 + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
133 + if out, err := cmd.CombinedOutput(); err != nil {
134 + t.Fatalf("git %v: %v (%s)", args, err, string(out))
135 + }
136 +}
137 +
138 +// pushCommit seeds the bare repository with a single commit on the given branch.
139 +func pushCommit(t *testing.T, barePath, branch, filename, content string) {
140 + t.Helper()
141 + work := t.TempDir()
142 + gitRun(t, work, "init", "-b", "main")
143 + gitRun(t, work, "config", "user.name", "Tester")
144 + gitRun(t, work, "config", "user.email", "tester@example.com")
145 + if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil {
146 + t.Fatalf("write %s: %v", filename, err)
147 + }
148 + gitRun(t, work, "add", ".")
149 + gitRun(t, work, "commit", "-m", "add "+filename)
150 + pushRef := "HEAD:refs/heads/" + branch
151 + gitRun(t, work, "push", barePath, pushRef)
152 +}
153 +
154 +// pushBranchFromClone adds a branch with a new commit on top of the cloned
155 +// history of the given bare repository.
156 +func pushBranchFromClone(t *testing.T, barePath, branch, filename, content string) {
157 + t.Helper()
158 + work := filepath.Join(t.TempDir(), "clone")
159 + gitRun(t, t.TempDir(), "clone", barePath, work)
160 + gitRun(t, work, "config", "user.name", "Tester")
161 + gitRun(t, work, "config", "user.email", "tester@example.com")
162 + gitRun(t, work, "checkout", "-b", branch)
163 + if err := os.WriteFile(filepath.Join(work, filename), []byte(content), 0o644); err != nil {
164 + t.Fatalf("write %s: %v", filename, err)
165 + }
166 + gitRun(t, work, "add", ".")
167 + gitRun(t, work, "commit", "-m", "add "+filename)
168 + gitRun(t, work, "push", "origin", branch)
169 +}
170 +
171 +// TestForksCrossRepoPRAndReviews exercises the social features end to end.
172 +func TestForksCrossRepoPRAndReviews(t *testing.T) {
173 + app, _, userSvc, repoSvc := setupTestApp(t)
174 + ctx := context.Background()
175 +
176 + alice, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true)
177 + if err != nil {
178 + t.Fatalf("register alice: %v", err)
179 + }
180 + bob, err := userSvc.Register(ctx, "bob", "bob@example.com", "strongpassword", true)
181 + if err != nil {
182 + t.Fatalf("register bob: %v", err)
183 + }
184 +
185 + r, err := repoSvc.Create(ctx, alice, "engine", "Analytical Engine", false)
186 + if err != nil {
187 + t.Fatalf("create repo: %v", err)
188 + }
189 + basePath, _ := repoSvc.DiskPath("alice", "engine")
190 + pushCommit(t, basePath, "main", "file.txt", "base\n")
191 +
192 + // Fork and verify both the record and the on-disk copy.
193 + fork, err := repoSvc.Fork(ctx, r, bob, "")
194 + if err != nil {
195 + t.Fatalf("fork: %v", err)
196 + }
197 + if fork.ForkedFromRepoID == nil || *fork.ForkedFromRepoID != r.ID {
198 + t.Fatalf("fork missing origin link")
199 + }
200 + if fork.ForkedFromOwner != "alice" || fork.ForkedFromName != "engine" {
201 + t.Fatalf("fork origin metadata wrong: %+v", fork)
202 + }
203 + forkPath, _ := repoSvc.DiskPath("bob", "engine")
204 + if _, err := os.Stat(filepath.Join(forkPath, "HEAD")); err != nil {
205 + t.Fatalf("fork bare repository missing on disk: %v", err)
206 + }
207 +
208 + found, err := repoSvc.FindForkByOwner(ctx, r.ID, bob.ID)
209 + if err != nil || found == nil || found.ID != fork.ID {
210 + t.Fatalf("FindForkByOwner failed: %+v err=%v", found, err)
211 + }
212 + forks, err := repoSvc.ListForks(ctx, r.ID, 10)
213 + if err != nil || len(forks) != 1 {
214 + t.Fatalf("ListForks expected 1, got %d err=%v", len(forks), err)
215 + }
216 +
217 + // Repository pages expose the fork relationship.
218 + status, page := getPage(t, app, "/bob/engine")
219 + if status != http.StatusOK || !strings.Contains(page, "/alice/engine") {
220 + t.Fatalf("fork page should link to the origin repository (status %d)", status)
221 + }
222 + status, page = getPage(t, app, "/alice/engine/forks")
223 + if status != http.StatusOK || !strings.Contains(page, "bob/engine") {
224 + t.Fatalf("forks page should list bob/engine (status %d)", status)
225 + }
226 +
227 + // Bob pushes a feature branch into his fork and opens a cross-fork PR.
228 + pushBranchFromClone(t, forkPath, "feature", "feature.txt", "feature")
229 +
230 + pr, err := repoSvc.CreatePullRequest(ctx, r.ID, bob.ID, "Add feature", "Implements feature", "main", "feature", &fork.ID)
231 + if err != nil {
232 + t.Fatalf("create cross-fork PR: %v", err)
233 + }
234 + if pr.HeadRepoID == nil || *pr.HeadRepoID != fork.ID || pr.HeadRepoOwner != "bob" {
235 + t.Fatalf("cross-fork PR metadata wrong: %+v", pr)
236 + }
237 +
238 + status, page = getPage(t, app, "/alice/engine/pulls/1")
239 + if status != http.StatusOK {
240 + t.Fatalf("pull detail status %d", status)
241 + }
242 + if !strings.Contains(page, "bob/engine:feature") {
243 + t.Fatalf("pull detail missing cross-fork head label")
244 + }
245 + if !strings.Contains(page, "Add feature") {
246 + t.Fatalf("pull detail missing title")
247 + }
248 +
249 + // Reviews: alice approves, the verdict shows up on the page.
250 + if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewApproved, "looks good"); err != nil {
251 + t.Fatalf("upsert review: %v", err)
252 + }
253 + reviews, err := repoSvc.ListReviews(ctx, pr.ID)
254 + if err != nil || len(reviews) != 1 || reviews[0].State != repo.ReviewApproved {
255 + t.Fatalf("ListReviews unexpected: %+v err=%v", reviews, err)
256 + }
257 + approved, changesRequested := repoSvc.ReviewCounts(ctx, pr.ID)
258 + if approved != 1 || changesRequested != 0 {
259 + t.Fatalf("ReviewCounts = %d/%d", approved, changesRequested)
260 + }
261 + // Changing the verdict updates the same review row.
262 + if err := repoSvc.UpsertReview(ctx, pr.ID, alice.ID, repo.ReviewChangesRequested, "needs tests"); err != nil {
263 + t.Fatalf("update review: %v", err)
264 + }
265 + reviews, _ = repoSvc.ListReviews(ctx, pr.ID)
266 + if len(reviews) != 1 || reviews[0].State != repo.ReviewChangesRequested || reviews[0].Body != "needs tests" {
267 + t.Fatalf("review update failed: %+v", reviews)
268 + }
269 + status, page = getPage(t, app, "/alice/engine/pulls/1")
270 + if status != http.StatusOK || !strings.Contains(page, "needs tests") {
271 + t.Fatalf("pull page should render reviews")
272 + }
273 +
274 + // Cross-fork merge produces a merge commit on the base repository.
275 + if err := repoSvc.MergePullRequest(ctx, r, pr, "Alice", "alice@example.com", alice.ID); err != nil {
276 + t.Fatalf("cross-fork merge: %v", err)
277 + }
278 + merged, err := repoSvc.GetPullRequest(ctx, r.ID, 1)
279 + if err != nil || merged.Status != "merged" {
280 + t.Fatalf("expected merged PR, got %+v err=%v", merged, err)
281 + }
282 +
283 + // Collaborators on a private repository.
284 + priv, err := repoSvc.Create(ctx, alice, "vault", "Secret", true)
285 + if err != nil {
286 + t.Fatalf("create private repo: %v", err)
287 + }
288 + if repoSvc.CanRead(ctx, priv, bob) {
289 + t.Fatalf("bob must not read the private repo before being added")
290 + }
291 + if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleRead); err != nil {
292 + t.Fatalf("add collaborator: %v", err)
293 + }
294 + if !repoSvc.CanRead(ctx, priv, bob) {
295 + t.Fatalf("bob should read the private repo with the read role")
296 + }
297 + if repoSvc.CanWrite(ctx, priv, bob) {
298 + t.Fatalf("bob must not write with the read role")
299 + }
300 + if err := repoSvc.AddCollaborator(ctx, priv, "bob", repo.RoleWrite); err != nil {
301 + t.Fatalf("upgrade collaborator: %v", err)
302 + }
303 + if !repoSvc.CanWrite(ctx, priv, bob) {
304 + t.Fatalf("bob should write with the write role")
305 + }
306 + if err := repoSvc.AddCollaborator(ctx, priv, "alice", repo.RoleWrite); err != repo.ErrCannotAddOwner {
307 + t.Fatalf("adding the owner should fail, got %v", err)
308 + }
309 + if err := repoSvc.AddCollaborator(ctx, priv, "ghost", repo.RoleRead); err != repo.ErrCollabUserNotFound {
310 + t.Fatalf("unknown user should fail, got %v", err)
311 + }
312 + collabs, err := repoSvc.ListCollaborators(ctx, priv.ID)
313 + if err != nil || len(collabs) != 1 || collabs[0].Username != "bob" || collabs[0].Role != repo.RoleWrite {
314 + t.Fatalf("ListCollaborators unexpected: %+v err=%v", collabs, err)
315 + }
316 + if err := repoSvc.RemoveCollaborator(ctx, priv.ID, bob.ID); err != nil {
317 + t.Fatalf("remove collaborator: %v", err)
318 + }
319 + if repoSvc.CanRead(ctx, priv, bob) {
320 + t.Fatalf("bob must lose access after removal")
321 + }
322 +}
internal/web/middleware/middleware.go
+223 -0
... ... @@ -0,0 +1,223 @@
1 +package middleware
2 +
3 +import (
4 + "crypto/rand"
5 + "crypto/subtle"
6 + "encoding/hex"
7 + "log/slog"
8 + "strings"
9 + "sync"
10 + "time"
11 +
12 + "polar/internal/auth"
13 + "polar/internal/i18n"
14 + "polar/internal/user"
15 +
16 + "github.com/gofiber/fiber/v2"
17 +)
18 +
19 +const (
20 + SessionCookieName = "polar_session"
21 + CSRFCookieName = "polar_csrf"
22 + ThemeCookieName = "polar_theme"
23 + LangCookieName = "polar_lang"
24 +)
25 +
26 +func SecurityHeaders() fiber.Handler {
27 + return func(c *fiber.Ctx) error {
28 + c.Set("X-Content-Type-Options", "nosniff")
29 + c.Set("X-Frame-Options", "DENY")
30 + c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
31 + c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
32 + return c.Next()
33 + }
34 +}
35 +
36 +func RecoverAndLog(logger *slog.Logger) fiber.Handler {
37 + return func(c *fiber.Ctx) (err error) {
38 + start := time.Now()
39 + defer func() {
40 + if r := recover(); r != nil {
41 + logger.Error("panic recovered", "panic", r, "path", c.Path())
42 + _ = c.Status(fiber.StatusInternalServerError).SendString("Internal Server Error")
43 + }
44 + }()
45 +
46 + err = c.Next()
47 + dur := time.Since(start)
48 + if !strings.HasPrefix(c.Path(), "/static/") && c.Path() != "/healthz" {
49 + logger.Info("http request",
50 + "method", c.Method(),
51 + "path", c.Path(),
52 + "status", c.Response().StatusCode(),
53 + "duration_ms", dur.Milliseconds(),
54 + )
55 + }
56 + return err
57 + }
58 +}
59 +
60 +func LocaleAndTheme(defaultLang string) fiber.Handler {
61 + return func(c *fiber.Ctx) error {
62 + lang := c.Query("lang")
63 + if lang == "ru" || lang == "en" {
64 + c.Cookie(&fiber.Cookie{
65 + Name: LangCookieName,
66 + Value: lang,
67 + Path: "/",
68 + MaxAge: 365 * 24 * 3600,
69 + HTTPOnly: true,
70 + SameSite: "Lax",
71 + })
72 + } else {
73 + lang = c.Cookies(LangCookieName, defaultLang)
74 + }
75 + c.Locals("lang", i18n.Normalize(lang))
76 +
77 + theme := c.Cookies(ThemeCookieName, "system")
78 + switch theme {
79 + case "light", "dark", "system":
80 + default:
81 + theme = "system"
82 + }
83 + c.Locals("theme", theme)
84 + return c.Next()
85 + }
86 +}
87 +
88 +func SessionAuth(authSvc *auth.Service, userSvc *user.Service) fiber.Handler {
89 + return func(c *fiber.Ctx) error {
90 + sessID := c.Cookies(SessionCookieName)
91 + if sessID != "" {
92 + if sess, err := authSvc.GetSession(c.UserContext(), sessID); err == nil && sess != nil {
93 + if u, err := userSvc.GetByID(c.UserContext(), sess.UserID); err == nil && u != nil {
94 + c.Locals("user", u)
95 + c.Locals("session", sess)
96 + c.Locals("csrf_token", sess.CSRFToken)
97 + return c.Next()
98 + }
99 + }
100 + }
101 +
102 + // Anonymous CSRF token via cookie
103 + csrfCookie := c.Cookies(CSRFCookieName)
104 + if len(csrfCookie) < 32 {
105 + b := make([]byte, 24)
106 + _, _ = rand.Read(b)
107 + csrfCookie = hex.EncodeToString(b)
108 + c.Cookie(&fiber.Cookie{
109 + Name: CSRFCookieName,
110 + Value: csrfCookie,
111 + Path: "/",
112 + MaxAge: 86400,
113 + HTTPOnly: true,
114 + SameSite: "Lax",
115 + })
116 + }
117 + c.Locals("csrf_token", csrfCookie)
118 + return c.Next()
119 + }
120 +}
121 +
122 +func CSRFProtection() fiber.Handler {
123 + return func(c *fiber.Ctx) error {
124 + method := c.Method()
125 + if method == fiber.MethodGet || method == fiber.MethodHead || method == fiber.MethodOptions {
126 + return c.Next()
127 + }
128 +
129 + // Git Smart HTTP endpoints use Basic Auth + Token
130 + p := c.Path()
131 + if strings.HasSuffix(p, "/git-upload-pack") || strings.HasSuffix(p, "/git-receive-pack") {
132 + return c.Next()
133 + }
134 +
135 + // Device flow endpoints are unauthenticated machine-to-machine calls.
136 + if strings.HasPrefix(p, "/api/v1/device/") {
137 + return c.Next()
138 + }
139 +
140 + expected, _ := c.Locals("csrf_token").(string)
141 + provided := c.FormValue("_csrf")
142 + if provided == "" {
143 + provided = c.Get("X-CSRF-Token")
144 + }
145 +
146 + if expected == "" || provided == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) != 1 {
147 + return c.Status(fiber.StatusForbidden).SendString("CSRF token mismatch")
148 + }
149 + return c.Next()
150 + }
151 +}
152 +
153 +func RequireAuth() fiber.Handler {
154 + return func(c *fiber.Ctx) error {
155 + if CurrentUser(c) == nil {
156 + return c.Redirect("/login", fiber.StatusSeeOther)
157 + }
158 + return c.Next()
159 + }
160 +}
161 +
162 +func RequireAdmin() fiber.Handler {
163 + return func(c *fiber.Ctx) error {
164 + u := CurrentUser(c)
165 + if u == nil {
166 + return c.Redirect("/login", fiber.StatusSeeOther)
167 + }
168 + if !u.IsAdmin {
169 + return c.Status(fiber.StatusForbidden).SendString("Forbidden: administrator access required")
170 + }
171 + return c.Next()
172 + }
173 +}
174 +
175 +func CurrentUser(c *fiber.Ctx) *user.User {
176 + u, _ := c.Locals("user").(*user.User)
177 + return u
178 +}
179 +
180 +type rateBucket struct {
181 + count int
182 + resetAt time.Time
183 +}
184 +
185 +type RateLimiter struct {
186 + mu sync.Mutex
187 + buckets map[string]*rateBucket
188 + limit int
189 + window time.Duration
190 +}
191 +
192 +func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
193 + return &RateLimiter{
194 + buckets: make(map[string]*rateBucket),
195 + limit: limit,
196 + window: window,
197 + }
198 +}
199 +
200 +func (rl *RateLimiter) Handler() fiber.Handler {
201 + return func(c *fiber.Ctx) error {
202 + ip := c.IP()
203 + if ip == "" {
204 + ip = "local"
205 + }
206 + now := time.Now()
207 +
208 + rl.mu.Lock()
209 + b, exists := rl.buckets[ip]
210 + if !exists || now.After(b.resetAt) {
211 + b = &rateBucket{count: 0, resetAt: now.Add(rl.window)}
212 + rl.buckets[ip] = b
213 + }
214 + b.count++
215 + over := b.count > rl.limit
216 + rl.mu.Unlock()
217 +
218 + if over {
219 + return c.Status(fiber.StatusTooManyRequests).SendString("Too many requests, please try again in a minute.")
220 + }
221 + return c.Next()
222 + }
223 +}
internal/web/static/css/app.css
+1367 -0
... ... @@ -0,0 +1,1367 @@
1 +/* Polar Design System — Minimalist Sidebar Layout, 3 Themes (light, dark, system) */
2 +
3 +@font-face {
4 + font-family: 'Inter';
5 + font-style: normal;
6 + font-weight: 400 600;
7 + font-display: swap;
8 + src: url('/static/fonts/Inter-Regular.woff2') format('woff2');
9 +}
10 +
11 +@font-face {
12 + font-family: 'Inter';
13 + font-style: normal;
14 + font-weight: 400 600;
15 + font-display: swap;
16 + src: url('/static/fonts/Inter-Cyrillic.woff2') format('woff2');
17 + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
18 +}
19 +
20 +@font-face {
21 + font-family: 'JetBrains Mono';
22 + font-style: normal;
23 + font-weight: 400 600;
24 + font-display: swap;
25 + src: url('/static/fonts/JetBrainsMono-Regular.woff2') format('woff2');
26 +}
27 +
28 +:root,
29 +html[data-theme="light"] {
30 + --background: 0 0% 100%;
31 + --foreground: 0 0% 6.7%;
32 + --card: 0 0% 98%;
33 + --card-foreground: 0 0% 6.7%;
34 + --popover: 0 0% 100%;
35 + --popover-foreground: 0 0% 6.7%;
36 + --primary: 0 0% 6.7%;
37 + --primary-foreground: 0 0% 100%;
38 + --secondary: 0 0% 95%;
39 + --secondary-foreground: 0 0% 6.7%;
40 + --muted: 0 0% 95%;
41 + --muted-foreground: 0 0% 45%;
42 + --accent: 0 0% 93%;
43 + --accent-foreground: 0 0% 6.7%;
44 + --destructive: 0 72% 46%;
45 + --destructive-foreground: 0 0% 100%;
46 + --border: 0 0% 90%;
47 + --input: 0 0% 89%;
48 + --ring: 0 0% 6.7%;
49 + --radius: 0.45rem;
50 +
51 + --diff-add-bg: 142 70% 94%;
52 + --diff-add-fg: 142 76% 22%;
53 + --diff-del-bg: 0 86% 95%;
54 + --diff-del-fg: 0 72% 35%;
55 +
56 + --syntax-kw: 262 72% 42%;
57 + --syntax-str: 142 68% 28%;
58 + --syntax-cmt: 0 0% 46%;
59 + --syntax-fn: 215 80% 42%;
60 + --syntax-num: 24 85% 38%;
61 + --syntax-type: 190 78% 32%;
62 + --syntax-op: 0 0% 25%;
63 + color-scheme: light;
64 +}
65 +
66 +html[data-theme="dark"] {
67 + --background: 0 0% 6.7%;
68 + --foreground: 0 0% 100%;
69 + --card: 0 0% 9%;
70 + --card-foreground: 0 0% 100%;
71 + --popover: 0 0% 9.5%;
72 + --popover-foreground: 0 0% 100%;
73 + --primary: 0 0% 100%;
74 + --primary-foreground: 0 0% 6.7%;
75 + --secondary: 0 0% 13%;
76 + --secondary-foreground: 0 0% 100%;
77 + --muted: 0 0% 12%;
78 + --muted-foreground: 0 0% 65%;
79 + --accent: 0 0% 15%;
80 + --accent-foreground: 0 0% 100%;
81 + --destructive: 0 72% 58%;
82 + --destructive-foreground: 0 0% 98%;
83 + --border: 0 0% 16%;
84 + --input: 0 0% 17%;
85 + --ring: 0 0% 85%;
86 +
87 + --diff-add-bg: 142 50% 12%;
88 + --diff-add-fg: 142 70% 75%;
89 + --diff-del-bg: 0 55% 14%;
90 + --diff-del-fg: 0 80% 78%;
91 +
92 + --syntax-kw: 265 85% 78%;
93 + --syntax-str: 142 65% 70%;
94 + --syntax-cmt: 0 0% 54%;
95 + --syntax-fn: 210 90% 74%;
96 + --syntax-num: 32 90% 72%;
97 + --syntax-type: 188 80% 68%;
98 + --syntax-op: 0 0% 78%;
99 + color-scheme: dark;
100 +}
101 +
102 +@media (prefers-color-scheme: dark) {
103 + html[data-theme="system"] {
104 + --background: 0 0% 6.7%;
105 + --foreground: 0 0% 100%;
106 + --card: 0 0% 9%;
107 + --card-foreground: 0 0% 100%;
108 + --popover: 0 0% 9.5%;
109 + --popover-foreground: 0 0% 100%;
110 + --primary: 0 0% 100%;
111 + --primary-foreground: 0 0% 6.7%;
112 + --secondary: 0 0% 13%;
113 + --secondary-foreground: 0 0% 100%;
114 + --muted: 0 0% 12%;
115 + --muted-foreground: 0 0% 65%;
116 + --accent: 0 0% 15%;
117 + --accent-foreground: 0 0% 100%;
118 + --destructive: 0 72% 58%;
119 + --destructive-foreground: 0 0% 98%;
120 + --border: 0 0% 16%;
121 + --input: 0 0% 17%;
122 + --ring: 0 0% 85%;
123 + --diff-add-bg: 142 50% 12%;
124 + --diff-add-fg: 142 70% 75%;
125 + --diff-del-bg: 0 55% 14%;
126 + --diff-del-fg: 0 80% 78%;
127 + --syntax-kw: 265 85% 78%;
128 + --syntax-str: 142 65% 70%;
129 + --syntax-cmt: 0 0% 54%;
130 + --syntax-fn: 210 90% 74%;
131 + --syntax-num: 32 90% 72%;
132 + --syntax-type: 188 80% 68%;
133 + --syntax-op: 0 0% 78%;
134 + color-scheme: dark;
135 + }
136 +}
137 +
138 +*, *::before, *::after {
139 + box-sizing: border-box;
140 + border-color: hsl(var(--border));
141 +}
142 +
143 +html {
144 + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
145 + font-size: 13.5px;
146 + line-height: 1.5;
147 + -webkit-font-smoothing: antialiased;
148 + background-color: hsl(var(--background));
149 + color: hsl(var(--foreground));
150 + overflow-y: scroll;
151 +}
152 +
153 +body {
154 + margin: 0;
155 + min-height: 100vh;
156 + background-color: hsl(var(--background));
157 + color: hsl(var(--foreground));
158 +}
159 +
160 +code, pre, kbd, .font-mono {
161 + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
162 +}
163 +
164 +a {
165 + color: inherit;
166 + text-decoration: none;
167 +}
168 +
169 +@media (prefers-reduced-motion: reduce) {
170 + *, *::before, *::after {
171 + animation-duration: 0.01ms !important;
172 + transition-duration: 0.01ms !important;
173 + }
174 +}
175 +
176 +:focus-visible {
177 + outline: 2px solid hsl(var(--ring));
178 + outline-offset: 2px;
179 +}
180 +
181 +/* App Shell: Left Sidebar + Right Main Content */
182 +.app-shell {
183 + display: flex;
184 + flex-direction: row;
185 + align-items: stretch;
186 + min-height: 100vh;
187 + width: 100%;
188 +}
189 +
190 +.app-sidebar {
191 + width: 236px;
192 + min-width: 236px;
193 + max-width: 236px;
194 + flex: 0 0 236px;
195 + border-right: 1px solid hsl(var(--border));
196 + background-color: hsl(var(--card));
197 + display: none;
198 + flex-direction: column;
199 + justify-content: space-between;
200 + position: sticky;
201 + top: 0;
202 + height: 100vh;
203 + overflow-y: auto;
204 + overflow-x: hidden;
205 + padding: 0.85rem 0.75rem;
206 + user-select: none;
207 + transition:
208 + width 200ms cubic-bezier(0.16, 1, 0.3, 1),
209 + min-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
210 + max-width 200ms cubic-bezier(0.16, 1, 0.3, 1),
211 + flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1),
212 + padding 200ms cubic-bezier(0.16, 1, 0.3, 1),
213 + border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
214 +}
215 +
216 +.sidebar-inner {
217 + width: 218px;
218 + min-width: 218px;
219 + height: 100%;
220 + display: flex;
221 + flex-direction: column;
222 + justify-content: space-between;
223 + transition:
224 + opacity 150ms cubic-bezier(0.16, 1, 0.3, 1),
225 + transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
226 +}
227 +
228 +html[data-sidebar="collapsed"] .app-sidebar {
229 + width: 0;
230 + min-width: 0;
231 + max-width: 0;
232 + flex-basis: 0;
233 + padding-left: 0;
234 + padding-right: 0;
235 + border-right-color: transparent;
236 +}
237 +
238 +html[data-sidebar="collapsed"] .sidebar-inner {
239 + opacity: 0;
240 + transform: translateX(-8px);
241 + pointer-events: none;
242 +}
243 +
244 +.sidebar-expand-btn {
245 + position: fixed;
246 + top: 0.85rem;
247 + left: 0.85rem;
248 + z-index: 50;
249 + display: none;
250 + align-items: center;
251 + justify-content: center;
252 + width: 2rem;
253 + height: 2rem;
254 + border-radius: var(--radius);
255 + border: 1px solid hsl(var(--border));
256 + background-color: hsl(var(--card));
257 + color: hsl(var(--muted-foreground));
258 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
259 + cursor: pointer;
260 + opacity: 0;
261 + transform: translateX(-6px) scale(0.95);
262 + pointer-events: none;
263 + transition:
264 + opacity 160ms cubic-bezier(0.16, 1, 0.3, 1),
265 + transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
266 + background-color 120ms ease,
267 + color 120ms ease;
268 +}
269 +
270 +.sidebar-expand-btn:hover {
271 + background-color: hsl(var(--accent));
272 + color: hsl(var(--foreground));
273 +}
274 +
275 +.mobile-topbar {
276 + display: flex;
277 + align-items: center;
278 + justify-content: space-between;
279 + height: 2.75rem;
280 + padding: 0 0.85rem;
281 + border-bottom: 1px solid hsl(var(--border));
282 + background-color: hsl(var(--card));
283 +}
284 +
285 +.app-col {
286 + flex: 1 1 0%;
287 + min-width: 0;
288 + width: 100%;
289 + display: flex;
290 + flex-direction: column;
291 + min-height: 100vh;
292 +}
293 +
294 +.app-main {
295 + flex: 1 1 auto;
296 + width: 100%;
297 + padding: 1.5rem 1.25rem 3rem;
298 +}
299 +
300 +@keyframes pageFadeIn {
301 + from {
302 + opacity: 0;
303 + transform: translateY(4px);
304 + }
305 + to {
306 + opacity: 1;
307 + transform: translateY(0);
308 + }
309 +}
310 +
311 +.app-main > .container,
312 +.app-main > .container-narrow {
313 + animation: pageFadeIn 160ms cubic-bezier(0.16, 1, 0.3, 1);
314 +}
315 +
316 +@media (min-width: 640px) {
317 + .app-sidebar {
318 + display: flex;
319 + }
320 + .sidebar-expand-btn {
321 + display: inline-flex;
322 + }
323 + html[data-sidebar="collapsed"] .sidebar-expand-btn {
324 + opacity: 1;
325 + transform: translateX(0) scale(1);
326 + pointer-events: auto;
327 + }
328 + .mobile-topbar {
329 + display: none;
330 + }
331 + .app-main {
332 + padding: 2rem 2.5rem 3.5rem;
333 + }
334 +}
335 +
336 +/* Unified Container Width — identical across Code, Commits, and Settings */
337 +.container,
338 +.container-narrow {
339 + width: 100%;
340 + max-width: 960px;
341 + margin-left: auto;
342 + margin-right: auto;
343 +}
344 +
345 +/* Sidebar Navigation */
346 +.nav-item {
347 + display: flex;
348 + align-items: center;
349 + justify-content: space-between;
350 + gap: 0.5rem;
351 + height: 1.95rem;
352 + padding: 0 0.55rem;
353 + border-radius: var(--radius);
354 + font-size: 0.85rem;
355 + color: hsl(var(--muted-foreground));
356 + transition: background-color 140ms ease, color 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1);
357 + cursor: pointer;
358 + border: 0;
359 + background: transparent;
360 + width: 100%;
361 + text-align: left;
362 + overflow: hidden;
363 +}
364 +.nav-item:hover,
365 +.nav-item.active {
366 + background-color: hsl(var(--accent));
367 + color: hsl(var(--foreground));
368 +}
369 +.nav-item:active {
370 + transform: scale(0.985);
371 +}
372 +.nav-section-label {
373 + display: flex;
374 + align-items: center;
375 + justify-content: space-between;
376 + padding: 0.45rem 0.55rem 0.25rem;
377 + font-size: 0.7rem;
378 + font-weight: 600;
379 + text-transform: uppercase;
380 + letter-spacing: 0.04em;
381 + color: hsl(var(--muted-foreground));
382 +}
383 +
384 +/* Segmented Theme Control in Sidebar */
385 +.segmented {
386 + display: grid;
387 + grid-template-columns: repeat(3, minmax(0, 1fr));
388 + gap: 2px;
389 + padding: 2px;
390 + width: 100%;
391 + background-color: hsl(var(--muted));
392 + border: 1px solid hsl(var(--border));
393 + border-radius: var(--radius);
394 +}
395 +.segmented-btn {
396 + display: inline-flex;
397 + align-items: center;
398 + justify-content: center;
399 + gap: 0.25rem;
400 + height: 1.65rem;
401 + padding: 0 0.2rem;
402 + border: 0;
403 + border-radius: calc(var(--radius) - 2px);
404 + background: transparent;
405 + color: hsl(var(--muted-foreground));
406 + font-size: 0.72rem;
407 + font-weight: 500;
408 + cursor: pointer;
409 + overflow: hidden;
410 + text-overflow: ellipsis;
411 + white-space: nowrap;
412 + transition: background-color 150ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, box-shadow 150ms ease, transform 120ms ease;
413 +}
414 +.segmented-btn:hover {
415 + color: hsl(var(--foreground));
416 +}
417 +.segmented-btn:active {
418 + transform: scale(0.96);
419 +}
420 +.segmented-btn.active {
421 + background-color: hsl(var(--background));
422 + color: hsl(var(--foreground));
423 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
424 +}
425 +
426 +/* Icons */
427 +.icon {
428 + display: inline-block;
429 + vertical-align: middle;
430 + flex-shrink: 0;
431 + stroke: currentColor;
432 +}
433 +.size-3 { width: 0.75rem; height: 0.75rem; }
434 +.size-3\.5 { width: 0.875rem; height: 0.875rem; }
435 +.size-4 { width: 1rem; height: 1rem; }
436 +.size-5 { width: 1.25rem; height: 1.25rem; }
437 +.size-6 { width: 1.5rem; height: 1.5rem; }
438 +
439 +/* Buttons */
440 +.btn {
441 + display: inline-flex;
442 + align-items: center;
443 + justify-content: center;
444 + gap: 0.4rem;
445 + white-space: nowrap;
446 + border-radius: var(--radius);
447 + font-size: 0.85rem;
448 + font-weight: 500;
449 + line-height: 1;
450 + height: 1.95rem;
451 + padding: 0 0.7rem;
452 + border: 1px solid transparent;
453 + cursor: pointer;
454 + transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, opacity 140ms ease, transform 120ms cubic-bezier(0.16, 1, 0.3, 1);
455 + user-select: none;
456 +}
457 +.btn:active:not(:disabled) {
458 + transform: scale(0.97);
459 +}
460 +.btn:disabled {
461 + opacity: 0.5;
462 + pointer-events: none;
463 +}
464 +.btn-sm {
465 + height: 1.7rem;
466 + padding: 0 0.5rem;
467 + font-size: 0.78rem;
468 +}
469 +.btn-lg {
470 + height: 2.3rem;
471 + padding: 0 1rem;
472 + font-size: 0.92rem;
473 +}
474 +.btn-icon {
475 + width: 1.9rem;
476 + height: 1.9rem;
477 + padding: 0;
478 +}
479 +.btn-default, .btn-primary {
480 + background-color: hsl(var(--primary));
481 + color: hsl(var(--primary-foreground));
482 +}
483 +.btn-default:hover, .btn-primary:hover {
484 + opacity: 0.9;
485 +}
486 +.btn-secondary {
487 + background-color: hsl(var(--secondary));
488 + color: hsl(var(--secondary-foreground));
489 + border-color: hsl(var(--border));
490 +}
491 +.btn-secondary:hover {
492 + background-color: hsl(var(--accent));
493 +}
494 +.btn-outline {
495 + background-color: transparent;
496 + color: hsl(var(--foreground));
497 + border-color: hsl(var(--border));
498 +}
499 +.btn-outline:hover {
500 + background-color: hsl(var(--accent));
501 + color: hsl(var(--accent-foreground));
502 +}
503 +.btn-ghost {
504 + background-color: transparent;
505 + color: hsl(var(--muted-foreground));
506 +}
507 +.btn-ghost:hover {
508 + background-color: hsl(var(--accent));
509 + color: hsl(var(--foreground));
510 +}
511 +.btn-destructive {
512 + background-color: hsl(var(--destructive));
513 + color: hsl(var(--destructive-foreground));
514 +}
515 +.btn-destructive:hover {
516 + opacity: 0.9;
517 +}
518 +
519 +/* Inputs, Selects, Textareas */
520 +.input, .select, .textarea {
521 + display: block;
522 + width: 100%;
523 + border-radius: var(--radius);
524 + border: 1px solid hsl(var(--input));
525 + background-color: hsl(var(--background));
526 + color: hsl(var(--foreground));
527 + font-size: 0.85rem;
528 + padding: 0.35rem 0.6rem;
529 + transition: border-color 100ms ease;
530 +}
531 +.input, .select {
532 + height: 2.05rem;
533 +}
534 +.textarea {
535 + min-height: 4rem;
536 + resize: vertical;
537 +}
538 +.input:focus, .select:focus, .textarea:focus {
539 + outline: none;
540 + border-color: hsl(var(--ring));
541 +}
542 +.input::placeholder, .textarea::placeholder {
543 + color: hsl(var(--muted-foreground));
544 +}
545 +.label {
546 + display: block;
547 + font-size: 0.8rem;
548 + font-weight: 500;
549 + margin-bottom: 0.3rem;
550 + color: hsl(var(--foreground));
551 +}
552 +.field-hint {
553 + font-size: 0.74rem;
554 + color: hsl(var(--muted-foreground));
555 + margin-top: 0.25rem;
556 +}
557 +
558 +/* Switch */
559 +.switch {
560 + position: relative;
561 + display: inline-flex;
562 + width: 2.1rem;
563 + height: 1.2rem;
564 + flex-shrink: 0;
565 + cursor: pointer;
566 + align-items: center;
567 + border-radius: 9999px;
568 + border: 1px solid hsl(var(--border));
569 + background-color: hsl(var(--muted));
570 +}
571 +.switch input {
572 + opacity: 0;
573 + width: 0;
574 + height: 0;
575 + position: absolute;
576 +}
577 +.switch-thumb {
578 + pointer-events: none;
579 + display: block;
580 + width: 0.85rem;
581 + height: 0.85rem;
582 + border-radius: 9999px;
583 + background-color: hsl(var(--foreground));
584 + transform: translateX(0.15rem);
585 + transition: transform 100ms ease;
586 +}
587 +.switch input:checked ~ .switch-thumb {
588 + transform: translateX(0.95rem);
589 +}
590 +
591 +/* Cards */
592 +.card {
593 + border-radius: var(--radius);
594 + border: 1px solid hsl(var(--border));
595 + background-color: hsl(var(--card));
596 + color: hsl(var(--card-foreground));
597 +}
598 +.card-header {
599 + padding: 0.7rem 0.9rem;
600 + border-bottom: 1px solid hsl(var(--border));
601 + display: flex;
602 + align-items: center;
603 + justify-content: space-between;
604 + gap: 0.75rem;
605 +}
606 +.card-body {
607 + padding: 0.9rem;
608 +}
609 +.card-footer {
610 + padding: 0.65rem 0.9rem;
611 + border-top: 1px solid hsl(var(--border));
612 + background-color: hsl(var(--muted) / 0.3);
613 +}
614 +
615 +/* Badges */
616 +.badge {
617 + display: inline-flex;
618 + align-items: center;
619 + gap: 0.25rem;
620 + border-radius: 9999px;
621 + border: 1px solid hsl(var(--border));
622 + padding: 0.08rem 0.45rem;
623 + font-size: 0.72rem;
624 + font-weight: 500;
625 + line-height: 1.3;
626 + color: hsl(var(--foreground));
627 + background-color: hsl(var(--secondary));
628 +}
629 +.badge-outline {
630 + background-color: transparent;
631 + color: hsl(var(--muted-foreground));
632 +}
633 +.badge-primary {
634 + background-color: hsl(var(--primary));
635 + color: hsl(var(--primary-foreground));
636 + border-color: transparent;
637 +}
638 +.badge-destructive {
639 + background-color: hsl(var(--destructive) / 0.12);
640 + color: hsl(var(--destructive));
641 + border-color: hsl(var(--destructive) / 0.3);
642 +}
643 +
644 +/* Kbd */
645 +kbd, .kbd {
646 + display: inline-flex;
647 + align-items: center;
648 + justify-content: center;
649 + min-width: 1.25rem;
650 + height: 1.15rem;
651 + padding: 0 0.28rem;
652 + font-size: 0.68rem;
653 + font-weight: 500;
654 + line-height: 1;
655 + color: hsl(var(--muted-foreground));
656 + background-color: hsl(var(--muted));
657 + border: 1px solid hsl(var(--border));
658 + border-radius: 0.25rem;
659 +}
660 +
661 +/* Avatar */
662 +.avatar {
663 + display: inline-flex;
664 + align-items: center;
665 + justify-content: center;
666 + width: 1.45rem;
667 + height: 1.45rem;
668 + border-radius: 9999px;
669 + background-color: hsl(var(--secondary));
670 + border: 1px solid hsl(var(--border));
671 + font-size: 0.7rem;
672 + font-weight: 600;
673 + color: hsl(var(--foreground));
674 + text-transform: uppercase;
675 + flex-shrink: 0;
676 + overflow: hidden;
677 +}
678 +.avatar-img {
679 + width: 100%;
680 + height: 100%;
681 + object-fit: cover;
682 + display: block;
683 +}
684 +.avatar-lg {
685 + width: 2.3rem;
686 + height: 2.3rem;
687 + font-size: 0.9rem;
688 +}
689 +.avatar-xl {
690 + width: 4.25rem;
691 + height: 4.25rem;
692 + font-size: 1.5rem;
693 +}
694 +
695 +
696 +/* Separator */
697 +.separator {
698 + height: 1px;
699 + width: 100%;
700 + background-color: hsl(var(--border));
701 + margin: 0.85rem 0;
702 + border: 0;
703 +}
704 +
705 +/* Tabs */
706 +.tabs-nav {
707 + display: flex;
708 + align-items: center;
709 + gap: 0.15rem;
710 + padding: 0.2rem;
711 + border: 1px solid hsl(var(--border));
712 + border-radius: 999px;
713 + background: hsl(var(--muted) / 0.25);
714 + margin-bottom: 1rem;
715 + overflow-x: auto;
716 +}
717 +.tab-link {
718 + display: inline-flex;
719 + align-items: center;
720 + gap: 0.35rem;
721 + padding: 0.38rem 0.8rem;
722 + font-size: 0.82rem;
723 + font-weight: 500;
724 + color: hsl(var(--muted-foreground));
725 + border-radius: 999px;
726 + white-space: nowrap;
727 + transition: color 100ms ease, background-color 100ms ease;
728 +}
729 +.tab-link:hover {
730 + color: hsl(var(--foreground));
731 +}
732 +.tab-link.active {
733 + color: hsl(var(--foreground));
734 + background: hsl(var(--card));
735 + box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08);
736 +}
737 +
738 +/* Dense File List & Tables */
739 +.dense-list {
740 + width: 100%;
741 +}
742 +.dense-row {
743 + display: flex;
744 + align-items: center;
745 + justify-content: space-between;
746 + gap: 0.75rem;
747 + min-height: 2.05rem;
748 + padding: 0.25rem 0.75rem;
749 + border-bottom: 1px solid hsl(var(--border));
750 + font-size: 0.85rem;
751 + transition: background-color 80ms ease;
752 +}
753 +.dense-row:last-child {
754 + border-bottom: none;
755 +}
756 +.dense-row:hover {
757 + background-color: hsl(var(--accent) / 0.5);
758 +}
759 +
760 +.table-wrap {
761 + width: 100%;
762 + overflow-x: auto;
763 + border: 1px solid hsl(var(--border));
764 + border-radius: var(--radius);
765 + background-color: hsl(var(--card));
766 +}
767 +.table {
768 + width: 100%;
769 + border-collapse: collapse;
770 + font-size: 0.85rem;
771 +}
772 +.table th {
773 + text-align: left;
774 + font-weight: 500;
775 + color: hsl(var(--muted-foreground));
776 + padding: 0.5rem 0.75rem;
777 + border-bottom: 1px solid hsl(var(--border));
778 + background-color: hsl(var(--muted) / 0.35);
779 + font-size: 0.75rem;
780 +}
781 +.table td {
782 + padding: 0.5rem 0.75rem;
783 + border-bottom: 1px solid hsl(var(--border));
784 + vertical-align: middle;
785 +}
786 +.table tr:last-child td {
787 + border-bottom: none;
788 +}
789 +
790 +/* Breadcrumb */
791 +.breadcrumb {
792 + display: flex;
793 + align-items: center;
794 + flex-wrap: wrap;
795 + gap: 0.25rem;
796 + font-size: 0.85rem;
797 +}
798 +.breadcrumb-sep {
799 + color: hsl(var(--muted-foreground));
800 +}
801 +
802 +/* Dropdown Menu */
803 +@keyframes dropdownIn {
804 + from {
805 + opacity: 0;
806 + transform: translateY(-4px) scale(0.98);
807 + }
808 + to {
809 + opacity: 1;
810 + transform: translateY(0) scale(1);
811 + }
812 +}
813 +.dropdown {
814 + position: relative;
815 + display: inline-block;
816 +}
817 +.dropdown-menu {
818 + position: absolute;
819 + left: 0;
820 + top: calc(100% + 0.3rem);
821 + min-width: 11rem;
822 + background-color: hsl(var(--popover));
823 + color: hsl(var(--popover-foreground));
824 + border: 1px solid hsl(var(--border));
825 + border-radius: var(--radius);
826 + padding: 0.25rem;
827 + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.22);
828 + z-index: 60;
829 + display: none;
830 + transform-origin: top left;
831 +}
832 +.dropdown-menu.open {
833 + display: block;
834 + animation: dropdownIn 140ms cubic-bezier(0.16, 1, 0.3, 1);
835 +}
836 +.dropdown-item {
837 + display: flex;
838 + width: 100%;
839 + align-items: center;
840 + justify-content: space-between;
841 + gap: 0.5rem;
842 + padding: 0.35rem 0.55rem;
843 + font-size: 0.8rem;
844 + border-radius: calc(var(--radius) - 2px);
845 + color: hsl(var(--popover-foreground));
846 + background: transparent;
847 + border: 0;
848 + cursor: pointer;
849 + text-align: left;
850 + transition: background-color 120ms ease, color 120ms ease;
851 +}
852 +.dropdown-item:hover, .dropdown-item.active {
853 + background-color: hsl(var(--accent));
854 + color: hsl(var(--accent-foreground));
855 +}
856 +
857 +/* Modal / Dialog & Command Palette */
858 +@keyframes backdropIn {
859 + from { opacity: 0; }
860 + to { opacity: 1; }
861 +}
862 +@keyframes dialogIn {
863 + from {
864 + opacity: 0;
865 + transform: translateY(-6px) scale(0.97);
866 + }
867 + to {
868 + opacity: 1;
869 + transform: translateY(0) scale(1);
870 + }
871 +}
872 +.backdrop {
873 + position: fixed;
874 + inset: 0;
875 + background-color: rgba(0, 0, 0, 0.55);
876 + backdrop-filter: blur(2px);
877 + z-index: 80;
878 + display: none;
879 + align-items: flex-start;
880 + justify-content: center;
881 + padding: 10vh 1rem 2rem;
882 +}
883 +.backdrop.open {
884 + display: flex;
885 + animation: backdropIn 140ms ease;
886 +}
887 +.dialog-box {
888 + width: 100%;
889 + max-width: 28rem;
890 + background-color: hsl(var(--popover));
891 + color: hsl(var(--popover-foreground));
892 + border: 1px solid hsl(var(--border));
893 + border-radius: var(--radius);
894 + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.35);
895 + overflow: hidden;
896 + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1);
897 +}
898 +.palette-box {
899 + width: 100%;
900 + max-width: 34rem;
901 + background-color: hsl(var(--popover));
902 + color: hsl(var(--popover-foreground));
903 + border: 1px solid hsl(var(--border));
904 + border-radius: var(--radius);
905 + box-shadow: 0 16px 48px -8px rgba(0, 0, 0, 0.4);
906 + overflow: hidden;
907 + animation: dialogIn 170ms cubic-bezier(0.16, 1, 0.3, 1);
908 +}
909 +.palette-input-wrap {
910 + display: flex;
911 + align-items: center;
912 + gap: 0.6rem;
913 + padding: 0.6rem 0.8rem;
914 + border-bottom: 1px solid hsl(var(--border));
915 +}
916 +.palette-input {
917 + width: 100%;
918 + border: 0;
919 + background: transparent;
920 + color: hsl(var(--foreground));
921 + font-size: 0.9rem;
922 + outline: none;
923 +}
924 +.palette-list {
925 + max-height: 20rem;
926 + overflow-y: auto;
927 + padding: 0.3rem;
928 +}
929 +.palette-group-title {
930 + padding: 0.3rem 0.5rem 0.15rem;
931 + font-size: 0.68rem;
932 + font-weight: 600;
933 + text-transform: uppercase;
934 + letter-spacing: 0.04em;
935 + color: hsl(var(--muted-foreground));
936 +}
937 +.palette-item {
938 + display: flex;
939 + width: 100%;
940 + align-items: center;
941 + justify-content: space-between;
942 + gap: 0.5rem;
943 + padding: 0.4rem 0.55rem;
944 + border-radius: calc(var(--radius) - 2px);
945 + font-size: 0.85rem;
946 + color: hsl(var(--foreground));
947 + cursor: pointer;
948 + border: 0;
949 + background: transparent;
950 + text-align: left;
951 + transition: background-color 100ms ease, color 100ms ease;
952 +}
953 +.palette-item:hover, .palette-item.selected {
954 + background-color: hsl(var(--accent));
955 + color: hsl(var(--accent-foreground));
956 +}
957 +
958 +/* Toast */
959 +@keyframes toastIn {
960 + from {
961 + opacity: 0;
962 + transform: translateY(8px) scale(0.97);
963 + }
964 + to {
965 + opacity: 1;
966 + transform: translateY(0) scale(1);
967 + }
968 +}
969 +.toast-container {
970 + position: fixed;
971 + bottom: 1rem;
972 + right: 1rem;
973 + z-index: 90;
974 + display: flex;
975 + flex-direction: column;
976 + gap: 0.4rem;
977 + max-width: 20rem;
978 +}
979 +.toast {
980 + display: flex;
981 + align-items: center;
982 + justify-content: space-between;
983 + gap: 0.65rem;
984 + padding: 0.5rem 0.75rem;
985 + border-radius: var(--radius);
986 + border: 1px solid hsl(var(--border));
987 + background-color: hsl(var(--popover));
988 + color: hsl(var(--popover-foreground));
989 + box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.25);
990 + font-size: 0.8rem;
991 + animation: toastIn 180ms cubic-bezier(0.16, 1, 0.3, 1);
992 +}
993 +
994 +/* Skeleton */
995 +@keyframes pulse {
996 + 0%, 100% { opacity: 1; }
997 + 50% { opacity: 0.45; }
998 +}
999 +.skeleton {
1000 + background-color: hsl(var(--muted));
1001 + border-radius: calc(var(--radius) - 2px);
1002 + animation: pulse 1.6s ease-in-out infinite;
1003 +}
1004 +
1005 +/* Empty State */
1006 +.empty-state {
1007 + text-align: center;
1008 + padding: 2rem 1rem;
1009 + border: 1px dashed hsl(var(--border));
1010 + border-radius: var(--radius);
1011 + color: hsl(var(--muted-foreground));
1012 +}
1013 +
1014 +/* Code Viewer & Chroma */
1015 +.code-table {
1016 + width: 100%;
1017 + border-collapse: collapse;
1018 + font-family: 'JetBrains Mono', monospace;
1019 + font-size: 0.8rem;
1020 + line-height: 1.5;
1021 +}
1022 +.code-ln {
1023 + width: 1%;
1024 + min-width: 2.75rem;
1025 + padding: 0 0.65rem;
1026 + text-align: right;
1027 + color: hsl(var(--muted-foreground) / 0.6);
1028 + user-select: none;
1029 + vertical-align: top;
1030 + border-right: 1px solid hsl(var(--border));
1031 +}
1032 +.code-line {
1033 + padding: 0 0.75rem;
1034 + white-space: pre;
1035 + overflow-x: auto;
1036 +}
1037 +.chroma .k, .chroma .kc, .chroma .kd, .chroma .kn, .chroma .kp, .chroma .kr { color: hsl(var(--syntax-kw)); font-weight: 500; }
1038 +.chroma .kt { color: hsl(var(--syntax-type)); }
1039 +.chroma .s, .chroma .sa, .chroma .sb, .chroma .sc, .chroma .dl, .chroma .sd, .chroma .s2, .chroma .se, .chroma .sh, .chroma .si, .chroma .sx, .chroma .sr, .chroma .s1, .chroma .ss { color: hsl(var(--syntax-str)); }
1040 +.chroma .c, .chroma .ch, .chroma .cm, .chroma .c1, .chroma .cs, .chroma .cp, .chroma .cpf { color: hsl(var(--syntax-cmt)); font-style: italic; }
1041 +.chroma .nf, .chroma .fm, .chroma .nx { color: hsl(var(--syntax-fn)); }
1042 +.chroma .m, .chroma .mb, .chroma .mf, .chroma .mh, .chroma .mi, .chroma .il, .chroma .mo { color: hsl(var(--syntax-num)); }
1043 +.chroma .o, .chroma .ow { color: hsl(var(--syntax-op)); }
1044 +
1045 +/* Diff Viewer */
1046 +.diff-table {
1047 + width: 100%;
1048 + border-collapse: collapse;
1049 + font-family: 'JetBrains Mono', monospace;
1050 + font-size: 0.78rem;
1051 + line-height: 1.5;
1052 +}
1053 +.diff-row-add {
1054 + background-color: hsl(var(--diff-add-bg));
1055 + color: hsl(var(--diff-add-fg));
1056 +}
1057 +.diff-row-del {
1058 + background-color: hsl(var(--diff-del-bg));
1059 + color: hsl(var(--diff-del-fg));
1060 +}
1061 +.diff-row-hunk {
1062 + background-color: hsl(var(--muted));
1063 + color: hsl(var(--muted-foreground));
1064 +}
1065 +.diff-num {
1066 + width: 2.75rem;
1067 + padding: 0 0.45rem;
1068 + text-align: right;
1069 + user-select: none;
1070 + opacity: 0.65;
1071 + border-right: 1px solid hsl(var(--border));
1072 +}
1073 +.diff-code {
1074 + padding: 0 0.65rem;
1075 + white-space: pre-wrap;
1076 + word-break: break-all;
1077 +}
1078 +
1079 +/* Markdown Body */
1080 +.markdown-body {
1081 + font-size: 0.9rem;
1082 + line-height: 1.6;
1083 + color: hsl(var(--foreground));
1084 +}
1085 +.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
1086 + margin-top: 1.25em;
1087 + margin-bottom: 0.45em;
1088 + font-weight: 600;
1089 + line-height: 1.25;
1090 +}
1091 +.markdown-body h1 { font-size: 1.35rem; padding-bottom: 0.25rem; border-bottom: 1px solid hsl(var(--border)); }
1092 +.markdown-body h2 { font-size: 1.15rem; padding-bottom: 0.2rem; border-bottom: 1px solid hsl(var(--border)); }
1093 +.markdown-body h3 { font-size: 1rem; }
1094 +.markdown-body h1:first-child, .markdown-body h2:first-child { margin-top: 0; }
1095 +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body pre, .markdown-body blockquote {
1096 + margin-top: 0;
1097 + margin-bottom: 0.8rem;
1098 +}
1099 +.markdown-body code {
1100 + background-color: hsl(var(--muted));
1101 + padding: 0.12rem 0.3rem;
1102 + border-radius: 0.25rem;
1103 + font-size: 0.84em;
1104 +}
1105 +.markdown-body pre {
1106 + background-color: hsl(var(--muted) / 0.6);
1107 + border: 1px solid hsl(var(--border));
1108 + border-radius: var(--radius);
1109 + padding: 0.75rem 0.9rem;
1110 + overflow-x: auto;
1111 +}
1112 +.markdown-body pre code {
1113 + background: transparent;
1114 + padding: 0;
1115 +}
1116 +.markdown-body a {
1117 + text-decoration: underline;
1118 + text-underline-offset: 3px;
1119 +}
1120 +
1121 +/* Utility Helpers */
1122 +.flex { display: flex; }
1123 +.block { display: block; }
1124 +.inline-flex { display: inline-flex; }
1125 +.grid { display: grid; }
1126 +.hidden { display: none; }
1127 +.items-center { align-items: center; }
1128 +.items-start { align-items: flex-start; }
1129 +.items-baseline { align-items: baseline; }
1130 +.justify-between { justify-content: space-between; }
1131 +.justify-center { justify-content: center; }
1132 +.justify-end { justify-content: flex-end; }
1133 +.flex-col { flex-direction: column; }
1134 +.flex-wrap { flex-wrap: wrap; }
1135 +.flex-1 { flex: 1 1 0%; }
1136 +.flex-shrink-0 { flex-shrink: 0; }
1137 +.min-w-0 { min-width: 0; }
1138 +.gap-0\.5 { gap: 0.125rem; }
1139 +.gap-1 { gap: 0.25rem; }
1140 +.gap-1\.5 { gap: 0.375rem; }
1141 +.gap-2 { gap: 0.5rem; }
1142 +.gap-2\.5 { gap: 0.625rem; }
1143 +.gap-3 { gap: 0.75rem; }
1144 +.gap-4 { gap: 1rem; }
1145 +.gap-6 { gap: 1.5rem; }
1146 +.w-full { width: 100%; }
1147 +.text-xs { font-size: 0.75rem; }
1148 +.text-sm { font-size: 0.85rem; }
1149 +.text-base { font-size: 0.96rem; }
1150 +.text-lg { font-size: 1.1rem; }
1151 +.text-xl { font-size: 1.25rem; }
1152 +.text-2xl { font-size: 1.5rem; }
1153 +.font-medium { font-weight: 500; }
1154 +.font-semibold { font-weight: 600; }
1155 +.font-bold { font-weight: 700; }
1156 +.uppercase { text-transform: uppercase; }
1157 +.tracking-tight { letter-spacing: -0.015em; }
1158 +.text-muted { color: hsl(var(--muted-foreground)); }
1159 +.text-destructive { color: hsl(var(--destructive)); }
1160 +.text-foreground { color: hsl(var(--foreground)); }
1161 +.text-center { text-align: center; }
1162 +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1163 +.hover\:underline:hover { text-decoration: underline; }
1164 +.select-all { user-select: all; }
1165 +.cursor-pointer { cursor: pointer; }
1166 +.mt-0\.5 { margin-top: 0.125rem; }
1167 +.mt-1 { margin-top: 0.25rem; }
1168 +.mt-2 { margin-top: 0.5rem; }
1169 +.mt-3 { margin-top: 0.75rem; }
1170 +.mt-4 { margin-top: 1rem; }
1171 +.mt-6 { margin-top: 1.5rem; }
1172 +.mr-2 { margin-right: 0.5rem; }
1173 +.mb-1 { margin-bottom: 0.25rem; }
1174 +.mb-1\.5 { margin-bottom: 0.375rem; }
1175 +.mb-2 { margin-bottom: 0.5rem; }
1176 +.mb-3 { margin-bottom: 0.75rem; }
1177 +.mb-4 { margin-bottom: 1rem; }
1178 +.mb-6 { margin-bottom: 1.5rem; }
1179 +.p-2 { padding: 0.5rem; }
1180 +.p-3 { padding: 0.75rem; }
1181 +.p-4 { padding: 1rem; }
1182 +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
1183 +.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
1184 +.pt-2 { padding-top: 0.5rem; }
1185 +.pt-3 { padding-top: 0.75rem; }
1186 +.pb-3 { padding-bottom: 0.75rem; }
1187 +
1188 +.grid-2 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; }
1189 +.grid-3 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; }
1190 +.grid-4 { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.85rem; }
1191 +
1192 +@media (min-width: 768px) {
1193 + .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1194 + .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
1195 + .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1196 + .md-flex { display: flex !important; }
1197 + .md-hidden { display: none !important; }
1198 +}
1199 +
1200 +/* Mobile Drawer */
1201 +.mobile-drawer {
1202 + position: fixed;
1203 + inset: 0;
1204 + z-index: 75;
1205 + background-color: rgba(0, 0, 0, 0.5);
1206 + display: none;
1207 +}
1208 +.mobile-drawer.open {
1209 + display: block;
1210 +}
1211 +.mobile-drawer-panel {
1212 + width: 224px;
1213 + max-width: 85vw;
1214 + height: 100%;
1215 + background-color: hsl(var(--card));
1216 + border-right: 1px solid hsl(var(--border));
1217 + padding: 0.75rem 0.65rem;
1218 + display: flex;
1219 + flex-direction: column;
1220 + justify-content: space-between;
1221 +}
1222 +
1223 +@media (min-width: 1024px) {
1224 + .grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
1225 +}
1226 +
1227 +/* ── Polar repository shell ─────────────────────────────────────────── */
1228 +.repo-banner {
1229 + position: relative;
1230 + display: flex;
1231 + align-items: flex-start;
1232 + justify-content: space-between;
1233 + gap: 1rem;
1234 + flex-wrap: wrap;
1235 + padding: 0.9rem 1rem 0.95rem;
1236 + background: hsl(var(--card));
1237 + border: 1px solid hsl(var(--border));
1238 + border-radius: calc(var(--radius) * 1.6);
1239 + overflow: hidden;
1240 +}
1241 +.repo-banner::before {
1242 + content: "";
1243 + position: absolute;
1244 + inset: 0 0 auto 0;
1245 + height: 2px;
1246 + background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0) 65%);
1247 +}
1248 +.repo-banner-main {
1249 + display: flex;
1250 + align-items: center;
1251 + gap: 0.7rem;
1252 + min-width: 0;
1253 + flex: 1 1 20rem;
1254 +}
1255 +.repo-banner-actions {
1256 + display: flex;
1257 + align-items: center;
1258 + gap: 0.4rem;
1259 + flex-wrap: wrap;
1260 +}
1261 +
1262 +.repo-stats {
1263 + display: flex;
1264 + align-items: center;
1265 + gap: 0.4rem;
1266 + flex-wrap: wrap;
1267 +}
1268 +.stat-chip {
1269 + display: inline-flex;
1270 + align-items: center;
1271 + gap: 0.4rem;
1272 + height: 1.85rem;
1273 + padding: 0 0.7rem;
1274 + border: 1px solid hsl(var(--border));
1275 + border-radius: 999px;
1276 + background: hsl(var(--card));
1277 + font-size: 0.78rem;
1278 + color: hsl(var(--muted-foreground));
1279 + white-space: nowrap;
1280 + transition: border-color 120ms ease, color 120ms ease;
1281 +}
1282 +.stat-chip b {
1283 + color: hsl(var(--foreground));
1284 + font-weight: 600;
1285 +}
1286 +.stat-chip:hover {
1287 + color: hsl(var(--foreground));
1288 + border-color: hsl(var(--primary) / 0.55);
1289 +}
1290 +
1291 +.repo-layout {
1292 + display: grid;
1293 + grid-template-columns: minmax(0, 1fr) 305px;
1294 + gap: 1.1rem;
1295 + align-items: start;
1296 +}
1297 +.repo-main {
1298 + min-width: 0;
1299 +}
1300 +.repo-side {
1301 + display: flex;
1302 + flex-direction: column;
1303 + gap: 0.9rem;
1304 + position: sticky;
1305 + top: 0.75rem;
1306 +}
1307 +.side-card {
1308 + border: 1px solid hsl(var(--border));
1309 + border-radius: calc(var(--radius) * 1.3);
1310 + background: hsl(var(--card));
1311 + overflow: hidden;
1312 +}
1313 +.side-card-header {
1314 + display: flex;
1315 + align-items: center;
1316 + gap: 0.4rem;
1317 + padding: 0.5rem 0.8rem;
1318 + font-size: 0.78rem;
1319 + font-weight: 600;
1320 + border-bottom: 1px solid hsl(var(--border));
1321 + background: hsl(var(--muted) / 0.3);
1322 +}
1323 +.side-card-body {
1324 + display: flex;
1325 + flex-direction: column;
1326 + gap: 0.55rem;
1327 + padding: 0.7rem 0.8rem;
1328 + font-size: 0.8rem;
1329 +}
1330 +.meta-row {
1331 + display: flex;
1332 + align-items: center;
1333 + justify-content: space-between;
1334 + gap: 0.5rem;
1335 + color: hsl(var(--muted-foreground));
1336 +}
1337 +.meta-row a,
1338 +.meta-row b {
1339 + color: hsl(var(--foreground));
1340 +}
1341 +.clone-box {
1342 + display: flex;
1343 + align-items: center;
1344 + gap: 0.35rem;
1345 + border: 1px solid hsl(var(--border));
1346 + border-radius: var(--radius);
1347 + background: hsl(var(--muted) / 0.25);
1348 + padding: 0.15rem 0.25rem 0.15rem 0.6rem;
1349 + min-width: 0;
1350 +}
1351 +.clone-box code {
1352 + flex: 1;
1353 + min-width: 0;
1354 + overflow: hidden;
1355 + text-overflow: ellipsis;
1356 + white-space: nowrap;
1357 + font-size: 0.72rem;
1358 +}
1359 +@media (max-width: 1000px) {
1360 + .repo-layout {
1361 + grid-template-columns: 1fr;
1362 + }
1363 + .repo-side {
1364 + position: static;
1365 + order: -1;
1366 + }
1367 +}
internal/web/static/embed.go
+26 -0
... ... @@ -0,0 +1,26 @@
1 +package static
2 +
3 +import (
4 + "crypto/sha256"
5 + "embed"
6 + "encoding/hex"
7 +)
8 +
9 +//go:embed css/* js/* fonts/* icons/* favicon.svg
10 +var FS embed.FS
11 +
12 +//go:embed icons/hugeicons-sprite.svg
13 +var SpriteSVG string
14 +
15 +//go:embed css/app.css
16 +var AppCSS string
17 +
18 +//go:embed js/app.js
19 +var AppJS string
20 +
21 +func AssetVersion() string {
22 + h := sha256.New()
23 + _, _ = h.Write([]byte(AppCSS))
24 + _, _ = h.Write([]byte(AppJS))
25 + return hex.EncodeToString(h.Sum(nil))[:10]
26 +}
internal/web/static/favicon.svg
+8 -0
... ... @@ -0,0 +1,8 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2 + <rect width="64" height="64" rx="14" fill="#0a0a0a"/>
3 + <path d="M22 18v24" stroke="#ffffff" stroke-width="6" stroke-linecap="round"/>
4 + <path d="M22 30c0 8 6 10 14 10" stroke="#ffffff" stroke-width="6" stroke-linecap="round" fill="none"/>
5 + <circle cx="22" cy="14" r="6" fill="#ffffff"/>
6 + <circle cx="22" cy="46" r="6" fill="#ffffff"/>
7 + <circle cx="42" cy="42" r="6" fill="#ffffff"/>
8 +</svg>
internal/web/static/fonts/Inter-Cyrillic.woff2
+0 -0
Binary file — inline preview unavailable
internal/web/static/fonts/Inter-Regular.woff2
+0 -0
Binary file — inline preview unavailable
internal/web/static/fonts/JetBrainsMono-Regular.woff2
+0 -0
Binary file — inline preview unavailable
internal/web/static/icons/hugeicons-sprite.svg
+221 -0
... ... @@ -0,0 +1,221 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
2 + <!-- Hugeicons (@hugeicons/core-free-icons, MIT License) inline SVG sprite -->
3 + <symbol id="hi-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4 + <path d="M3 7.8C3 6.12 3 5.28 3.327 4.638a3 3 0 0 1 1.311-1.311C5.28 3 6.12 3 7.8 3h1.33c.653 0 .98 0 1.276.096.262.085.505.222.712.405.234.207.415.479.778 1.023l.408.612c.363.544.544.816.778 1.023.207.183.45.32.712.405.296.096.623.096 1.276.096H16.2c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.311 1.311C21 8.94 21 9.78 21 11.46v4.74c0 1.68 0 2.52-.327 3.162a3 3 0 0 1-1.311 1.311C18.72 21 17.88 21 16.2 21H7.8c-1.68 0-2.52 0-3.162-.327a3 3 0 0 1-1.311-1.311C3 18.72 3 17.88 3 16.2V7.8Z"/>
5 + </symbol>
6 + <symbol id="hi-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
7 + <path d="M14 2.5V6c0 1.1.9 2 2 2h3.5"/>
8 + <path d="M5 17V7c0-2.21 1.79-4 4-4h5.17a3 3 0 0 1 2.12.88l2.83 2.83A3 3 0 0 1 20 8.83V17c0 2.21-1.79 4-4 4H9c-2.21 0-4-1.79-4-4Z"/>
9 + </symbol>
10 + <symbol id="hi-file-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
11 + <path d="M14 2.5V6c0 1.1.9 2 2 2h3.5"/>
12 + <path d="M5 17V7c0-2.21 1.79-4 4-4h5.17a3 3 0 0 1 2.12.88l2.83 2.83A3 3 0 0 1 20 8.83V17c0 2.21-1.79 4-4 4H9c-2.21 0-4-1.79-4-4Z"/>
13 + <path d="m10 12.5-2 2 2 2m4-4 2 2-2 2"/>
14 + </symbol>
15 + <symbol id="hi-git-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
16 + <circle cx="6" cy="6" r="2.5"/>
17 + <circle cx="18" cy="8" r="2.5"/>
18 + <circle cx="6" cy="18" r="2.5"/>
19 + <path d="M6 8.5v7M18 10.5c0 3.5-2.5 4.5-6 4.5H6"/>
20 + </symbol>
21 + <symbol id="hi-git-commit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
22 + <circle cx="12" cy="12" r="3.5"/>
23 + <path d="M3 12h5.5m7 0H21"/>
24 + </symbol>
25 + <symbol id="hi-git-merge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
26 + <circle cx="6" cy="6" r="2.5"/>
27 + <circle cx="6" cy="18" r="2.5"/>
28 + <circle cx="18" cy="16" r="2.5"/>
29 + <path d="M6 8.5v7m0-7c0 4 4 7.5 9.5 7.5"/>
30 + </symbol>
31 + <symbol id="hi-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
32 + <path d="M3.5 9.6V5.8A2.3 2.3 0 0 1 5.8 3.5h3.8c.61 0 1.2.24 1.63.67l8.6 8.6a2.3 2.3 0 0 1 0 3.26l-3.8 3.8a2.3 2.3 0 0 1-3.26 0l-8.6-8.6A2.3 2.3 0 0 1 3.5 9.6Z"/>
33 + <circle cx="7.5" cy="7.5" r="1.25" fill="currentColor"/>
34 + </symbol>
35 + <symbol id="hi-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
36 + <circle cx="11" cy="11" r="7.5"/>
37 + <path d="m16.5 16.5 4 4"/>
38 + </symbol>
39 + <symbol id="hi-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
40 + <circle cx="12" cy="12" r="3"/>
41 + <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/>
42 + </symbol>
43 + <symbol id="hi-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
44 + <circle cx="12" cy="8" r="4"/>
45 + <path d="M5 20c0-3.314 3.134-6 7-6s7 2.686 7 6"/>
46 + </symbol>
47 + <symbol id="hi-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
48 + <circle cx="9" cy="8" r="3.5"/>
49 + <path d="M3 19.5c0-2.9 2.686-5.25 6-5.25s6 2.35 6 5.25"/>
50 + <path d="M15.5 5a3.2 3.2 0 0 1 0 6.2M17.5 14.5c2.1.45 3.5 2 3.5 4.2"/>
51 + </symbol>
52 + <symbol id="hi-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
53 + <rect x="8.5" y="8.5" width="11.5" height="11.5" rx="2.5"/>
54 + <path d="M15.5 8.5V6.5A2.5 2.5 0 0 0 13 4H6.5A2.5 2.5 0 0 0 4 6.5V13a2.5 2.5 0 0 0 2.5 2.5h2"/>
55 + </symbol>
56 + <symbol id="hi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
57 + <path d="m4.5 12.5 5 5L19.5 7.5"/>
58 + </symbol>
59 + <symbol id="hi-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
60 + <path d="M12 4.5v15m7.5-7.5h-15"/>
61 + </symbol>
62 + <symbol id="hi-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
63 + <circle cx="12" cy="12" r="4.2"/>
64 + <path d="M12 2.5v2.2m0 14.6v2.2M4.93 4.93l1.56 1.56m11.02 11.02 1.56 1.56M2.5 12h2.2m14.6 0h2.2M4.93 19.07l1.56-1.56m11.02-11.02 1.56-1.56"/>
65 + </symbol>
66 + <symbol id="hi-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
67 + <path d="M20.2 14.6A8.5 8.5 0 0 1 9.4 3.8a8.5 8.5 0 1 0 10.8 10.8Z"/>
68 + </symbol>
69 + <symbol id="hi-laptop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
70 + <rect x="4" y="4.5" width="16" height="11" rx="2"/>
71 + <path d="M2.5 19.5h19"/>
72 + </symbol>
73 + <symbol id="hi-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
74 + <path d="m12 3 1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm6.5 12 .8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z"/>
75 + </symbol>
76 + <symbol id="hi-book-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
77 + <path d="M12 6.5c-2-1.7-4.7-2.5-8-2.5v14c3.3 0 6 .8 8 2.5m0-14c2-1.7 4.7-2.5 8-2.5v14c-3.3 0-6 .8-8 2.5m0-14v14"/>
78 + </symbol>
79 + <symbol id="hi-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
80 + <path d="M4 6.5h16M4 12h16M4 17.5h16"/>
81 + </symbol>
82 + <symbol id="hi-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
83 + <path d="m6 6 12 12M18 6 6 18"/>
84 + </symbol>
85 + <symbol id="hi-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
86 + <rect x="4.5" y="10.5" width="15" height="10" rx="2.5"/>
87 + <path d="M8 10.5V7.5a4 4 0 1 1 8 0v3m-4 4v2"/>
88 + </symbol>
89 + <symbol id="hi-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
90 + <rect x="4.5" y="10.5" width="15" height="10" rx="2.5"/>
91 + <path d="M8 10.5V7.5a4 4 0 0 1 7.6-1.7M12 14.5v2"/>
92 + </symbol>
93 + <symbol id="hi-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
94 + <circle cx="12" cy="12" r="9"/>
95 + <path d="M3.5 12h17M12 3c2.5 2.5 3.8 5.6 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.6-3.8-9S9.5 5.5 12 3Z"/>
96 + </symbol>
97 + <symbol id="hi-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
98 + <path d="m12 3.5 2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1-4.4-4.3 6.1-.9L12 3.5Z"/>
99 + </symbol>
100 + <symbol id="hi-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
101 + <path d="M4 6.5h16m-10 4v6m4-6v6M6 6.5l.8 12.1a2.5 2.5 0 0 0 2.5 2.4h5.4a2.5 2.5 0 0 0 2.5-2.4L18 6.5M9 6.5V4.8A1.8 1.8 0 0 1 10.8 3h2.4A1.8 1.8 0 0 1 15 4.8v1.7"/>
102 + </symbol>
103 + <symbol id="hi-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
104 + <path d="m6 9 6 6 6-6"/>
105 + </symbol>
106 + <symbol id="hi-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
107 + <path d="m9 6 6 6-6 6"/>
108 + </symbol>
109 + <symbol id="hi-arrow-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
110 + <path d="M19 12H5m6-6-6 6 6 6"/>
111 + </symbol>
112 + <symbol id="hi-arrow-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
113 + <path d="M5 12h14m-6-6 6 6-6 6"/>
114 + </symbol>
115 + <symbol id="hi-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
116 + <rect x="3" y="4" width="18" height="16" rx="2.5"/>
117 + <path d="m7.5 9.5 3 2.5-3 2.5m4.5 0h4"/>
118 + </symbol>
119 + <symbol id="hi-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
120 + <circle cx="8" cy="15" r="4.5"/>
121 + <path d="m11.2 11.8 8.3-8.3m-2.5 2.5 2.5 2.5m-5 0 2 2"/>
122 + </symbol>
123 + <symbol id="hi-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
124 + <path d="M12 3 4.5 6.2v5.3c0 5 3.2 8.7 7.5 9.5 4.3-.8 7.5-4.5 7.5-9.5V6.2L12 3Z"/>
125 + <path d="m9 12.2 2 2 4-4"/>
126 + </symbol>
127 + <symbol id="hi-alert-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
128 + <circle cx="12" cy="12" r="9"/>
129 + <path d="M12 8v4.5m0 3.5h.01"/>
130 + </symbol>
131 + <symbol id="hi-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
132 + <circle cx="12" cy="12" r="9"/>
133 + <path d="M12 11.5v4.5m0-8h.01"/>
134 + </symbol>
135 + <symbol id="hi-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
136 + <path d="m8 8-4.5 4L8 16m8-8 4.5 4L16 16m-2.5-10-3 12"/>
137 + </symbol>
138 + <symbol id="hi-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
139 + <path d="M12 4v11.5m0 0-4.5-4.5M12 15.5l4.5-4.5M4.5 19.5h15"/>
140 + </symbol>
141 + <symbol id="hi-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
142 + <path d="M3.5 12a8.5 8.5 0 1 0 2.3-5.8L3.5 8.5M3.5 4v4.5H8M12 7.5v4.8l3.2 1.8"/>
143 + </symbol>
144 + <symbol id="hi-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
145 + <path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/>
146 + <circle cx="12" cy="12" r="3"/>
147 + </symbol>
148 + <symbol id="hi-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
149 + <path d="M15.5 4.5a2.12 2.12 0 0 1 3 3L8 18l-4 1 1-4 10.5-10.5Z"/>
150 + </symbol>
151 + <symbol id="hi-log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
152 + <path d="M10 4H6.5A2.5 2.5 0 0 0 4 6.5v11A2.5 2.5 0 0 0 6.5 20H10m6-12 4 4-4 4m4-4H9.5"/>
153 + </symbol>
154 + <symbol id="hi-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
155 + <path d="m3.5 10.5 8.5-7 8.5 7v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-8Z"/>
156 + <path d="M9.5 20.5v-6h5v6"/>
157 + </symbol>
158 + <symbol id="hi-command" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
159 + <path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"/>
160 + </symbol>
161 + <symbol id="hi-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
162 + <rect x="2.5" y="5.5" width="19" height="13" rx="2.5"/>
163 + <path d="M6.5 9.5h.01M10 9.5h.01M14 9.5h.01M17.5 9.5h.01M6.5 13h.01M17.5 13h.01M9 15h6"/>
164 + </symbol>
165 + <symbol id="hi-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
166 + <rect x="3.5" y="4" width="17" height="6.5" rx="2"/>
167 + <rect x="3.5" y="13.5" width="17" height="6.5" rx="2"/>
168 + <path d="M7.5 7.25h.01M7.5 16.75h.01"/>
169 + </symbol>
170 + <symbol id="hi-database" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
171 + <ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/>
172 + <path d="M4.5 5.5v13c0 1.55 3.36 2.8 7.5 2.8s7.5-1.25 7.5-2.8v-13M4.5 12c0 1.55 3.36 2.8 7.5 2.8s7.5-1.25 7.5-2.8"/>
173 + </symbol>
174 + <symbol id="hi-zap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
175 + <path d="M13 2.5 4.5 13.5h6.5L10 21.5l9.5-11H13V2.5Z"/>
176 + </symbol>
177 + <symbol id="hi-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
178 + <circle cx="5.5" cy="12" r="1.25" fill="currentColor"/>
179 + <circle cx="12" cy="12" r="1.25" fill="currentColor"/>
180 + <circle cx="18.5" cy="12" r="1.25" fill="currentColor"/>
181 + </symbol>
182 + <symbol id="hi-sidebar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
183 + <rect x="3" y="4" width="18" height="16" rx="2.5"/>
184 + <path d="M9 4v16"/>
185 + </symbol>
186 + <symbol id="hi-map-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
187 + <path d="M12 21s-6.5-5.6-6.5-10.5a6.5 6.5 0 1 1 13 0C18.5 15.4 12 21 12 21Z"/>
188 + <circle cx="12" cy="10.5" r="2.2"/>
189 + </symbol>
190 + <symbol id="hi-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
191 + <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11 4.93"/>
192 + <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07L13 19.07"/>
193 + </symbol>
194 + <symbol id="hi-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
195 + <rect x="3.5" y="5" width="17" height="15.5" rx="2.5"/>
196 + <path d="M8 3.5v3M16 3.5v3M3.5 9.5h17"/>
197 + </symbol>
198 + <symbol id="hi-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
199 + <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5Z"/>
200 + </symbol>
201 + <symbol id="hi-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
202 + <rect x="3.5" y="3.5" width="17" height="17" rx="2.5"/>
203 + <circle cx="8.5" cy="8.5" r="1.5"/>
204 + <path d="m20.5 15-4.5-4.5L6 20.5"/>
205 + </symbol>
206 + <symbol id="hi-issue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
207 + <circle cx="12" cy="12" r="9"/>
208 + <circle cx="12" cy="12" r="2" fill="currentColor"/>
209 + </symbol>
210 + <symbol id="hi-git-pull-request" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
211 + <circle cx="18" cy="18" r="2.5"/>
212 + <circle cx="6" cy="6" r="2.5"/>
213 + <path d="M13 6h3a2 2 0 0 1 2 2v7.5"/>
214 + <path d="M6 8.5v12"/>
215 + <polyline points="15 9 12 6 15 3"/>
216 + </symbol>
217 + <symbol id="hi-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
218 + <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
219 + </symbol>
220 +</svg>
221 +
internal/web/static/js/app.js
+467 -0
... ... @@ -0,0 +1,467 @@
1 +(function () {
2 + 'use strict';
3 +
4 + function showToast(message) {
5 + var container = document.getElementById('toast-container');
6 + if (!container) return;
7 + var toast = document.createElement('div');
8 + toast.className = 'toast';
9 + toast.setAttribute('role', 'status');
10 + toast.textContent = message;
11 + container.appendChild(toast);
12 + setTimeout(function () {
13 + if (toast.parentNode) toast.parentNode.removeChild(toast);
14 + }, 2800);
15 + }
16 + window.PolarToast = showToast;
17 +
18 + function setTheme(theme, silent) {
19 + var allowed = ['light', 'dark', 'system'];
20 + if (allowed.indexOf(theme) === -1) theme = 'system';
21 + document.documentElement.setAttribute('data-theme', theme);
22 + try {
23 + localStorage.setItem('polar_theme', theme);
24 + } catch (e) {}
25 + document.cookie = 'polar_theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
26 +
27 + document.querySelectorAll('[data-set-theme]').forEach(function (el) {
28 + var isCurrent = el.getAttribute('data-set-theme') === theme;
29 + el.classList.toggle('active', isCurrent);
30 + el.setAttribute('aria-checked', isCurrent ? 'true' : 'false');
31 + });
32 + }
33 + window.PolarSetTheme = setTheme;
34 +
35 + try {
36 + var currentAttr = document.documentElement.getAttribute('data-theme') || 'system';
37 + document.querySelectorAll('[data-set-theme]').forEach(function (el) {
38 + el.classList.toggle('active', el.getAttribute('data-set-theme') === currentAttr);
39 + });
40 + } catch (e) {}
41 +
42 + function toggleSidebar() {
43 + var html = document.documentElement;
44 + var isCollapsed = html.getAttribute('data-sidebar') === 'collapsed';
45 + if (isCollapsed) {
46 + html.removeAttribute('data-sidebar');
47 + try {
48 + localStorage.setItem('polar_sidebar', 'expanded');
49 + } catch (e) {}
50 + } else {
51 + html.setAttribute('data-sidebar', 'collapsed');
52 + try {
53 + localStorage.setItem('polar_sidebar', 'collapsed');
54 + } catch (e) {}
55 + }
56 + }
57 + window.PolarToggleSidebar = toggleSidebar;
58 +
59 + function closeAllDropdowns() {
60 + document.querySelectorAll('[data-dropdown-menu].open').forEach(function (menu) {
61 + menu.classList.remove('open');
62 + var parent = menu.closest('[data-dropdown]');
63 + if (parent) {
64 + var btn = parent.querySelector('[data-dropdown-trigger]');
65 + if (btn) btn.setAttribute('aria-expanded', 'false');
66 + }
67 + });
68 + }
69 +
70 + function openDialog(id) {
71 + var dlg = document.getElementById(id);
72 + if (!dlg) return;
73 + closeAllDropdowns();
74 + dlg.classList.add('open');
75 + dlg.setAttribute('aria-hidden', 'false');
76 + var firstInput = dlg.querySelector('input:not([type="hidden"]), button[data-autofocus]');
77 + if (firstInput) {
78 + setTimeout(function () { firstInput.focus(); }, 20);
79 + }
80 + }
81 + window.PolarOpenDialog = openDialog;
82 +
83 + function closeDialog(dlg) {
84 + if (!dlg) return;
85 + dlg.classList.remove('open');
86 + dlg.setAttribute('aria-hidden', 'true');
87 + }
88 +
89 + // Command Palette logic
90 + var paletteIndex = 0;
91 +
92 + function getVisiblePaletteItems() {
93 + var list = document.querySelectorAll('#command-palette [data-palette-item]');
94 + var visible = [];
95 + list.forEach(function (item) {
96 + if (item.style.display !== 'none') visible.push(item);
97 + });
98 + return visible;
99 + }
100 +
101 + function updatePaletteSelection() {
102 + var items = getVisiblePaletteItems();
103 + if (paletteIndex >= items.length) paletteIndex = 0;
104 + if (paletteIndex < 0) paletteIndex = Math.max(0, items.length - 1);
105 + items.forEach(function (el, idx) {
106 + el.classList.toggle('selected', idx === paletteIndex);
107 + if (idx === paletteIndex) {
108 + el.scrollIntoView({ block: 'nearest' });
109 + }
110 + });
111 + }
112 +
113 + function filterPalette(query) {
114 + var q = (query || '').trim().toLowerCase();
115 + var allItems = document.querySelectorAll('#command-palette [data-palette-item]');
116 + allItems.forEach(function (item) {
117 + var text = (item.getAttribute('data-palette-keywords') || item.textContent || '').toLowerCase();
118 + var show = q === '' || text.indexOf(q) !== -1;
119 + item.style.display = show ? '' : 'none';
120 + });
121 + document.querySelectorAll('#command-palette [data-palette-group]').forEach(function (group) {
122 + var hasVisible = false;
123 + group.querySelectorAll('[data-palette-item]').forEach(function (it) {
124 + if (it.style.display !== 'none') hasVisible = true;
125 + });
126 + group.style.display = hasVisible ? '' : 'none';
127 + });
128 + paletteIndex = 0;
129 + updatePaletteSelection();
130 + }
131 +
132 + function openPalette(initialQuery) {
133 + var palette = document.getElementById('command-palette');
134 + if (!palette) return;
135 + closeAllDropdowns();
136 + palette.classList.add('open');
137 + palette.setAttribute('aria-hidden', 'false');
138 + var input = document.getElementById('command-palette-input');
139 + if (input) {
140 + input.value = initialQuery || '';
141 + filterPalette(input.value);
142 + setTimeout(function () {
143 + input.focus();
144 + if (initialQuery) input.select();
145 + }, 20);
146 + }
147 + }
148 + window.PolarOpenPalette = openPalette;
149 +
150 + function activatePaletteItem(item) {
151 + if (!item) return;
152 + var palette = document.getElementById('command-palette');
153 + var href = item.getAttribute('data-palette-href');
154 + var theme = item.getAttribute('data-palette-theme');
155 + var action = item.getAttribute('data-palette-action');
156 + if (palette) closeDialog(palette);
157 +
158 + if (theme) {
159 + setTheme(theme, false);
160 + } else if (action === 'shortcuts') {
161 + openDialog('shortcuts-dialog');
162 + } else if (action === 'toggle-sidebar') {
163 + toggleSidebar();
164 + } else if (href) {
165 + window.location.href = href;
166 + }
167 + }
168 +
169 + document.addEventListener('click', function (e) {
170 + // Toggle sidebar
171 + var sbBtn = e.target.closest('[data-toggle-sidebar]');
172 + if (sbBtn) {
173 + e.preventDefault();
174 + toggleSidebar();
175 + return;
176 + }
177 +
178 + // Dropdown trigger
179 + var trigger = e.target.closest('[data-dropdown-trigger]');
180 + if (trigger) {
181 + e.stopPropagation();
182 + var dropdown = trigger.closest('[data-dropdown]');
183 + var menu = dropdown ? dropdown.querySelector('[data-dropdown-menu]') : null;
184 + var wasOpen = menu && menu.classList.contains('open');
185 + closeAllDropdowns();
186 + if (menu && !wasOpen) {
187 + menu.classList.add('open');
188 + trigger.setAttribute('aria-expanded', 'true');
189 + }
190 + return;
191 + }
192 +
193 + // Theme button
194 + var themeBtn = e.target.closest('[data-set-theme]');
195 + if (themeBtn) {
196 + e.preventDefault();
197 + setTheme(themeBtn.getAttribute('data-set-theme'), false);
198 + closeAllDropdowns();
199 + return;
200 + }
201 +
202 + // Open Command Palette
203 + var paletteTrigger = e.target.closest('[data-open-palette]');
204 + if (paletteTrigger) {
205 + e.preventDefault();
206 + openPalette(paletteTrigger.getAttribute('data-open-palette') || '');
207 + return;
208 + }
209 +
210 + // Palette item click
211 + var paletteItem = e.target.closest('[data-palette-item]');
212 + if (paletteItem) {
213 + e.preventDefault();
214 + activatePaletteItem(paletteItem);
215 + return;
216 + }
217 +
218 + // Dialog open
219 + var dlgOpen = e.target.closest('[data-dialog-open]');
220 + if (dlgOpen) {
221 + e.preventDefault();
222 + openDialog(dlgOpen.getAttribute('data-dialog-open'));
223 + return;
224 + }
225 +
226 + // Dialog close
227 + var dlgClose = e.target.closest('[data-dialog-close]');
228 + if (dlgClose) {
229 + e.preventDefault();
230 + closeDialog(dlgClose.closest('.backdrop'));
231 + return;
232 + }
233 +
234 + // Backdrop click
235 + if (e.target.classList.contains('backdrop')) {
236 + closeDialog(e.target);
237 + return;
238 + }
239 +
240 + // Mobile drawer
241 + var drawerOpen = e.target.closest('[data-drawer-open]');
242 + if (drawerOpen) {
243 + var drw = document.getElementById('mobile-drawer');
244 + if (drw) drw.classList.add('open');
245 + return;
246 + }
247 + var drawerClose = e.target.closest('[data-drawer-close]');
248 + if (drawerClose || e.target.id === 'mobile-drawer') {
249 + var drw2 = document.getElementById('mobile-drawer');
250 + if (drw2) drw2.classList.remove('open');
251 + return;
252 + }
253 +
254 + // Copy to clipboard
255 + var copyBtn = e.target.closest('[data-copy]');
256 + if (copyBtn) {
257 + e.preventDefault();
258 + var text = copyBtn.getAttribute('data-copy');
259 + var copiedMsg = document.documentElement.getAttribute('data-copied-text') || 'Copied';
260 + if (navigator.clipboard && navigator.clipboard.writeText) {
261 + navigator.clipboard.writeText(text).then(function () {
262 + showToast(copiedMsg);
263 + });
264 + } else {
265 + var ta = document.createElement('textarea');
266 + ta.value = text;
267 + document.body.appendChild(ta);
268 + ta.select();
269 + document.execCommand('copy');
270 + document.body.removeChild(ta);
271 + showToast(copiedMsg);
272 + }
273 + return;
274 + }
275 +
276 + if (!e.target.closest('[data-dropdown-menu]')) {
277 + closeAllDropdowns();
278 + }
279 + });
280 +
281 + // Input handlers (palette filter + delete repo confirmation match)
282 + document.addEventListener('input', function (e) {
283 + if (e.target.id === 'command-palette-input') {
284 + filterPalette(e.target.value);
285 + return;
286 + }
287 +
288 + var matchExpected = e.target.getAttribute('data-confirm-input');
289 + if (matchExpected !== null) {
290 + var btnId = e.target.getAttribute('data-confirm-button');
291 + var btn = btnId ? document.getElementById(btnId) : null;
292 + if (btn) {
293 + btn.disabled = e.target.value.trim() !== matchExpected;
294 + }
295 + }
296 +
297 + if (e.target.hasAttribute('data-file-filter')) {
298 + var q = e.target.value.trim().toLowerCase();
299 + document.querySelectorAll('[data-tree-row]').forEach(function (row) {
300 + var name = (row.getAttribute('data-tree-name') || '').toLowerCase();
301 + row.style.display = !q || name.indexOf(q) !== -1 ? '' : 'none';
302 + });
303 + }
304 + });
305 +
306 + // Keyboard navigation & chords
307 + var lastChordKey = '';
308 + var lastChordTime = 0;
309 +
310 + document.addEventListener('keydown', function (e) {
311 + var palette = document.getElementById('command-palette');
312 + var isPaletteOpen = palette && palette.classList.contains('open');
313 +
314 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
315 + e.preventDefault();
316 + if (isPaletteOpen) {
317 + closeDialog(palette);
318 + } else {
319 + openPalette('');
320 + }
321 + return;
322 + }
323 +
324 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
325 + e.preventDefault();
326 + toggleSidebar();
327 + return;
328 + }
329 +
330 + if (e.key === 'Escape') {
331 + closeAllDropdowns();
332 + document.querySelectorAll('.backdrop.open').forEach(function (b) {
333 + closeDialog(b);
334 + });
335 + var drw = document.getElementById('mobile-drawer');
336 + if (drw) drw.classList.remove('open');
337 + return;
338 + }
339 +
340 + if (isPaletteOpen) {
341 + var items = getVisiblePaletteItems();
342 + if (e.key === 'ArrowDown') {
343 + e.preventDefault();
344 + paletteIndex = (paletteIndex + 1) % Math.max(1, items.length);
345 + updatePaletteSelection();
346 + } else if (e.key === 'ArrowUp') {
347 + e.preventDefault();
348 + paletteIndex = (paletteIndex - 1 + Math.max(1, items.length)) % Math.max(1, items.length);
349 + updatePaletteSelection();
350 + } else if (e.key === 'Enter') {
351 + e.preventDefault();
352 + if (items[paletteIndex]) activatePaletteItem(items[paletteIndex]);
353 + }
354 + return;
355 + }
356 +
357 + var tag = e.target && e.target.tagName ? e.target.tagName.toUpperCase() : '';
358 + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target && e.target.isContentEditable)) {
359 + return;
360 + }
361 + if (e.ctrlKey || e.metaKey || e.altKey) return;
362 +
363 + var now = Date.now();
364 + if (lastChordKey === 'g' && now - lastChordTime < 900) {
365 + lastChordKey = '';
366 + if (e.key.toLowerCase() === 'h') {
367 + e.preventDefault();
368 + window.location.href = '/';
369 + return;
370 + }
371 + if (e.key.toLowerCase() === 'r') {
372 + e.preventDefault();
373 + window.location.href = '/repos';
374 + return;
375 + }
376 + }
377 +
378 + if (e.key.toLowerCase() === 'g') {
379 + lastChordKey = 'g';
380 + lastChordTime = now;
381 + return;
382 + }
383 +
384 + if (e.key === '/') {
385 + e.preventDefault();
386 + var searchInput = document.querySelector('[data-search-input]');
387 + if (searchInput) {
388 + searchInput.focus();
389 + } else {
390 + openPalette('');
391 + }
392 + return;
393 + }
394 +
395 + if (e.key === '?') {
396 + e.preventDefault();
397 + openDialog('shortcuts-dialog');
398 + return;
399 + }
400 +
401 + if (e.key.toLowerCase() === 't') {
402 + e.preventDefault();
403 + var fileFilter = document.querySelector('[data-file-filter]');
404 + if (fileFilter) {
405 + fileFilter.focus();
406 + } else {
407 + openPalette('file:');
408 + }
409 + }
410 + });
411 +
412 + // Avatar file picker preview & client-side 256x256 square crop/resize
413 + document.addEventListener('change', function (e) {
414 + if (e.target && e.target.id === 'avatar-file-input') {
415 + var file = e.target.files && e.target.files[0];
416 + if (!file) return;
417 + var reader = new FileReader();
418 + reader.onload = function (ev) {
419 + var img = new Image();
420 + img.onload = function () {
421 + var size = 256;
422 + var canvas = document.createElement('canvas');
423 + canvas.width = size;
424 + canvas.height = size;
425 + var ctx = canvas.getContext('2d');
426 + var minSide = Math.min(img.width, img.height);
427 + var sx = (img.width - minSide) / 2;
428 + var sy = (img.height - minSide) / 2;
429 + ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size);
430 + var dataUrl = canvas.toDataURL('image/webp', 0.88);
431 + if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
432 + dataUrl = canvas.toDataURL('image/jpeg', 0.88);
433 + }
434 + var hidden = document.getElementById('avatar-data-input');
435 + var rmHidden = document.getElementById('remove-avatar-input');
436 + if (hidden) hidden.value = dataUrl;
437 + if (rmHidden) rmHidden.value = 'false';
438 + var previewWrap = document.getElementById('avatar-preview-wrap');
439 + if (previewWrap) {
440 + previewWrap.innerHTML = '<span class="avatar avatar-xl"><img src="' + dataUrl + '" alt="Avatar" class="avatar-img"></span>';
441 + }
442 + };
443 + img.src = ev.target.result;
444 + };
445 + reader.readAsDataURL(file);
446 + }
447 + });
448 +
449 + document.addEventListener('click', function (e) {
450 + var rmBtn = e.target.closest('#remove-avatar-btn');
451 + if (rmBtn) {
452 + e.preventDefault();
453 + var rmHidden = document.getElementById('remove-avatar-input');
454 + var hidden = document.getElementById('avatar-data-input');
455 + var fileInput = document.getElementById('avatar-file-input');
456 + if (rmHidden) rmHidden.value = 'true';
457 + if (hidden) hidden.value = '';
458 + if (fileInput) fileInput.value = '';
459 + var previewWrap = document.getElementById('avatar-preview-wrap');
460 + if (previewWrap) {
461 + previewWrap.innerHTML = '<span class="avatar avatar-xl">?</span>';
462 + }
463 + rmBtn.style.display = 'none';
464 + }
465 + });
466 +})();
467 +
internal/web/static/js/htmx.min.js
+1 -0
... ... @@ -0,0 +1 @@
1 +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.3"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=de;Q.ajax=Rn;Q.find=r;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=h;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:dn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:i,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:dt,triggerEvent:de,triggerErrorEvent:fe,withExtensions:Ft};const o=["get","post","put","delete","patch"];const R=o.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function h(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function i(e,t){while(e&&!t(e)){e=c(e)}return e||null}function H(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;i(t,function(e){return!!(r=H(t,ue(e),n))});if(r!=="unset"){return r}}function d(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function N(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function A(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(A(e)){const t=N(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){C(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){C(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||d(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ue(e),ge(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(f(e),ge(t.substr(5)))]}else if(t==="next"){return[ue(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[pe(e,ge(t.substr(5)),!!n)]}else if(t==="previous"){return[ue(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[me(e,ge(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[m(e,!!n)]}else if(t==="host"){return[e.getRootNode().host]}else if(t.indexOf("global ")===0){return p(e,t.slice(7),true)}else{return M(f(m(e,!!n)).querySelectorAll(ge(t)))}}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){C('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(i(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){C(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substr(0,e.indexOf(":"));n=e.substr(e.indexOf(":")+1,e.length)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=r("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=r("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=r("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=r("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ae(f(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=$(d(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){delete t[e]})}function a(e){de(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){a(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}u(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}a(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return u(e,e.firstChild,t,n)}function Fe(e,t,n){return u(c(e),e,t,n)}function Be(e,t,n){return u(e,null,t,n)}function Ue(e,t,n){return u(c(e),e.nextSibling,t,n)}function je(e){a(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;u(e,r,t,n);if(r){while(r.nextSibling){a(r.nextSibling);e.removeChild(r.nextSibling)}a(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ne(c))}}}return}}catch(e){C(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const d=n.querySelector("#"+e);if(d){He(f,d,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(ze(e.content,l,i)){e.remove()}});if(o.select){const h=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){h.appendChild(e)});n=h}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}de(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}de(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){de(n,s[e].trim(),[])}}}const Ke=/\s/;const b=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const v=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substr(r,n-r+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substr(r,n-r+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function w(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=w(e,Qe).trim();e.shift()}else{t=w(e,b)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{w(o,v);const l=o.length;const c=w(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};w(o,v);u.pollInterval=h(w(o,/[,\[\s]/));w(o,v);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}w(o,v);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(w(o,b))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=w(o,b);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const d=rt(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(w(o,b))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=w(o,b)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=w(o,b)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(d(e,"form")){return[{trigger:"submit"}]}else if(d(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(d(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(d(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ht(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(ht(l,e)){return}if(a||dt(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!d(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){de(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){de(l,"htmx:trigger");c(l,e)},u.delay)}else{de(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(o,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){a(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){de(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(R+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function Nt(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function At(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}At(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){a(t);return}const n=ie(t);if(n.initHash!==Pe(t)){De(t);n.initHash=Pe(t);de(t,"htmx:beforeProcessNode");const e=st(t);const r=wt(t,n,e);if(!r){if(re(t,"hx-boost")==="true"){ft(t,n,e)}else if(s(t,"hx-trigger")){e.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){Nt(t)}de(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){a(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){de(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){C(e)}})}function C(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function de(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){C(n.error);de(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};de(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){de(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};de(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){de(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;de(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!d(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:Nn(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function hn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!hn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=h(l.substr(5))}else if(l.indexOf("settle:")===0){r.settleDelay=h(l.substr(7))}else if(l.indexOf("transition:")===0){r.transition=l.substr(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.substr(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.substr(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="true"}else if(e==0){r.swapStyle=l}else{C("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||d(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||!e&&!y(r.source)){e=ve}return he(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return de(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Nn(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Se(r,"hx-sync")}else{d=ue(ae(r,I))}h=(A[1]||"drop").trim();u=ie(d);if(h==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(h==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const W=h.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){de(d,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!de(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=dn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:Nn(w),unfilteredFormData:v,unfilteredParameters:Nn(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!de(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){de(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}de(r,"htmx:afterRequest",H);de(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function An(e,t){const n=t.xhr;let r=null;let o=null;if(O(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(O(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(O(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=r("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!de(o,"htmx:beforeOnLoad",i))return;if(O(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(O(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=O(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(O(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(O(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=An(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let d=Q.config.ignoreTitle||r.ignoreTitle;let h=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(O(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(O(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:d,selectOverride:h,swapOverride:g},i);if(r.event&&!de(l,r.event,m))return;if(!de(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;d=m.ignoreTitle;h=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=d}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){h=c}if(O(s,/HX-Reselect:/i)){h=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){de(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);de(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);de(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:h||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(O(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(O(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&de(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}();
internal/web/views/layouts/base.html
+381 -0
... ... @@ -0,0 +1,381 @@
1 +<!DOCTYPE html>
2 +<html lang="{{.Lang}}" data-theme="{{if .Theme}}{{.Theme}}{{else}}system{{end}}" data-copied-text="{{t .Lang "common.copied"}}">
3 +<head>
4 + <meta charset="utf-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1">
6 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
7 + <title>{{if .Title}}{{.Title}} · Polar{{else}}Polar{{end}}</title>
8 + <meta name="description" content="{{t .Lang "app.tagline"}}">
9 + <script>
10 + (function(){
11 + try {
12 + var saved = localStorage.getItem('polar_theme');
13 + if (saved && ['light','dark','system'].indexOf(saved) !== -1) {
14 + document.documentElement.setAttribute('data-theme', saved);
15 + }
16 + var sb = localStorage.getItem('polar_sidebar');
17 + if (sb === 'collapsed') {
18 + document.documentElement.setAttribute('data-sidebar', 'collapsed');
19 + }
20 + } catch(e) {}
21 + })();
22 + </script>
23 + <style>{{inlineCSS}}</style>
24 + <link rel="stylesheet" href="/static/css/app.css?v={{assetVer}}">
25 +</head>
26 +<body>
27 + {{sprite}}
28 +
29 + <!-- Floating button to expand sidebar when collapsed -->
30 + <button type="button" class="sidebar-expand-btn" data-toggle-sidebar title="{{t .Lang "nav.toggle_sidebar"}} (Ctrl+B)" aria-label="{{t .Lang "nav.toggle_sidebar"}}">
31 + {{icon "sidebar" "size-4"}}
32 + </button>
33 +
34 + <div class="app-shell">
35 + <!-- Left Sidebar -->
36 + <aside class="app-sidebar" aria-label="Sidebar">
37 + <div class="sidebar-inner">
38 + <div class="flex flex-col gap-2 min-w-0">
39 + <!-- Brand, Language & Collapse Sidebar -->
40 + <div class="flex items-center justify-between px-3 py-2" style="padding:0.25rem 0.35rem;">
41 + <a href="/" class="font-semibold text-base tracking-tight" style="letter-spacing:-0.02em;">Polar</a>
42 + <div class="flex items-center gap-1">
43 + {{if eq .Lang "en"}}
44 + <a href="?lang=ru" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}">RU</a>
45 + {{else}}
46 + <a href="?lang=en" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}">EN</a>
47 + {{end}}
48 + <button type="button" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" data-toggle-sidebar title="{{t .Lang "nav.toggle_sidebar"}} (Ctrl+B)" aria-label="{{t .Lang "nav.toggle_sidebar"}}">
49 + {{icon "sidebar" "size-4"}}
50 + </button>
51 + </div>
52 + </div>
53 +
54 + <!-- Search / Command Palette Trigger -->
55 + <button type="button" class="btn btn-outline w-full justify-between text-muted" style="padding:0 0.55rem;height:1.95rem;" data-open-palette="">
56 + <span class="flex items-center gap-2 text-xs">
57 + {{icon "search" "size-3.5"}}
58 + <span>{{t .Lang "nav.search_placeholder"}}</span>
59 + </span>
60 + <kbd class="kbd">⌘K</kbd>
61 + </button>
62 +
63 + <!-- Primary Navigation -->
64 + <nav class="flex flex-col gap-1 mt-1">
65 + {{if .CurrentUser}}
66 + <a href="/repos" class="nav-item">
67 + <span class="flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "nav.repositories"}}</span>
68 + </a>
69 + {{else}}
70 + <a href="/" class="nav-item">
71 + <span class="flex items-center gap-2">{{icon "home" "size-4"}} {{t .Lang "nav.home"}}</span>
72 + </a>
73 + {{end}}
74 + {{if .ExploreEnabled}}
75 + <a href="/explore" class="nav-item">
76 + <span class="flex items-center gap-2">{{icon "globe" "size-4"}} {{t .Lang "nav.explore"}}</span>
77 + </a>
78 + {{end}}
79 + {{if .CurrentUser}}
80 + <a href="/settings" class="nav-item">
81 + <span class="flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "nav.settings"}}</span>
82 + </a>
83 + {{if .CurrentUser.IsAdmin}}
84 + <a href="/admin" class="nav-item">
85 + <span class="flex items-center gap-2">{{icon "shield" "size-4"}} {{t .Lang "nav.admin"}}</span>
86 + </a>
87 + {{end}}
88 + {{end}}
89 + </nav>
90 +
91 + <!-- Quick Repository List in Sidebar -->
92 + <div class="mt-2">
93 + <div class="nav-section-label">
94 + <span>{{t .Lang "nav.repositories"}}</span>
95 + {{if .CurrentUser}}
96 + <a href="/new" class="btn btn-ghost btn-icon" style="width:1.35rem;height:1.35rem;" title="{{t .Lang "repo.new.title"}}">
97 + {{icon "plus" "size-3.5"}}
98 + </a>
99 + {{end}}
100 + </div>
101 + <div class="flex flex-col gap-1">
102 + {{if .PaletteRepos}}
103 + {{range .PaletteRepos}}
104 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="nav-item" style="height:1.8rem;font-size:0.8rem;">
105 + <span class="flex items-center gap-2 min-w-0 truncate">
106 + {{if .IsPrivate}}
107 + {{icon "lock" "size-3.5"}}
108 + {{else}}
109 + {{icon "folder" "size-3.5"}}
110 + {{end}}
111 + <span class="truncate">{{.OwnerUsername}}/<strong>{{.Name}}</strong></span>
112 + </span>
113 + </a>
114 + {{end}}
115 + {{else}}
116 + {{if .CurrentUser}}
117 + <a href="/new" class="nav-item text-xs">
118 + <span class="flex items-center gap-1.5">{{icon "plus" "size-3.5"}} {{t .Lang "repo.new.title"}}</span>
119 + </a>
120 + {{end}}
121 + {{end}}
122 + </div>
123 + </div>
124 + </div>
125 +
126 + <!-- Sidebar Bottom: 3-Theme Switcher + Account -->
127 + <div class="flex flex-col gap-2 pt-2" style="border-top:1px solid hsl(var(--border));">
128 + <div class="segmented" role="radiogroup" aria-label="{{t .Lang "nav.theme"}}">
129 + <button type="button" class="segmented-btn {{if eq .Theme "light"}}active{{end}}" data-set-theme="light" title="{{t .Lang "theme.light"}}">
130 + {{icon "sun" "size-3.5"}}
131 + <span>{{t .Lang "theme.light"}}</span>
132 + </button>
133 + <button type="button" class="segmented-btn {{if eq .Theme "dark"}}active{{end}}" data-set-theme="dark" title="{{t .Lang "theme.dark"}}">
134 + {{icon "moon" "size-3.5"}}
135 + <span>{{t .Lang "theme.dark"}}</span>
136 + </button>
137 + <button type="button" class="segmented-btn {{if eq .Theme "system"}}active{{end}}" data-set-theme="system" title="{{t .Lang "theme.system"}}">
138 + {{icon "laptop" "size-3.5"}}
139 + <span>{{t .Lang "theme.system"}}</span>
140 + </button>
141 + </div>
142 +
143 + {{if .CurrentUser}}
144 + <div class="flex items-center justify-between gap-1" style="padding:0.15rem 0.25rem;">
145 + <a href="/{{.CurrentUser.Username}}" class="flex items-center gap-2 min-w-0 truncate text-sm font-medium">
146 + {{avatar .CurrentUser.Username .CurrentUser.AvatarURL}}
147 + <span class="truncate">{{.CurrentUser.Username}}</span>
148 + </a>
149 + <div class="flex items-center gap-1">
150 + <form method="post" action="/logout" style="margin:0;">
151 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
152 + <button type="submit" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" title="{{t .Lang "nav.logout"}}" aria-label="{{t .Lang "nav.logout"}}">
153 + {{icon "log-out" "size-3.5"}}
154 + </button>
155 + </form>
156 + </div>
157 + </div>
158 + {{else}}
159 + <div class="flex flex-col gap-1.5">
160 + <a href="/login" class="btn btn-outline btn-sm w-full">{{t .Lang "nav.login"}}</a>
161 + {{if .RegistrationEnabled}}
162 + <a href="/register" class="btn btn-primary btn-sm w-full">{{t .Lang "nav.register"}}</a>
163 + {{end}}
164 + </div>
165 + {{end}}
166 + </div>
167 + </div>
168 + </aside>
169 +
170 + <!-- Main Content Column -->
171 + <div class="app-col">
172 + <!-- Mobile Topbar (only visible on small screens) -->
173 + <div class="mobile-topbar">
174 + <div class="flex items-center gap-2">
175 + <button type="button" class="btn btn-ghost btn-icon" data-drawer-open aria-label="Menu">
176 + {{icon "menu" "size-4"}}
177 + </button>
178 + <a href="/" class="font-semibold text-base tracking-tight" style="letter-spacing:-0.02em;">Polar</a>
179 + </div>
180 + <div class="flex items-center gap-1.5">
181 + <button type="button" class="btn btn-ghost btn-icon" data-open-palette="" aria-label="{{t .Lang "nav.command_palette"}}">
182 + {{icon "search" "size-4"}}
183 + </button>
184 + {{if .CurrentUser}}
185 + <a href="/new" class="btn btn-primary btn-sm">{{icon "plus" "size-3.5"}}</a>
186 + {{else}}
187 + <a href="/login" class="btn btn-outline btn-sm">{{t .Lang "nav.login"}}</a>
188 + {{end}}
189 + </div>
190 + </div>
191 +
192 + <main class="app-main">
193 + {{if .Flash}}
194 + <div class="container mb-4">
195 + <div class="card p-3 flex items-center justify-between gap-2" role="status" style="border-color:hsl(var(--primary) / 0.4);">
196 + <span class="flex items-center gap-2 text-sm">{{icon "check" "size-4"}} {{.Flash}}</span>
197 + </div>
198 + </div>
199 + {{end}}
200 + {{if .Error}}
201 + <div class="container mb-4">
202 + <div class="card p-3 flex items-center justify-between gap-2" role="alert" style="border-color:hsl(var(--destructive) / 0.6);color:hsl(var(--destructive));">
203 + <span class="flex items-center gap-2 text-sm">{{icon "alert-circle" "size-4"}} {{.Error}}</span>
204 + </div>
205 + </div>
206 + {{end}}
207 +
208 + {{template "content" .}}
209 + </main>
210 + </div>
211 + </div>
212 +
213 + <!-- Mobile Drawer -->
214 + <div id="mobile-drawer" class="mobile-drawer" aria-hidden="true">
215 + <div class="mobile-drawer-panel">
216 + <div class="flex flex-col gap-2">
217 + <div class="flex items-center justify-between" style="padding-bottom:0.5rem;border-bottom:1px solid hsl(var(--border));">
218 + <span class="font-semibold text-base tracking-tight" style="letter-spacing:-0.02em;">Polar</span>
219 + <button type="button" class="btn btn-ghost btn-icon" data-drawer-close aria-label="{{t .Lang "common.close"}}">
220 + {{icon "close" "size-4"}}
221 + </button>
222 + </div>
223 + <nav class="flex flex-col gap-1">
224 + <a href="/" class="nav-item">{{icon "home" "size-4"}} {{t .Lang "nav.home"}}</a>
225 + {{if .CurrentUser}}
226 + <a href="/repos" class="nav-item">{{icon "git-branch" "size-4"}} {{t .Lang "nav.repositories"}}</a>
227 + <a href="/new" class="nav-item">{{icon "plus" "size-4"}} {{t .Lang "repo.new.title"}}</a>
228 + <a href="/settings" class="nav-item">{{icon "settings" "size-4"}} {{t .Lang "nav.settings"}}</a>
229 + {{if .CurrentUser.IsAdmin}}
230 + <a href="/admin" class="nav-item">{{icon "shield" "size-4"}} {{t .Lang "nav.admin"}}</a>
231 + {{end}}
232 + {{end}}
233 + {{if .ExploreEnabled}}
234 + <a href="/explore" class="nav-item">{{icon "globe" "size-4"}} {{t .Lang "nav.explore"}}</a>
235 + {{end}}
236 + </nav>
237 + </div>
238 + <div class="segmented">
239 + <button type="button" class="segmented-btn" data-set-theme="light">{{t .Lang "theme.light"}}</button>
240 + <button type="button" class="segmented-btn" data-set-theme="dark">{{t .Lang "theme.dark"}}</button>
241 + <button type="button" class="segmented-btn" data-set-theme="system">{{t .Lang "theme.system"}}</button>
242 + </div>
243 + </div>
244 + </div>
245 +
246 + <!-- Command Palette (Ctrl/Cmd+K) -->
247 + <div id="command-palette" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "nav.command_palette"}}" aria-hidden="true">
248 + <div class="palette-box">
249 + <div class="palette-input-wrap">
250 + {{icon "search" "size-4"}}
251 + <input id="command-palette-input" type="text" class="palette-input" placeholder="{{t .Lang "palette.placeholder"}}" autocomplete="off">
252 + <kbd class="kbd">ESC</kbd>
253 + </div>
254 + <div class="palette-list" role="listbox">
255 + <div data-palette-group>
256 + <div class="palette-group-title">{{t .Lang "palette.group.nav"}}</div>
257 + <button type="button" class="palette-item" data-palette-item data-palette-href="/" data-palette-keywords="home главная домой">
258 + <span class="flex items-center gap-2">{{icon "home" "size-4"}} {{t .Lang "nav.home"}}</span>
259 + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">h</kbd></span>
260 + </button>
261 + <button type="button" class="palette-item" data-palette-item data-palette-href="/repos" data-palette-keywords="repositories repos репозитории">
262 + <span class="flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "nav.repositories"}}</span>
263 + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">r</kbd></span>
264 + </button>
265 + {{if .ExploreEnabled}}
266 + <button type="button" class="palette-item" data-palette-item data-palette-href="/explore" data-palette-keywords="explore обзор публичные">
267 + <span class="flex items-center gap-2">{{icon "globe" "size-4"}} {{t .Lang "nav.explore"}}</span>
268 + </button>
269 + {{end}}
270 + {{if .CurrentUser}}
271 + <button type="button" class="palette-item" data-palette-item data-palette-href="/new" data-palette-keywords="new create создать репозиторий">
272 + <span class="flex items-center gap-2">{{icon "plus" "size-4"}} {{t .Lang "repo.new.title"}}</span>
273 + </button>
274 + <button type="button" class="palette-item" data-palette-item data-palette-href="/settings" data-palette-keywords="settings tokens настройки токены">
275 + <span class="flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "nav.settings"}}</span>
276 + </button>
277 + {{if .CurrentUser.IsAdmin}}
278 + <button type="button" class="palette-item" data-palette-item data-palette-href="/admin" data-palette-keywords="admin админка">
279 + <span class="flex items-center gap-2">{{icon "shield" "size-4"}} {{t .Lang "nav.admin"}}</span>
280 + </button>
281 + {{end}}
282 + {{else}}
283 + <button type="button" class="palette-item" data-palette-item data-palette-href="/login" data-palette-keywords="login войти">
284 + <span class="flex items-center gap-2">{{icon "user" "size-4"}} {{t .Lang "nav.login"}}</span>
285 + </button>
286 + <button type="button" class="palette-item" data-palette-item data-palette-href="/register" data-palette-keywords="register регистрация">
287 + <span class="flex items-center gap-2">{{icon "plus" "size-4"}} {{t .Lang "nav.register"}}</span>
288 + </button>
289 + {{end}}
290 + <button type="button" class="palette-item" data-palette-item data-palette-action="toggle-sidebar" data-palette-keywords="sidebar toggle боковая панель скрыть">
291 + <span class="flex items-center gap-2">{{icon "sidebar" "size-4"}} {{t .Lang "nav.toggle_sidebar"}}</span>
292 + <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">B</kbd></span>
293 + </button>
294 + </div>
295 +
296 + {{if .PaletteFiles}}
297 + <div data-palette-group>
298 + <div class="palette-group-title">{{t .Lang "repo.files"}} ({{.Repo.OwnerUsername}}/{{.Repo.Name}})</div>
299 + {{range .PaletteFiles}}
300 + <button type="button" class="palette-item" data-palette-item data-palette-href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.}}" data-palette-keywords="file: {{.}}">
301 + <span class="flex items-center gap-2 font-mono text-xs">{{icon "file-code" "size-4"}} {{.}}</span>
302 + </button>
303 + {{end}}
304 + </div>
305 + {{end}}
306 +
307 + {{if .PaletteRepos}}
308 + <div data-palette-group>
309 + <div class="palette-group-title">{{t .Lang "palette.group.repos"}}</div>
310 + {{range .PaletteRepos}}
311 + <button type="button" class="palette-item" data-palette-item data-palette-href="/{{.OwnerUsername}}/{{.Name}}" data-palette-keywords="repo {{.OwnerUsername}}/{{.Name}} {{.Description}}">
312 + <span class="flex items-center gap-2">
313 + {{if .IsPrivate}}{{icon "lock" "size-4"}}{{else}}{{icon "git-branch" "size-4"}}{{end}}
314 + <span>{{.OwnerUsername}}/<strong>{{.Name}}</strong></span>
315 + </span>
316 + <span class="text-xs text-muted">{{if .IsPrivate}}{{t $.Lang "repo.private"}}{{else}}{{t $.Lang "repo.public"}}{{end}}</span>
317 + </button>
318 + {{end}}
319 + </div>
320 + {{end}}
321 +
322 + <div data-palette-group>
323 + <div class="palette-group-title">{{t .Lang "palette.group.themes"}}</div>
324 + <button type="button" class="palette-item" data-palette-item data-palette-theme="light" data-palette-keywords="theme light светлая">
325 + <span class="flex items-center gap-2">{{icon "sun" "size-4"}} {{t .Lang "theme.light"}}</span>
326 + </button>
327 + <button type="button" class="palette-item" data-palette-item data-palette-theme="dark" data-palette-keywords="theme dark темная тёмная">
328 + <span class="flex items-center gap-2">{{icon "moon" "size-4"}} {{t .Lang "theme.dark"}}</span>
329 + </button>
330 + <button type="button" class="palette-item" data-palette-item data-palette-theme="system" data-palette-keywords="theme system авто системная">
331 + <span class="flex items-center gap-2">{{icon "laptop" "size-4"}} {{t .Lang "theme.system"}}</span>
332 + </button>
333 + </div>
334 + </div>
335 + </div>
336 + </div>
337 +
338 + <!-- Keyboard Shortcuts Dialog (?) -->
339 + <div id="shortcuts-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "shortcuts.title"}}" aria-hidden="true">
340 + <div class="dialog-box">
341 + <div class="card-header">
342 + <span class="font-semibold flex items-center gap-2">{{icon "keyboard" "size-4"}} {{t .Lang "shortcuts.title"}}</span>
343 + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">
344 + {{icon "close" "size-4"}}
345 + </button>
346 + </div>
347 + <div class="card-body flex flex-col gap-2 text-sm">
348 + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
349 + <span>{{t .Lang "shortcuts.palette"}}</span>
350 + <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">K</kbd></span>
351 + </div>
352 + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
353 + <span>{{t .Lang "shortcuts.sidebar"}}</span>
354 + <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">B</kbd></span>
355 + </div>
356 + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
357 + <span>{{t .Lang "shortcuts.search"}}</span>
358 + <kbd class="kbd">/</kbd>
359 + </div>
360 + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
361 + <span>{{t .Lang "shortcuts.home"}}</span>
362 + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">h</kbd></span>
363 + </div>
364 + <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
365 + <span>{{t .Lang "shortcuts.repos"}}</span>
366 + <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">r</kbd></span>
367 + </div>
368 + <div class="flex items-center justify-between py-2">
369 + <span>{{t .Lang "shortcuts.files"}}</span>
370 + <kbd class="kbd">t</kbd>
371 + </div>
372 + </div>
373 + </div>
374 + </div>
375 +
376 + <div id="toast-container" class="toast-container" aria-live="polite"></div>
377 +
378 + <script src="/static/js/htmx.min.js" defer></script>
379 + <script src="/static/js/app.js?v={{assetVer}}" defer></script>
380 +</body>
381 +</html>
internal/web/views/pages/activity.html
+107 -0
... ... @@ -0,0 +1,107 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <!-- Pulse Statistics -->
6 + {{if .Stats}}
7 + <div class="grid-4 mb-4">
8 + <div class="card p-3">
9 + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1">
10 + <span>{{t .Lang "activity.commits"}}</span>
11 + {{icon "git-commit" "size-4"}}
12 + </div>
13 + <div class="text-xl font-bold font-mono">{{.Stats.CommitCount}}</div>
14 + <div class="text-xs text-muted mt-1">{{t .Lang "activity.on_default_branch"}}</div>
15 + </div>
16 +
17 + <div class="card p-3">
18 + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1">
19 + <span>{{t .Lang "repo.tab.pulls"}}</span>
20 + {{icon "git-pull-request" "size-4"}}
21 + </div>
22 + <div class="flex items-baseline gap-2 font-mono">
23 + <span class="text-xl font-bold" style="color:hsl(var(--primary));">{{.Stats.OpenPRs}}</span>
24 + <span class="text-xs text-muted">/ {{.Stats.MergedPRs}} {{t .Lang "pulls.status.merged"}}</span>
25 + </div>
26 + <div class="text-xs text-muted mt-1">{{t .Lang "activity.pull_requests"}}</div>
27 + </div>
28 +
29 + <div class="card p-3">
30 + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1">
31 + <span>{{t .Lang "repo.tab.issues"}}</span>
32 + {{icon "issue" "size-4"}}
33 + </div>
34 + <div class="flex items-baseline gap-2 font-mono">
35 + <span class="text-xl font-bold" style="color:hsl(var(--primary));">{{.Stats.OpenIssues}}</span>
36 + <span class="text-xs text-muted">/ {{.Stats.ClosedIssues}} {{t .Lang "issue.status.closed"}}</span>
37 + </div>
38 + <div class="text-xs text-muted mt-1">{{t .Lang "activity.issues"}}</div>
39 + </div>
40 +
41 + <div class="card p-3">
42 + <div class="flex items-center justify-between text-muted text-xs font-semibold uppercase mb-1">
43 + <span>{{t .Lang "repo.tab.releases"}}</span>
44 + {{icon "tag" "size-4"}}
45 + </div>
46 + <div class="text-xl font-bold font-mono">{{.Stats.ReleaseCount}}</div>
47 + <div class="text-xs text-muted mt-1">{{t .Lang "activity.published_releases"}}</div>
48 + </div>
49 + </div>
50 + {{end}}
51 +
52 + <!-- Activity Timeline -->
53 + <div class="card">
54 + <div class="card-header flex items-center justify-between">
55 + <span class="font-semibold text-sm flex items-center gap-2">
56 + {{icon "activity" "size-4"}}
57 + <span>{{t .Lang "activity.recent_feed"}}</span>
58 + </span>
59 + </div>
60 +
61 + {{if .Items}}
62 + <div class="dense-list">
63 + {{range .Items}}
64 + <div class="dense-row" style="min-height:3rem;padding:0.6rem 0.85rem;">
65 + <div class="flex items-center gap-3 min-w-0">
66 + <span class="text-muted flex-shrink-0" style="display:inline-flex;align-items:center;">
67 + {{if eq .Type "commit"}}
68 + <span style="color:hsl(var(--primary));">{{icon "git-commit" "size-4"}}</span>
69 + {{else if eq .Type "pull"}}
70 + <span style="color:hsl(var(--accent));">{{icon "git-pull-request" "size-4"}}</span>
71 + {{else if eq .Type "issue"}}
72 + <span style="color:hsl(var(--primary));">{{icon "issue" "size-4"}}</span>
73 + {{else if eq .Type "release"}}
74 + <span style="color:hsl(var(--warning-fg, 38 92% 50%));">{{icon "tag" "size-4"}}</span>
75 + {{else}}
76 + {{icon "activity" "size-4"}}
77 + {{end}}
78 + </span>
79 +
80 + <div class="flex flex-col min-w-0">
81 + <div class="flex items-center gap-2 flex-wrap min-w-0">
82 + <a href="{{.Link}}" class="font-semibold text-sm truncate">{{.Title}}</a>
83 + {{if .Subtitle}}
84 + <span class="font-mono text-xs text-muted">{{.Subtitle}}</span>
85 + {{end}}
86 + {{if .Badge}}
87 + <span class="badge badge-outline text-xs" style="padding:0.05rem 0.35rem;">{{activityBadge $.Lang .Type .Badge}}</span>
88 + {{end}}
89 + </div>
90 + <div class="text-xs text-muted flex items-center gap-1.5 mt-0.5">
91 + {{if .AuthorUsername}}
92 + <span>@{{.AuthorUsername}}</span>
93 + <span>·</span>
94 + {{end}}
95 + <span>{{timeAgo .CreatedAt $.Lang}}</span>
96 + </div>
97 + </div>
98 + </div>
99 + </div>
100 + {{end}}
101 + </div>
102 + {{else}}
103 + <div class="card-body text-xs text-muted">{{t .Lang "activity.empty"}}</div>
104 + {{end}}
105 + </div>
106 +</div>
107 +{{end}}
internal/web/views/pages/admin.html
+171 -0
... ... @@ -0,0 +1,171 @@
1 +{{define "content"}}
2 +<div class="container">
3 + <div class="flex items-center justify-between mb-4">
4 + <h1 class="text-lg font-semibold" style="margin:0;">{{t .Lang "admin.title"}}</h1>
5 + </div>
6 +
7 + <!-- Feature Toggles (Modules & Access) -->
8 + <section class="card mb-4">
9 + <div class="card-header">
10 + <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "admin.features"}}</span>
11 + </div>
12 + <div class="dense-list">
13 + <!-- 1. Explore Page -->
14 + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
15 + <div class="flex flex-col gap-1 min-w-0">
16 + <div class="flex items-center gap-2 font-medium text-sm">
17 + {{icon "globe" "size-4"}}
18 + <span>{{t .Lang "admin.explore"}}</span>
19 + {{if .ExploreEnabled}}
20 + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span>
21 + {{else}}
22 + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span>
23 + {{end}}
24 + </div>
25 + <div class="text-xs text-muted">{{t .Lang "admin.explore.desc"}}</div>
26 + </div>
27 + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;">
28 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
29 + <input type="hidden" name="setting_key" value="explore_enabled">
30 + {{if .ExploreEnabled}}
31 + <input type="hidden" name="setting_val" value="false">
32 + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button>
33 + {{else}}
34 + <input type="hidden" name="setting_val" value="true">
35 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button>
36 + {{end}}
37 + </form>
38 + </div>
39 +
40 + <!-- 2. Public Registration -->
41 + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
42 + <div class="flex flex-col gap-1 min-w-0">
43 + <div class="flex items-center gap-2 font-medium text-sm">
44 + {{icon "user" "size-4"}}
45 + <span>{{t .Lang "admin.registration"}}</span>
46 + {{if .RegistrationEnabled}}
47 + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span>
48 + {{else}}
49 + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span>
50 + {{end}}
51 + </div>
52 + <div class="text-xs text-muted">{{t .Lang "admin.registration.desc"}}</div>
53 + </div>
54 + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;">
55 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
56 + <input type="hidden" name="setting_key" value="registration_enabled">
57 + {{if .RegistrationEnabled}}
58 + <input type="hidden" name="setting_val" value="false">
59 + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button>
60 + {{else}}
61 + <input type="hidden" name="setting_val" value="true">
62 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button>
63 + {{end}}
64 + </form>
65 + </div>
66 +
67 +
68 + <!-- 4. Landing Page -->
69 + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
70 + <div class="flex flex-col gap-1 min-w-0">
71 + <div class="flex items-center gap-2 font-medium text-sm">
72 + {{icon "home" "size-4"}}
73 + <span>{{t .Lang "admin.landing"}}</span>
74 + {{if .LandingEnabled}}
75 + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span>
76 + {{else}}
77 + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span>
78 + {{end}}
79 + </div>
80 + <div class="text-xs text-muted">{{t .Lang "admin.landing.desc"}}</div>
81 + </div>
82 + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;">
83 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
84 + <input type="hidden" name="setting_key" value="landing_enabled">
85 + {{if .LandingEnabled}}
86 + <input type="hidden" name="setting_val" value="false">
87 + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button>
88 + {{else}}
89 + <input type="hidden" name="setting_val" value="true">
90 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button>
91 + {{end}}
92 + </form>
93 + </div>
94 +
95 + <!-- 5. Anonymous Read Access -->
96 + <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;">
97 + <div class="flex flex-col gap-1 min-w-0">
98 + <div class="flex items-center gap-2 font-medium text-sm">
99 + {{icon "eye" "size-4"}}
100 + <span>{{t .Lang "admin.anon_read"}}</span>
101 + {{if .AnonymousReadEnabled}}
102 + <span class="badge badge-primary">{{t .Lang "admin.enabled"}}</span>
103 + {{else}}
104 + <span class="badge badge-outline">{{t .Lang "admin.disabled"}}</span>
105 + {{end}}
106 + </div>
107 + <div class="text-xs text-muted">{{t .Lang "admin.anon_read.desc"}}</div>
108 + </div>
109 + <form method="post" action="/admin/settings" style="margin:0;flex-shrink:0;">
110 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
111 + <input type="hidden" name="setting_key" value="anonymous_read_enabled">
112 + {{if .AnonymousReadEnabled}}
113 + <input type="hidden" name="setting_val" value="false">
114 + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "admin.disable_btn"}}</button>
115 + {{else}}
116 + <input type="hidden" name="setting_val" value="true">
117 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "admin.enable_btn"}}</button>
118 + {{end}}
119 + </form>
120 + </div>
121 + </div>
122 + </section>
123 +
124 + <!-- Users Table -->
125 + <section class="card">
126 + <div class="card-header">
127 + <span class="font-semibold flex items-center gap-2">{{icon "users" "size-4"}} {{t .Lang "admin.users"}} ({{len .Users}})</span>
128 + </div>
129 + <div class="table-wrap" style="border:0;border-radius:0;">
130 + <table class="table">
131 + <thead>
132 + <tr>
133 + <th>ID</th>
134 + <th>{{t .Lang "admin.col.user"}}</th>
135 + <th>{{t .Lang "auth.email"}}</th>
136 + <th>{{t .Lang "admin.col.repos"}}</th>
137 + <th>{{t .Lang "admin.col.role"}}</th>
138 + <th>{{t .Lang "admin.col.joined"}}</th>
139 + </tr>
140 + </thead>
141 + <tbody>
142 + {{range .Users}}
143 + <tr>
144 + <td class="font-mono text-xs text-muted">#{{.ID}}</td>
145 + <td>
146 + <a href="/{{.Username}}" class="flex items-center gap-2 font-medium">
147 + {{avatar .Username .AvatarURL}}
148 + <div class="flex flex-col">
149 + <span>{{.Username}}</span>
150 + {{if .FullName}}<span class="text-xs text-muted">{{.FullName}}</span>{{end}}
151 + </div>
152 + </a>
153 + </td>
154 + <td class="text-xs text-muted">{{.Email}}</td>
155 + <td class="font-mono text-xs">{{.RepoCount}}</td>
156 + <td>
157 + {{if .IsAdmin}}
158 + <span class="badge badge-primary">{{t $.Lang "admin.role.admin"}}</span>
159 + {{else}}
160 + <span class="badge badge-outline">{{t $.Lang "admin.role.user"}}</span>
161 + {{end}}
162 + </td>
163 + <td class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</td>
164 + </tr>
165 + {{end}}
166 + </tbody>
167 + </table>
168 + </div>
169 + </section>
170 +</div>
171 +{{end}}
internal/web/views/pages/blob.html
+106 -0
... ... @@ -0,0 +1,106 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
6 + <div class="flex items-center gap-2 flex-wrap">
7 + <div class="dropdown" data-dropdown>
8 + <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false">
9 + {{icon "git-branch" "size-3.5"}}
10 + <span>{{.Ref}}</span>
11 + {{icon "chevron-down" "size-3.5"}}
12 + </button>
13 + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;">
14 + <div class="palette-group-title">{{t .Lang "repo.branches"}}</div>
15 + {{range .Branches}}
16 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{.}}/{{$.SubPath}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
17 + <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span>
18 + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
19 + </a>
20 + {{end}}
21 + {{if .Tags}}
22 + <hr class="separator" style="margin:0.25rem 0;">
23 + <div class="palette-group-title">{{t .Lang "repo.tags"}}</div>
24 + {{range .Tags}}
25 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{.}}/{{$.SubPath}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
26 + <span class="flex items-center gap-2">{{icon "tag" "size-3.5"}} {{.}}</span>
27 + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
28 + </a>
29 + {{end}}
30 + {{end}}
31 + </div>
32 + </div>
33 +
34 + <nav class="breadcrumb font-mono text-xs" aria-label="File breadcrumb">
35 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}" class="text-muted">{{.Repo.Name}}</a>
36 + {{range .Breadcrumbs}}
37 + <span class="breadcrumb-sep">/</span>
38 + {{if .IsLast}}
39 + <span class="font-semibold">{{.Name}}</span>
40 + {{else}}
41 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="text-muted">{{.Name}}</a>
42 + {{end}}
43 + {{end}}
44 + </nav>
45 + </div>
46 +
47 + <div class="flex items-center gap-1.5">
48 + <button type="button" class="btn btn-outline btn-sm" data-open-palette="file:">
49 + {{icon "search" "size-3.5"}}
50 + <kbd class="kbd">t</kbd>
51 + </button>
52 + </div>
53 + </div>
54 +
55 + <div class="card">
56 + <div class="card-header" style="padding:0.5rem 0.8rem;">
57 + <div class="flex items-center gap-2 text-xs text-muted font-mono">
58 + {{icon "file-code" "size-4"}}
59 + <span class="font-semibold" style="color:hsl(var(--foreground));">{{.Blob.Name}}</span>
60 + <span>·</span>
61 + {{if not .Blob.IsBinary}}
62 + <span>{{.Blob.LineCount}} {{pluralT .Lang .Blob.LineCount "repo.lines"}}</span>
63 + <span>·</span>
64 + {{end}}
65 + <span>{{formatSize .Blob.Size}}</span>
66 + </div>
67 +
68 + <div class="flex items-center gap-1.5">
69 + {{if not .Blob.IsBinary}}
70 + <button type="button" class="btn btn-outline btn-sm" data-copy="{{.RawText}}" aria-label="{{t .Lang "common.copy"}}">
71 + {{icon "copy" "size-3.5"}}
72 + <span>{{t .Lang "common.copy"}}</span>
73 + </button>
74 + {{end}}
75 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.SubPath}}" class="btn btn-outline btn-sm">
76 + {{icon "download" "size-3.5"}}
77 + <span>{{t .Lang "repo.raw"}}</span>
78 + </a>
79 + </div>
80 + </div>
81 +
82 + {{if .Blob.IsBinary}}
83 + <div class="card-body text-center py-2 text-sm text-muted">
84 + <p>{{t .Lang "repo.binary_notice"}} ({{formatSize .Blob.Size}})</p>
85 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.SubPath}}" class="btn btn-secondary btn-sm">
86 + {{icon "download" "size-3.5"}}
87 + <span>{{t .Lang "repo.download_raw"}}</span>
88 + </a>
89 + </div>
90 + {{else}}
91 + <div style="overflow-x:auto;">
92 + <table class="code-table chroma">
93 + <tbody>
94 + {{range .HighlightedLines}}
95 + <tr id="L{{.Num}}">
96 + <td class="code-ln"><a href="#L{{.Num}}">{{.Num}}</a></td>
97 + <td class="code-line">{{.HTML}}</td>
98 + </tr>
99 + {{end}}
100 + </tbody>
101 + </table>
102 + </div>
103 + {{end}}
104 + </div>
105 +</div>
106 +{{end}}
internal/web/views/pages/branches.html
+129 -0
... ... @@ -0,0 +1,129 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <!-- Branches Section -->
6 + <section class="card mb-4">
7 + <div class="card-header">
8 + <span class="font-semibold flex items-center gap-2">
9 + {{icon "git-branch" "size-4"}}
10 + <span>{{t .Lang "repo.branches"}} ({{len .BranchDetails}})</span>
11 + </span>
12 + </div>
13 +
14 + {{if and .CanWrite (not .IsEmpty)}}
15 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches" class="card-body flex items-center flex-wrap gap-2" style="border-bottom:1px solid hsl(var(--border));background:hsl(var(--muted)/0.25);">
16 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
17 + <input name="branch_name" type="text" class="input font-mono" style="flex:1 1 12rem;height:1.9rem;" placeholder="{{t .Lang "repo.branch.name_ph"}}" required maxlength="100">
18 + <select name="from_ref" class="select font-mono" style="flex:0 1 11rem;height:1.9rem;">
19 + {{range .BranchDetails}}
20 + <option value="{{.Name}}" {{if .IsDefault}}selected{{end}}>{{.Name}}</option>
21 + {{end}}
22 + </select>
23 + <button type="submit" class="btn btn-primary btn-sm" style="height:1.9rem;">
24 + {{icon "plus" "size-3.5"}}
25 + <span>{{t .Lang "repo.branch.create"}}</span>
26 + </button>
27 + </form>
28 + {{end}}
29 +
30 + {{if .BranchDetails}}
31 + <div class="dense-list">
32 + {{range .BranchDetails}}
33 + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;">
34 + <div class="flex items-center gap-2 min-w-0">
35 + {{icon "git-branch" "size-4"}}
36 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.Name}}" class="font-mono font-semibold text-sm">{{.Name}}</a>
37 + {{if .IsDefault}}
38 + <span class="badge badge-primary">{{t $.Lang "repo.branch.default"}}</span>
39 + {{end}}
40 + {{if .Subject}}
41 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="text-xs text-muted truncate">— {{.Subject}}</a>
42 + {{end}}
43 + </div>
44 + <div class="flex items-center gap-2 flex-shrink-0">
45 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="badge badge-outline font-mono">{{.ShortHash}}</a>
46 + <span class="text-xs text-muted">{{timeAgo .UpdatedAt $.Lang}}</span>
47 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.Name}}.zip" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "repo.download_zip"}}">
48 + {{icon "download" "size-3.5"}}
49 + </a>
50 + {{if and $.CanWrite (not .IsDefault)}}
51 + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/branches/delete" style="margin:0;">
52 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
53 + <input type="hidden" name="branch_name" value="{{.Name}}">
54 + <button type="submit" class="btn btn-ghost btn-icon text-destructive" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "common.delete"}}">
55 + {{icon "trash" "size-3.5"}}
56 + </button>
57 + </form>
58 + {{end}}
59 + </div>
60 + </div>
61 + {{end}}
62 + </div>
63 + {{else}}
64 + <div class="card-body text-xs text-muted">{{t .Lang "repo.no_commits"}}</div>
65 + {{end}}
66 + </section>
67 +
68 + <!-- Tags / Releases Section -->
69 + <section class="card">
70 + <div class="card-header">
71 + <span class="font-semibold flex items-center gap-2">
72 + {{icon "tag" "size-4"}}
73 + <span>{{t .Lang "repo.tags"}} ({{len .TagDetails}})</span>
74 + </span>
75 + </div>
76 +
77 + {{if and .CanWrite (not .IsEmpty)}}
78 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tags" class="card-body flex items-center flex-wrap gap-2" style="border-bottom:1px solid hsl(var(--border));background:hsl(var(--muted)/0.25);">
79 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
80 + <input name="tag_name" type="text" class="input font-mono" style="flex:0 1 9rem;height:1.9rem;" placeholder="{{t .Lang "repo.tag.name_ph"}}" required maxlength="80">
81 + <select name="from_ref" class="select font-mono" style="flex:0 1 10rem;height:1.9rem;">
82 + {{range .BranchDetails}}
83 + <option value="{{.Name}}" {{if .IsDefault}}selected{{end}}>{{.Name}}</option>
84 + {{end}}
85 + </select>
86 + <input name="message" type="text" class="input" style="flex:1 1 12rem;height:1.9rem;" placeholder="{{t .Lang "repo.tag.msg_ph"}}" maxlength="200">
87 + <button type="submit" class="btn btn-outline btn-sm" style="height:1.9rem;">
88 + {{icon "plus" "size-3.5"}}
89 + <span>{{t .Lang "repo.tag.create"}}</span>
90 + </button>
91 + </form>
92 + {{end}}
93 +
94 + {{if .TagDetails}}
95 + <div class="dense-list">
96 + {{range .TagDetails}}
97 + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;">
98 + <div class="flex items-center gap-2 min-w-0">
99 + {{icon "tag" "size-4"}}
100 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.Name}}" class="font-mono font-semibold text-sm">{{.Name}}</a>
101 + {{if .Subject}}
102 + <span class="text-xs text-muted truncate">— {{.Subject}}</span>
103 + {{end}}
104 + </div>
105 + <div class="flex items-center gap-2 flex-shrink-0">
106 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="badge badge-outline font-mono">{{.ShortHash}}</a>
107 + <span class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</span>
108 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.Name}}.zip" class="btn btn-ghost btn-icon" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "repo.download_zip"}}">
109 + {{icon "download" "size-3.5"}}
110 + </a>
111 + {{if $.CanWrite}}
112 + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tags/delete" style="margin:0;">
113 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
114 + <input type="hidden" name="tag_name" value="{{.Name}}">
115 + <button type="submit" class="btn btn-ghost btn-icon text-destructive" style="width:1.65rem;height:1.65rem;" title="{{t $.Lang "common.delete"}}">
116 + {{icon "trash" "size-3.5"}}
117 + </button>
118 + </form>
119 + {{end}}
120 + </div>
121 + </div>
122 + {{end}}
123 + </div>
124 + {{else}}
125 + <div class="card-body text-xs text-muted">{{t .Lang "repo.tags.empty"}}</div>
126 + {{end}}
127 + </section>
128 +</div>
129 +{{end}}
internal/web/views/pages/commit.html
+101 -0
... ... @@ -0,0 +1,101 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="card mb-4">
6 + <div class="card-header flex items-center justify-between flex-wrap gap-2">
7 + <div class="flex items-center gap-2 min-w-0">
8 + {{icon "git-commit" "size-4"}}
9 + <h1 class="text-base font-semibold truncate" style="margin:0;">{{.Detail.Commit.Subject}}</h1>
10 + </div>
11 + <div class="flex items-center gap-1.5 font-mono text-xs">
12 + <span class="badge badge-outline">{{.Detail.Commit.ShortHash}}</span>
13 + <button type="button" class="btn btn-ghost btn-icon" style="width:1.7rem;height:1.7rem;" data-copy="{{.Detail.Commit.Hash}}" aria-label="{{t .Lang "common.copy"}}">
14 + {{icon "copy" "size-3.5"}}
15 + </button>
16 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Detail.Commit.Hash}}" class="btn btn-outline btn-sm">{{t .Lang "repo.browse_at_commit"}}</a>
17 + </div>
18 + </div>
19 +
20 + {{if .Detail.Commit.Body}}
21 + <div class="card-body" style="border-bottom:1px solid hsl(var(--border));background:hsl(var(--muted)/0.25);">
22 + <pre class="font-mono text-xs" style="margin:0;white-space:pre-wrap;">{{.Detail.Commit.Body}}</pre>
23 + </div>
24 + {{end}}
25 +
26 + <div class="card-body flex items-center justify-between flex-wrap gap-3 text-xs text-muted" style="padding:0.6rem 0.9rem;">
27 + <div class="flex items-center gap-2">
28 + <span class="avatar" style="width:1.35rem;height:1.35rem;font-size:0.62rem;">{{avatarInitial .Detail.Commit.AuthorName}}</span>
29 + <span class="font-medium" style="color:hsl(var(--foreground));">{{.Detail.Commit.AuthorName}}</span>
30 + <span>·</span>
31 + <span>{{formatDate .Detail.Commit.AuthoredAt}}</span>
32 + </div>
33 +
34 + <div class="flex items-center gap-3 font-mono">
35 + <span><strong>{{.Detail.FilesChanged}}</strong> {{pluralT .Lang .Detail.FilesChanged "repo.files_changed"}}</span>
36 + <span style="color:hsl(var(--diff-add-fg));">+{{.Detail.TotalAdditions}}</span>
37 + <span style="color:hsl(var(--diff-del-fg));">-{{.Detail.TotalDeletions}}</span>
38 + </div>
39 + </div>
40 + </div>
41 +
42 + <div class="flex flex-col gap-4">
43 + {{range .Detail.Files}}
44 + <section class="card">
45 + <div class="card-header" style="padding:0.5rem 0.8rem;">
46 + <div class="flex items-center gap-2 font-mono text-xs min-w-0">
47 + {{icon "file-code" "size-4"}}
48 + {{if eq .Status "renamed"}}
49 + <span>{{.OldPath}} → <strong>{{.NewPath}}</strong></span>
50 + {{else}}
51 + <strong>{{.NewPath}}</strong>
52 + {{end}}
53 + </div>
54 + <div class="flex items-center gap-2 font-mono text-xs">
55 + <span style="color:hsl(var(--diff-add-fg));">+{{.Additions}}</span>
56 + <span style="color:hsl(var(--diff-del-fg));">-{{.Deletions}}</span>
57 + </div>
58 + </div>
59 +
60 + {{if .IsBinary}}
61 + <div class="card-body text-xs text-muted">{{t $.Lang "repo.binary_notice"}}</div>
62 + {{else}}
63 + <div style="overflow-x:auto;">
64 + <table class="diff-table">
65 + <tbody>
66 + {{range .Lines}}
67 + {{if eq .Type "hunk"}}
68 + <tr class="diff-row-hunk">
69 + <td class="diff-num">...</td>
70 + <td class="diff-num">...</td>
71 + <td class="diff-code">{{.Content}}</td>
72 + </tr>
73 + {{else if eq .Type "add"}}
74 + <tr class="diff-row-add">
75 + <td class="diff-num"></td>
76 + <td class="diff-num">{{.NewNum}}</td>
77 + <td class="diff-code">+{{.Content}}</td>
78 + </tr>
79 + {{else if eq .Type "del"}}
80 + <tr class="diff-row-del">
81 + <td class="diff-num">{{.OldNum}}</td>
82 + <td class="diff-num"></td>
83 + <td class="diff-code">-{{.Content}}</td>
84 + </tr>
85 + {{else}}
86 + <tr>
87 + <td class="diff-num">{{.OldNum}}</td>
88 + <td class="diff-num">{{.NewNum}}</td>
89 + <td class="diff-code"> {{.Content}}</td>
90 + </tr>
91 + {{end}}
92 + {{end}}
93 + </tbody>
94 + </table>
95 + </div>
96 + {{end}}
97 + </section>
98 + {{end}}
99 + </div>
100 +</div>
101 +{{end}}
internal/web/views/pages/commits.html
+66 -0
... ... @@ -0,0 +1,66 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
6 + <div class="dropdown" data-dropdown>
7 + <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false">
8 + {{icon "git-branch" "size-3.5"}}
9 + <span>{{.Ref}}</span>
10 + {{icon "chevron-down" "size-3.5"}}
11 + </button>
12 + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;">
13 + <div class="palette-group-title">{{t .Lang "repo.branches"}}</div>
14 + {{range .Branches}}
15 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commits/{{.}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
16 + <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span>
17 + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
18 + </a>
19 + {{end}}
20 + </div>
21 + </div>
22 +
23 + <span class="text-xs text-muted">{{t .Lang "repo.history"}} · {{.Ref}}</span>
24 + </div>
25 +
26 + {{if .Commits}}
27 + <div class="card">
28 + {{range .Commits}}
29 + <div class="dense-row" style="min-height:2.6rem;padding:0.4rem 0.85rem;">
30 + <div class="flex items-center gap-2.5 min-w-0">
31 + {{icon "git-commit" "size-4"}}
32 + <div class="min-w-0">
33 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="font-medium text-sm truncate block">{{.Subject}}</a>
34 + <div class="text-xs text-muted flex items-center gap-1.5 mt-1">
35 + <span>{{.AuthorName}}</span>
36 + <span>·</span>
37 + <span>{{timeAgo .AuthoredAt $.Lang}}</span>
38 + </div>
39 + </div>
40 + </div>
41 +
42 + <div class="flex items-center gap-1.5">
43 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="btn btn-outline btn-sm font-mono">{{.ShortHash}}</a>
44 + <button type="button" class="btn btn-ghost btn-icon" style="width:1.7rem;height:1.7rem;" data-copy="{{.Hash}}" aria-label="{{t $.Lang "common.copy"}}">
45 + {{icon "copy" "size-3.5"}}
46 + </button>
47 + </div>
48 + </div>
49 + {{end}}
50 + </div>
51 +
52 + <div class="flex items-center justify-between mt-4">
53 + {{if gt .Page 1}}
54 + <a href="?page={{sub .Page 1}}" class="btn btn-outline btn-sm">{{icon "arrow-left" "size-3.5"}} {{t .Lang "common.newer"}}</a>
55 + {{else}}
56 + <span></span>
57 + {{end}}
58 + {{if .HasMore}}
59 + <a href="?page={{add .Page 1}}" class="btn btn-outline btn-sm">{{t .Lang "common.older"}} {{icon "arrow-right" "size-3.5"}}</a>
60 + {{end}}
61 + </div>
62 + {{else}}
63 + {{template "ui_empty_state" (dict "Icon" "git-commit" "Title" (t .Lang "repo.no_commits"))}}
64 + {{end}}
65 +</div>
66 +{{end}}
internal/web/views/pages/device.html
+75 -0
... ... @@ -0,0 +1,75 @@
1 +{{define "content"}}
2 +<div class="container-narrow">
3 + <div class="card mb-4" style="margin-top:2.5rem;">
4 + <div class="card-header">
5 + <span class="font-semibold flex items-center gap-2">{{icon "key" "size-4"}} {{t .Lang "device.title"}}</span>
6 + <span class="badge badge-outline font-mono">{{t .Lang "device.badge"}}</span>
7 + </div>
8 + <div class="card-body flex flex-col gap-3">
9 + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "device.desc"}}</p>
10 +
11 + {{if .CodeError}}
12 + <div class="text-sm flex items-center gap-2" style="color:hsl(var(--diff-del-fg));">
13 + {{icon "alert-circle" "size-4"}}
14 + <span>{{.CodeError}}</span>
15 + </div>
16 + {{end}}
17 +
18 + <form method="get" action="/login/device" class="flex items-center gap-2">
19 + <input name="user_code" class="input font-mono" style="letter-spacing:0.18em;text-transform:uppercase;max-width:12rem;" placeholder="XXXX-XXXX" value="{{.UserCode}}" maxlength="12" autocomplete="off" autofocus>
20 + <button class="btn btn-outline btn-sm" type="submit">
21 + {{icon "search" "size-3.5"}}
22 + <span>{{t .Lang "device.check"}}</span>
23 + </button>
24 + </form>
25 +
26 + {{if .CodeApproved}}
27 + <div class="flex items-center gap-2 text-sm" style="color:hsl(var(--diff-add-fg));border-top:1px solid hsl(var(--border));padding-top:0.85rem;">
28 + {{icon "check" "size-4"}}
29 + <span>{{t .Lang "device.approved_now"}}</span>
30 + </div>
31 + {{else if .CodeDenied}}
32 + <div class="flex items-center gap-2 text-sm" style="color:hsl(var(--diff-del-fg));border-top:1px solid hsl(var(--border));padding-top:0.85rem;">
33 + {{icon "close" "size-4"}}
34 + <span>{{t .Lang "device.denied_now"}}</span>
35 + </div>
36 + {{else if .SignedIn}}
37 + {{if and .UserCode .CodePending}}
38 + <form method="post" action="/login/device" class="flex flex-col gap-3" style="border-top:1px solid hsl(var(--border));padding-top:0.85rem;">
39 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
40 + <input type="hidden" name="user_code" value="{{.UserCode}}">
41 + <div class="text-sm">
42 + {{t .Lang "device.authorize_for"}}
43 + <span class="font-mono font-semibold">{{.UserCode}}</span>
44 + {{if .ClientName}}· <span class="font-medium">{{.ClientName}}</span>{{end}}
45 + </div>
46 + <div class="text-xs text-muted">{{t .Lang "device.scope_hint"}}</div>
47 + <div class="flex items-center gap-2">
48 + <button type="submit" name="action" value="approve" class="btn btn-primary btn-sm">
49 + {{icon "check" "size-3.5"}}
50 + <span>{{t .Lang "device.approve"}}</span>
51 + </button>
52 + <button type="submit" name="action" value="deny" class="btn btn-outline btn-sm">
53 + {{icon "close" "size-3.5"}}
54 + <span>{{t .Lang "device.deny"}}</span>
55 + </button>
56 + </div>
57 + </form>
58 + {{end}}
59 + {{else}}
60 + <div class="flex items-center gap-2 text-sm" style="border-top:1px solid hsl(var(--border));padding-top:0.85rem;">
61 + <a class="btn btn-primary btn-sm" href="/login?next=%2Flogin%2Fdevice%3Fuser_code%3D{{.UserCode}}">
62 + {{icon "user" "size-3.5"}}
63 + <span>{{t .Lang "device.signin_to_approve"}}</span>
64 + </a>
65 + </div>
66 + {{end}}
67 + </div>
68 + </div>
69 +
70 + <div class="text-xs text-muted text-center" style="margin-bottom:2.5rem;">
71 + {{t .Lang "device.helper_hint"}}
72 + <code class="font-mono">scripts/git-credential-polar</code>
73 + </div>
74 +</div>
75 +{{end}}
internal/web/views/pages/error.html
+13 -0
... ... @@ -0,0 +1,13 @@
1 +{{define "content"}}
2 +<div class="container" style="max-width:28rem;padding-top:3rem;">
3 + <div class="empty-state">
4 + <div class="font-mono text-2xl font-bold mb-1" style="color:hsl(var(--foreground));">{{.StatusCode}}</div>
5 + <div class="font-semibold text-base" style="color:hsl(var(--foreground));">{{.Title}}</div>
6 + <p class="text-sm text-muted mt-1">{{.Message}}</p>
7 + <div class="mt-4 flex items-center justify-center gap-2">
8 + <a href="/" class="btn btn-primary btn-sm">{{icon "home" "size-3.5"}} {{t .Lang "nav.home"}}</a>
9 + <a href="/repos" class="btn btn-outline btn-sm">{{icon "git-branch" "size-3.5"}} {{t .Lang "nav.repositories"}}</a>
10 + </div>
11 + </div>
12 +</div>
13 +{{end}}
internal/web/views/pages/forks.html
+46 -0
... ... @@ -0,0 +1,46 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="card mb-4">
6 + <div class="card-header">
7 + <span class="font-semibold flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "forks.title"}}</span>
8 + <span class="text-xs text-muted font-mono">{{.Repo.OwnerUsername}}/{{.Repo.Name}}</span>
9 + </div>
10 +
11 + {{if .Upstream}}
12 + <div class="card-body text-xs text-muted flex items-center gap-1.5" style="border-bottom:1px solid hsl(var(--border));">
13 + {{icon "git-branch" "size-3.5"}}
14 + {{t .Lang "fork.forked_from"}}
15 + <a class="font-medium" style="color:hsl(var(--foreground));" href="/{{.Upstream.Owner}}/{{.Upstream.Name}}">{{.Upstream.Owner}}/{{.Upstream.Name}}</a>
16 + </div>
17 + {{end}}
18 +
19 + <div class="dense-list">
20 + {{range .Forks}}
21 + <div class="dense-row" style="min-height:3rem;padding:0.55rem 0.85rem;">
22 + <div class="flex items-center gap-2.5 min-w-0">
23 + {{avatar .OwnerUsername .OwnerAvatarURL}}
24 + <div class="min-w-0">
25 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-semibold text-sm">{{.OwnerUsername}}/{{.Name}}</a>
26 + {{if .Description}}
27 + <div class="text-xs text-muted truncate">{{.Description}}</div>
28 + {{end}}
29 + </div>
30 + </div>
31 + <div class="flex items-center gap-3 text-xs text-muted">
32 + <span>{{timeAgo .UpdatedAt $.Lang}}</span>
33 + {{if $.CurrentUser}}
34 + {{if eq .OwnerID $.CurrentUser.ID}}
35 + <span class="badge badge-outline">{{t $.Lang "fork.your_fork"}}</span>
36 + {{end}}
37 + {{end}}
38 + </div>
39 + </div>
40 + {{else}}
41 + <div class="card-body text-sm text-muted">{{t .Lang "forks.empty"}}</div>
42 + {{end}}
43 + </div>
44 + </div>
45 +</div>
46 +{{end}}
internal/web/views/pages/issue_detail.html
+95 -0
... ... @@ -0,0 +1,95 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <!-- Issue Header -->
6 + <div class="mb-4 pb-3" style="border-bottom:1px solid hsl(var(--border));">
7 + <div class="flex items-start justify-between flex-wrap gap-3">
8 + <div class="flex items-center gap-2 flex-wrap">
9 + <h1 class="text-lg font-bold" style="margin:0;">{{.Issue.Title}}</h1>
10 + <span class="font-mono text-base text-muted">#{{.Issue.Number}}</span>
11 + </div>
12 + {{if .CanToggleIssue}}
13 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/state" style="margin:0;">
14 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
15 + {{if .Issue.IsClosed}}
16 + <input type="hidden" name="state" value="open">
17 + <button type="submit" class="btn btn-outline btn-sm">
18 + {{icon "alert-circle" "size-3.5"}}
19 + <span>{{t .Lang "issue.reopen_btn"}}</span>
20 + </button>
21 + {{else}}
22 + <input type="hidden" name="state" value="closed">
23 + <button type="submit" class="btn btn-outline btn-sm">
24 + {{icon "check" "size-3.5"}}
25 + <span>{{t .Lang "issue.close_btn"}}</span>
26 + </button>
27 + {{end}}
28 + </form>
29 + {{end}}
30 + </div>
31 +
32 + <div class="flex items-center gap-2 mt-2 text-xs text-muted">
33 + {{if .Issue.IsClosed}}
34 + <span class="badge badge-outline">{{icon "check" "size-3.5"}} {{t .Lang "issue.status.closed"}}</span>
35 + {{else}}
36 + <span class="badge badge-primary">{{icon "alert-circle" "size-3.5"}} {{t .Lang "issue.status.open"}}</span>
37 + {{end}}
38 + <a href="/{{.Issue.AuthorUsername}}" class="font-medium" style="color:hsl(var(--foreground));">@{{.Issue.AuthorUsername}}</a>
39 + <span>·</span>
40 + <span>{{timeAgo .Issue.CreatedAt .Lang}}</span>
41 + </div>
42 + </div>
43 +
44 + <!-- Issue Description Card -->
45 + <div class="card mb-4">
46 + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.3);">
47 + <div class="flex items-center gap-2">
48 + {{avatar .Issue.AuthorUsername .Issue.AuthorAvatar}}
49 + <a href="/{{.Issue.AuthorUsername}}" class="font-semibold">@{{.Issue.AuthorUsername}}</a>
50 + <span class="text-muted">{{timeAgo .Issue.CreatedAt .Lang}}</span>
51 + </div>
52 + </div>
53 + <div class="card-body markdown-body">
54 + {{if .Issue.Body}}
55 + {{renderMD .Issue.Body}}
56 + {{else}}
57 + <span class="text-xs text-muted">—</span>
58 + {{end}}
59 + </div>
60 + </div>
61 +
62 + <!-- Comments -->
63 + {{if .Comments}}
64 + <div class="flex flex-col gap-3 mb-4">
65 + {{range .Comments}}
66 + <div class="card">
67 + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.2);">
68 + <div class="flex items-center gap-2">
69 + {{avatar .AuthorUsername .AuthorAvatar}}
70 + <a href="/{{.AuthorUsername}}" class="font-semibold">@{{.AuthorUsername}}</a>
71 + <span class="text-muted">{{timeAgo .CreatedAt $.Lang}}</span>
72 + </div>
73 + </div>
74 + <div class="card-body markdown-body">
75 + {{renderMD .Body}}
76 + </div>
77 + </div>
78 + {{end}}
79 + </div>
80 + {{end}}
81 +
82 + <!-- Add Comment Form -->
83 + {{if .CurrentUser}}
84 + <div class="card">
85 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comments" class="card-body flex flex-col gap-2.5">
86 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
87 + <textarea name="body" class="textarea" rows="4" placeholder="{{t .Lang "issue.comment_ph"}}" required maxlength="20000"></textarea>
88 + <div class="flex items-center justify-end">
89 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "issue.comment_btn"}}</button>
90 + </div>
91 + </form>
92 + </div>
93 + {{end}}
94 +</div>
95 +{{end}}
internal/web/views/pages/issue_new.html
+26 -0
... ... @@ -0,0 +1,26 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="card">
6 + <div class="card-header">
7 + <span class="font-semibold flex items-center gap-2">{{icon "plus" "size-4"}} {{t .Lang "issue.new"}}</span>
8 + </div>
9 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new" class="card-body flex flex-col gap-3">
10 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
11 + <div>
12 + <label class="label" for="title">{{t .Lang "issue.title"}}</label>
13 + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "issue.title_ph"}}" required autofocus maxlength="200">
14 + </div>
15 + <div>
16 + <label class="label" for="body">{{t .Lang "issue.body"}}</label>
17 + <textarea id="body" name="body" class="textarea" rows="6" placeholder="{{t .Lang "issue.body_ph"}}" maxlength="20000"></textarea>
18 + </div>
19 + <div class="flex items-center justify-end gap-2">
20 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues" class="btn btn-ghost btn-sm">{{t .Lang "common.cancel"}}</a>
21 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "issue.create"}}</button>
22 + </div>
23 + </form>
24 + </div>
25 +</div>
26 +{{end}}
internal/web/views/pages/issues.html
+68 -0
... ... @@ -0,0 +1,68 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
6 + <div class="flex items-center gap-1.5">
7 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues?state=open" class="btn {{if not .ShowClosed}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
8 + {{icon "alert-circle" "size-3.5"}}
9 + <span>{{t .Lang "issue.open"}} ({{.OpenCount}})</span>
10 + </a>
11 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues?state=closed" class="btn {{if .ShowClosed}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
12 + {{icon "check" "size-3.5"}}
13 + <span>{{t .Lang "issue.closed"}} ({{.ClosedCount}})</span>
14 + </a>
15 + </div>
16 +
17 + {{if .CurrentUser}}
18 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new" class="btn btn-primary btn-sm">
19 + {{icon "plus" "size-3.5"}}
20 + <span>{{t .Lang "issue.new"}}</span>
21 + </a>
22 + {{end}}
23 + </div>
24 +
25 + {{if .Issues}}
26 + <div class="card">
27 + <div class="dense-list">
28 + {{range .Issues}}
29 + <div class="dense-row" style="min-height:2.75rem;padding:0.5rem 0.85rem;">
30 + <div class="flex items-center gap-2.5 min-w-0">
31 + {{if .IsClosed}}
32 + <span class="text-muted">{{icon "check" "size-4"}}</span>
33 + {{else}}
34 + <span style="color:hsl(var(--primary));">{{icon "alert-circle" "size-4"}}</span>
35 + {{end}}
36 + <div class="flex flex-col min-w-0">
37 + <div class="flex items-center gap-2 min-w-0">
38 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/issues/{{.Number}}" class="font-semibold text-sm truncate">{{.Title}}</a>
39 + <span class="font-mono text-xs text-muted">#{{.Number}}</span>
40 + </div>
41 + <div class="text-xs text-muted flex items-center gap-1.5">
42 + <span>@{{.AuthorUsername}}</span>
43 + <span>·</span>
44 + <span>{{timeAgo .CreatedAt $.Lang}}</span>
45 + </div>
46 + </div>
47 + </div>
48 + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0">
49 + {{if gt .CommentCount 0}}
50 + <span class="flex items-center gap-1 font-mono">
51 + {{icon "message" "size-3.5"}}
52 + <span>{{.CommentCount}}</span>
53 + </span>
54 + {{end}}
55 + </div>
56 + </div>
57 + {{end}}
58 + </div>
59 + </div>
60 + {{else}}
61 + {{if .CurrentUser}}
62 + {{template "ui_empty_state" (dict "Icon" "alert-circle" "Title" (t .Lang "issue.empty") "ActionHref" (printf "/%s/%s/issues/new" .Repo.OwnerUsername .Repo.Name) "ActionLabel" (t .Lang "issue.new") "ActionIcon" "plus")}}
63 + {{else}}
64 + {{template "ui_empty_state" (dict "Icon" "alert-circle" "Title" (t .Lang "issue.empty"))}}
65 + {{end}}
66 + {{end}}
67 +</div>
68 +{{end}}
internal/web/views/pages/landing.html
+98 -0
... ... @@ -0,0 +1,98 @@
1 +{{define "content"}}
2 +<div class="container">
3 + <!-- Minimal Hero -->
4 + <section class="mb-6">
5 + <div class="mb-2">
6 + <span class="badge">{{icon "git-branch" "size-3.5"}} {{t .Lang "landing.badge"}}</span>
7 + </div>
8 + <h1 class="text-2xl font-bold" style="max-width:38rem;line-height:1.2;margin:0 0 0.6rem 0;">
9 + {{t .Lang "landing.hero.title"}}
10 + </h1>
11 + <p class="text-sm text-muted" style="max-width:36rem;margin:0 0 1.25rem 0;">
12 + {{t .Lang "landing.hero.subtitle"}}
13 + </p>
14 +
15 + <div class="flex items-center flex-wrap gap-2">
16 + {{if .CurrentUser}}
17 + <a href="/new" class="btn btn-primary">
18 + {{icon "plus" "size-4"}}
19 + <span>{{t .Lang "repo.new.title"}}</span>
20 + </a>
21 + <a href="/repos" class="btn btn-outline">
22 + {{icon "git-branch" "size-4"}}
23 + <span>{{t .Lang "nav.repositories"}}</span>
24 + </a>
25 + {{else}}
26 + {{if .RegistrationEnabled}}
27 + <a href="/register" class="btn btn-primary">
28 + <span>{{t .Lang "landing.hero.cta_start"}}</span>
29 + {{icon "arrow-right" "size-4"}}
30 + </a>
31 + {{end}}
32 + <a href="/login" class="btn btn-outline">
33 + <span>{{t .Lang "nav.login"}}</span>
34 + </a>
35 + {{end}}
36 + </div>
37 + </section>
38 +
39 + <!-- 4 Core Principles -->
40 + <section class="mb-6">
41 + <div class="grid-2">
42 + <div class="card p-3">
43 + <div class="flex items-center gap-2 font-semibold text-sm mb-1">
44 + {{icon "server" "size-4"}}
45 + <span>{{t .Lang "landing.feat1.title"}}</span>
46 + </div>
47 + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat1.desc"}}</p>
48 + </div>
49 +
50 + <div class="card p-3">
51 + <div class="flex items-center gap-2 font-semibold text-sm mb-1">
52 + {{icon "database" "size-4"}}
53 + <span>{{t .Lang "landing.feat2.title"}}</span>
54 + </div>
55 + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat2.desc"}}</p>
56 + </div>
57 +
58 + <div class="card p-3">
59 + <div class="flex items-center gap-2 font-semibold text-sm mb-1">
60 + {{icon "zap" "size-4"}}
61 + <span>{{t .Lang "landing.feat3.title"}}</span>
62 + </div>
63 + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat3.desc"}}</p>
64 + </div>
65 +
66 + <div class="card p-3">
67 + <div class="flex items-center gap-2 font-semibold text-sm mb-1">
68 + {{icon "lock" "size-4"}}
69 + <span>{{t .Lang "landing.feat4.title"}}</span>
70 + </div>
71 + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "landing.feat4.desc"}}</p>
72 + </div>
73 + </div>
74 + </section>
75 +
76 + {{if and .ExploreEnabled .Repos}}
77 + <section>
78 + <div class="flex items-center justify-between mb-2">
79 + <h2 class="text-base font-semibold" style="margin:0;">{{t .Lang "nav.explore"}}</h2>
80 + </div>
81 + <div class="card">
82 + {{range .Repos}}
83 + <div class="dense-row">
84 + <div class="flex items-center gap-2 min-w-0">
85 + {{icon "git-branch" "size-4"}}
86 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-medium">{{.OwnerUsername}}/<strong>{{.Name}}</strong></a>
87 + {{if .Description}}
88 + <span class="text-xs text-muted truncate">— {{.Description}}</span>
89 + {{end}}
90 + </div>
91 + <span class="text-xs text-muted">{{timeAgo .UpdatedAt $.Lang}}</span>
92 + </div>
93 + {{end}}
94 + </div>
95 + </section>
96 + {{end}}
97 +</div>
98 +{{end}}
internal/web/views/pages/login.html
+30 -0
... ... @@ -0,0 +1,30 @@
1 +{{define "content"}}
2 +<div class="container" style="max-width:23rem;padding-top:2rem;">
3 + <div class="card">
4 + <div class="card-header">
5 + <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "auth.login.title"}}</h1>
6 + </div>
7 + <form method="post" action="/login" class="card-body flex flex-col gap-3">
8 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
9 + {{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
10 + <div>
11 + <label class="label" for="login">{{t .Lang "auth.username_or_email"}}</label>
12 + <input id="login" name="login" type="text" class="input" value="{{.FormLogin}}" required autofocus autocomplete="username">
13 + </div>
14 + <div>
15 + <label class="label" for="password">{{t .Lang "auth.password"}}</label>
16 + <input id="password" name="password" type="password" class="input" required autocomplete="current-password">
17 + </div>
18 + <button type="submit" class="btn btn-primary w-full mt-1">
19 + <span>{{t .Lang "auth.submit_login"}}</span>
20 + </button>
21 + </form>
22 + {{if .RegistrationEnabled}}
23 + <div class="card-footer text-xs text-muted flex items-center justify-between">
24 + <span>{{t .Lang "auth.no_account"}}</span>
25 + <a href="/register" class="font-medium" style="color:hsl(var(--foreground));">{{t .Lang "nav.register"}} →</a>
26 + </div>
27 + {{end}}
28 + </div>
29 +</div>
30 +{{end}}
internal/web/views/pages/profile.html
+138 -0
... ... @@ -0,0 +1,138 @@
1 +{{define "content"}}
2 +<div class="container">
3 + <!-- Profile Card -->
4 + <div class="card p-4 mb-4">
5 + <div class="flex items-start justify-between flex-wrap gap-4">
6 + <div class="flex items-start gap-4 min-w-0">
7 + {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xl"}}
8 + <div class="flex flex-col gap-1 min-w-0">
9 + <div class="flex items-center gap-2 flex-wrap">
10 + {{if .ProfileUser.FullName}}
11 + <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{.ProfileUser.FullName}}</h1>
12 + <span class="font-mono text-sm text-muted">@{{.ProfileUser.Username}}</span>
13 + {{else}}
14 + <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{.ProfileUser.Username}}</h1>
15 + {{end}}
16 + {{if .ProfileUser.IsAdmin}}
17 + <span class="badge badge-primary">{{t .Lang "admin.role.admin"}}</span>
18 + {{end}}
19 + </div>
20 +
21 + {{if .ProfileUser.Bio}}
22 + <p class="text-sm" style="margin:0.15rem 0 0.25rem;max-width:38rem;">{{.ProfileUser.Bio}}</p>
23 + {{end}}
24 +
25 + <div class="flex items-center flex-wrap gap-3 text-xs text-muted mt-1">
26 + {{if .ProfileUser.Location}}
27 + <span class="flex items-center gap-1">
28 + {{icon "map-pin" "size-3.5"}}
29 + <span>{{.ProfileUser.Location}}</span>
30 + </span>
31 + {{end}}
32 + {{if .ProfileUser.Website}}
33 + <a href="{{.ProfileUser.Website}}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1" style="color:hsl(var(--foreground));text-decoration:underline;text-underline-offset:3px;">
34 + {{icon "link" "size-3.5"}}
35 + <span>{{.ProfileUser.Website}}</span>
36 + </a>
37 + {{end}}
38 + <span class="flex items-center gap-1">
39 + {{icon "calendar" "size-3.5"}}
40 + <span>{{t .Lang "profile.joined"}}: {{formatShortDate .ProfileUser.CreatedAt}}</span>
41 + </span>
42 + </div>
43 + </div>
44 + </div>
45 +
46 + <div class="flex items-center gap-2">
47 + {{if .CurrentUser}}
48 + {{if eq .CurrentUser.ID .ProfileUser.ID}}
49 + <a href="/settings" class="btn btn-outline btn-sm">
50 + {{icon "edit" "size-3.5"}}
51 + <span>{{t .Lang "profile.edit"}}</span>
52 + </a>
53 + {{end}}
54 + {{end}}
55 + </div>
56 + </div>
57 +
58 + <!-- Stats Row -->
59 + <div class="flex items-center gap-4 flex-wrap mt-4 pt-3" style="border-top:1px solid hsl(var(--border));">
60 + <div class="flex items-center gap-1.5 text-xs">
61 + {{icon "git-branch" "size-3.5"}}
62 + <span class="text-muted">{{t .Lang "profile.total_repos"}}:</span>
63 + <strong class="font-mono">{{len .Repos}}</strong>
64 + </div>
65 + <div class="flex items-center gap-1.5 text-xs">
66 + {{icon "globe" "size-3.5"}}
67 + <span class="text-muted">{{t .Lang "profile.public_repos"}}:</span>
68 + <strong class="font-mono">{{.PublicRepoCount}}</strong>
69 + </div>
70 + {{if .ShowPrivateCount}}
71 + <div class="flex items-center gap-1.5 text-xs">
72 + {{icon "lock" "size-3.5"}}
73 + <span class="text-muted">{{t .Lang "profile.private_repos"}}:</span>
74 + <strong class="font-mono">{{.PrivateRepoCount}}</strong>
75 + </div>
76 + {{end}}
77 + </div>
78 + </div>
79 +
80 + <!-- Repositories Section -->
81 + <div class="flex items-center justify-between flex-wrap gap-3 mb-3">
82 + <h2 class="text-base font-semibold" style="margin:0;">{{t .Lang "nav.repositories"}} ({{len .Repos}})</h2>
83 + <div class="flex items-center gap-2">
84 + <div class="flex items-center" style="position:relative;">
85 + <input type="text" class="input" style="height:1.85rem;width:14rem;padding-right:1.8rem;" placeholder="{{t .Lang "repo.filter_ph"}}" data-search-input data-file-filter>
86 + <kbd class="kbd" style="position:absolute;right:0.4rem;pointer-events:none;">/</kbd>
87 + </div>
88 + {{if .CurrentUser}}
89 + {{if eq .CurrentUser.ID .ProfileUser.ID}}
90 + <a href="/new" class="btn btn-primary btn-sm" style="height:1.85rem;">
91 + {{icon "plus" "size-3.5"}}
92 + <span>{{t .Lang "nav.new_repo"}}</span>
93 + </a>
94 + {{end}}
95 + {{end}}
96 + </div>
97 + </div>
98 +
99 + {{if .Repos}}
100 + <div class="grid-2">
101 + {{range .Repos}}
102 + <div class="card p-3 flex flex-col justify-between gap-2" data-tree-row data-tree-name="{{.Name}} {{.Description}}">
103 + <div>
104 + <div class="flex items-center justify-between gap-2 mb-1">
105 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-semibold text-sm flex items-center gap-1.5 truncate">
106 + {{if .IsPrivate}}
107 + {{icon "lock" "size-3.5"}}
108 + {{else}}
109 + {{icon "git-branch" "size-3.5"}}
110 + {{end}}
111 + <span class="truncate">{{.Name}}</span>
112 + </a>
113 + {{if .IsPrivate}}
114 + <span class="badge badge-outline">{{t $.Lang "repo.private"}}</span>
115 + {{else}}
116 + <span class="badge badge-outline">{{t $.Lang "repo.public"}}</span>
117 + {{end}}
118 + </div>
119 + {{if .Description}}
120 + <p class="text-xs text-muted" style="margin:0.2rem 0 0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{{.Description}}</p>
121 + {{end}}
122 + </div>
123 + <div class="flex items-center justify-between text-xs text-muted pt-2" style="border-top:1px solid hsl(var(--border)/0.6);">
124 + <span class="font-mono flex items-center gap-1">{{icon "git-branch" "size-3.5"}} {{.DefaultBranch}}</span>
125 + <span>{{timeAgo .UpdatedAt $.Lang}}</span>
126 + </div>
127 + </div>
128 + {{end}}
129 + </div>
130 + {{else}}
131 + {{if and .CurrentUser (eq .CurrentUser.ID .ProfileUser.ID)}}
132 + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list") "Subtitle" (t .Lang "repo.empty_list_sub") "ActionHref" "/new" "ActionLabel" (t .Lang "repo.new.title") "ActionIcon" "plus")}}
133 + {{else}}
134 + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list"))}}
135 + {{end}}
136 + {{end}}
137 +</div>
138 +{{end}}
internal/web/views/pages/pull_detail.html
+343 -0
... ... @@ -0,0 +1,343 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <!-- PR Header -->
6 + <div class="mb-4 pb-3" style="border-bottom:1px solid hsl(var(--border));">
7 + <div class="flex items-start justify-between flex-wrap gap-3">
8 + <div class="flex items-center gap-2 flex-wrap min-w-0">
9 + <h1 class="text-lg font-bold" style="margin:0;">{{.PR.Title}}</h1>
10 + <span class="font-mono text-base text-muted">#{{.PR.Number}}</span>
11 + </div>
12 +
13 + {{if .CanToggle}}
14 + {{if ne .PR.Status "merged"}}
15 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/state" style="margin:0;">
16 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
17 + {{if eq .PR.Status "closed"}}
18 + <input type="hidden" name="state" value="open">
19 + <button type="submit" class="btn btn-outline btn-sm">
20 + {{icon "git-pull-request" "size-3.5"}}
21 + <span>{{t .Lang "pulls.reopen_btn"}}</span>
22 + </button>
23 + {{else}}
24 + <input type="hidden" name="state" value="closed">
25 + <button type="submit" class="btn btn-outline btn-sm">
26 + {{icon "close" "size-3.5"}}
27 + <span>{{t .Lang "pulls.close_btn"}}</span>
28 + </button>
29 + {{end}}
30 + </form>
31 + {{end}}
32 + {{end}}
33 + </div>
34 +
35 + <div class="flex items-center gap-2 mt-2 text-xs text-muted flex-wrap">
36 + {{if eq .PR.Status "merged"}}
37 + <span class="badge badge-primary font-medium flex items-center gap-1.5" style="background:hsl(var(--accent));color:#fff;">
38 + {{icon "git-merge" "size-3.5"}} {{t .Lang "pulls.status.merged"}}
39 + </span>
40 + {{else if eq .PR.Status "closed"}}
41 + <span class="badge badge-outline flex items-center gap-1.5">
42 + {{icon "close" "size-3.5"}} {{t .Lang "pulls.status.closed"}}
43 + </span>
44 + {{else}}
45 + <span class="badge badge-primary flex items-center gap-1.5">
46 + {{icon "git-pull-request" "size-3.5"}} {{t .Lang "pulls.status.open"}}
47 + </span>
48 + {{end}}
49 +
50 + <a href="/{{.PR.AuthorUsername}}" class="font-medium" style="color:hsl(var(--foreground));">@{{.PR.AuthorUsername}}</a>
51 + <span>{{t .Lang "pulls.wants_to_merge"}}</span>
52 + <span class="badge badge-outline font-mono text-xs">{{if .PR.HeadRepoID}}{{.PR.HeadRepoOwner}}/{{.PR.HeadRepoName}}:{{end}}{{.PR.HeadBranch}}</span>
53 + <span>{{t .Lang "pulls.into"}}</span>
54 + <span class="badge badge-outline font-mono text-xs">{{.PR.BaseBranch}}</span>
55 + <span>·</span>
56 + <span>{{timeAgo .PR.CreatedAt .Lang}}</span>
57 + </div>
58 +
59 + <!-- Sub-tabs: Conversation, Commits, Files Changed -->
60 + <div class="flex items-center gap-2 mt-3 pt-2" style="border-top:1px solid hsl(var(--border));">
61 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}?tab=conversation" class="btn {{if eq .SubTab "conversation"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
62 + {{icon "message" "size-3.5"}}
63 + <span>{{t .Lang "pulls.conversation"}}</span>
64 + </a>
65 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}?tab=commits" class="btn {{if eq .SubTab "commits"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
66 + {{icon "git-commit" "size-3.5"}}
67 + <span>{{t .Lang "pulls.commits"}} ({{len .CompareCommits}})</span>
68 + </a>
69 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}?tab=files" class="btn {{if eq .SubTab "files"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
70 + {{icon "file-code" "size-3.5"}}
71 + <span>{{t .Lang "pulls.files_changed"}} {{if .Compare}}({{.Compare.FilesChanged}}){{end}}</span>
72 + </a>
73 + </div>
74 + </div>
75 +
76 + {{if eq .SubTab "commits"}}
77 + <!-- Commits list -->
78 + <div class="card mb-4">
79 + <div class="dense-list">
80 + {{range .CompareCommits}}
81 + <div class="dense-row" style="min-height:2.8rem;padding:0.5rem 0.85rem;">
82 + <div class="flex items-center gap-2 min-w-0">
83 + <span class="text-muted">{{icon "git-commit" "size-3.5"}}</span>
84 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.Hash}}" class="font-semibold text-sm truncate">{{.Subject}}</a>
85 + </div>
86 + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0 font-mono">
87 + <span>{{.AuthorName}}</span>
88 + <span class="badge badge-outline">{{.ShortHash}}</span>
89 + </div>
90 + </div>
91 + {{else}}
92 + <div class="card-body text-xs text-muted">{{t .Lang "pulls.no_commits"}}</div>
93 + {{end}}
94 + </div>
95 + </div>
96 +
97 + {{else if eq .SubTab "files"}}
98 + <!-- Files changed diff viewer -->
99 + {{if .Compare}}
100 + <div class="flex items-center justify-between mb-3 text-xs text-muted font-mono">
101 + <span><strong>{{.Compare.FilesChanged}}</strong> {{pluralT .Lang .Compare.FilesChanged "repo.files_changed"}}</span>
102 + <div class="flex items-center gap-2">
103 + <span style="color:hsl(var(--diff-add-fg));">+{{.Compare.TotalAdditions}}</span>
104 + <span style="color:hsl(var(--diff-del-fg));">-{{.Compare.TotalDeletions}}</span>
105 + </div>
106 + </div>
107 +
108 + <div class="flex flex-col gap-4">
109 + {{range .Compare.Files}}
110 + <section class="card">
111 + <div class="card-header" style="padding:0.5rem 0.8rem;">
112 + <div class="flex items-center gap-2 font-mono text-xs min-w-0">
113 + {{icon "file-code" "size-4"}}
114 + {{if eq .Status "renamed"}}
115 + <span>{{.OldPath}} → <strong>{{.NewPath}}</strong></span>
116 + {{else}}
117 + <strong>{{.NewPath}}</strong>
118 + {{end}}
119 + </div>
120 + <div class="flex items-center gap-2 font-mono text-xs">
121 + <span style="color:hsl(var(--diff-add-fg));">+{{.Additions}}</span>
122 + <span style="color:hsl(var(--diff-del-fg));">-{{.Deletions}}</span>
123 + </div>
124 + </div>
125 +
126 + {{if .IsBinary}}
127 + <div class="card-body text-xs text-muted">{{t $.Lang "repo.binary_notice"}}</div>
128 + {{else}}
129 + <div style="overflow-x:auto;">
130 + <table class="diff-table">
131 + <tbody>
132 + {{range .Lines}}
133 + {{if eq .Type "hunk"}}
134 + <tr class="diff-row-hunk">
135 + <td class="diff-num">...</td>
136 + <td class="diff-num">...</td>
137 + <td class="diff-code">{{.Content}}</td>
138 + </tr>
139 + {{else if eq .Type "add"}}
140 + <tr class="diff-row-add">
141 + <td class="diff-num"></td>
142 + <td class="diff-num">{{.NewNum}}</td>
143 + <td class="diff-code">+{{.Content}}</td>
144 + </tr>
145 + {{else if eq .Type "del"}}
146 + <tr class="diff-row-del">
147 + <td class="diff-num">{{.OldNum}}</td>
148 + <td class="diff-num"></td>
149 + <td class="diff-code">-{{.Content}}</td>
150 + </tr>
151 + {{else}}
152 + <tr>
153 + <td class="diff-num">{{.OldNum}}</td>
154 + <td class="diff-num">{{.NewNum}}</td>
155 + <td class="diff-code"> {{.Content}}</td>
156 + </tr>
157 + {{end}}
158 + {{end}}
159 + </tbody>
160 + </table>
161 + </div>
162 + {{end}}
163 + </section>
164 + {{else}}
165 + <div class="card p-4 text-xs text-muted">{{t .Lang "pulls.no_changes"}}</div>
166 + {{end}}
167 + </div>
168 + {{end}}
169 +
170 + {{else}}
171 + <!-- Conversation Tab -->
172 +
173 + <!-- Initial PR Description Card -->
174 + <div class="card mb-4">
175 + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.3);">
176 + <div class="flex items-center gap-2">
177 + {{avatar .PR.AuthorUsername .PR.AuthorAvatar}}
178 + <a href="/{{.PR.AuthorUsername}}" class="font-semibold">@{{.PR.AuthorUsername}}</a>
179 + <span class="text-muted">{{timeAgo .PR.CreatedAt .Lang}}</span>
180 + </div>
181 + </div>
182 + <div class="card-body markdown-body">
183 + {{if .PR.Body}}
184 + {{renderMD .PR.Body}}
185 + {{else}}
186 + <span class="text-xs text-muted">—</span>
187 + {{end}}
188 + </div>
189 + </div>
190 +
191 + <!-- Comments List -->
192 + {{if .Comments}}
193 + <div class="flex flex-col gap-3 mb-4">
194 + {{range .Comments}}
195 + <div class="card">
196 + <div class="card-header text-xs" style="padding:0.5rem 0.85rem;background:hsl(var(--muted)/0.2);">
197 + <div class="flex items-center gap-2">
198 + {{avatar .AuthorUsername .AuthorAvatar}}
199 + <a href="/{{.AuthorUsername}}" class="font-semibold">@{{.AuthorUsername}}</a>
200 + <span class="text-muted">{{timeAgo .CreatedAt $.Lang}}</span>
201 + </div>
202 + </div>
203 + <div class="card-body markdown-body">
204 + {{renderMD .Body}}
205 + </div>
206 + </div>
207 + {{end}}
208 + </div>
209 + {{end}}
210 +
211 + <!-- Merge Box / State Banner -->
212 + <div class="card mb-4">
213 + {{if eq .PR.Status "merged"}}
214 + <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.2);">
215 + <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-6"}}</span>
216 + <div class="flex flex-col gap-0.5">
217 + <div class="font-semibold text-sm">{{t .Lang "pulls.merged_message"}}</div>
218 + <div class="text-xs text-muted">
219 + {{if .PR.MergedByUsername}}
220 + <span>@{{.PR.MergedByUsername}}</span>
221 + <span>·</span>
222 + {{end}}
223 + {{if .PR.MergedAt}}
224 + <span>{{formatDate .PR.MergedAt}}</span>
225 + {{end}}
226 + </div>
227 + </div>
228 + </div>
229 +
230 + {{else if eq .PR.Status "closed"}}
231 + <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.2);">
232 + <span class="text-muted">{{icon "close" "size-6"}}</span>
233 + <div class="flex flex-col gap-0.5">
234 + <div class="font-semibold text-sm">{{t .Lang "pulls.closed_message"}}</div>
235 + <div class="text-xs text-muted">{{t .Lang "pulls.closed_desc"}}</div>
236 + </div>
237 + </div>
238 +
239 + {{else}}
240 + <!-- PR is open -->
241 + {{if .CanMergeAction}}
242 + <div class="card-body flex flex-col gap-3" style="background:hsl(var(--muted)/0.15);">
243 + <div class="flex items-center gap-2 text-sm font-semibold" style="color:hsl(var(--diff-add-fg));">
244 + {{icon "check" "size-5"}}
245 + <span>{{t .Lang "pulls.able_to_merge"}}</span>
246 + </div>
247 + <div class="text-xs text-muted">{{t .Lang "pulls.merge_clean"}}</div>
248 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/merge" class="flex items-center gap-2">
249 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
250 + <button type="submit" class="btn btn-primary btn-sm">
251 + {{icon "git-merge" "size-3.5"}}
252 + <span>{{t .Lang "pulls.merge_btn"}}</span>
253 + </button>
254 + </form>
255 + </div>
256 + {{else if not .CanMerge}}
257 + <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.15);">
258 + <span style="color:hsl(var(--diff-del-fg));">{{icon "alert-circle" "size-5"}}</span>
259 + <div class="flex flex-col gap-0.5">
260 + <div class="font-semibold text-sm" style="color:hsl(var(--diff-del-fg));">{{t .Lang "pulls.conflicts_title"}}</div>
261 + <div class="text-xs text-muted">{{t .Lang "pulls.conflicts_desc"}}</div>
262 + </div>
263 + </div>
264 + {{else}}
265 + <div class="card-body flex items-center gap-2 text-xs text-muted" style="background:hsl(var(--muted)/0.15);">
266 + {{icon "check" "size-4"}}
267 + <span>{{t .Lang "pulls.only_write_merge"}}</span>
268 + </div>
269 + {{end}}
270 + {{end}}
271 + </div>
272 +
273 + <!-- Reviews Panel -->
274 + {{if or .Reviews .CanReview}}
275 + <div class="card mb-4">
276 + <div class="card-header" style="padding:0.55rem 0.85rem;">
277 + <span class="font-semibold text-xs flex items-center gap-2">
278 + {{icon "shield" "size-4"}}
279 + <span>{{t .Lang "reviews.title"}}</span>
280 + {{if gt .ApprovedCount 0}}<span class="badge" style="padding:0 0.35rem;font-size:0.68rem;color:hsl(var(--diff-add-fg));">{{.ApprovedCount}} ✓</span>{{end}}
281 + {{if gt .ChangesRequestedCount 0}}<span class="badge" style="padding:0 0.35rem;font-size:0.68rem;color:hsl(var(--diff-del-fg));">{{.ChangesRequestedCount}} ✗</span>{{end}}
282 + </span>
283 + </div>
284 + {{if .Reviews}}
285 + <div class="dense-list">
286 + {{range .Reviews}}
287 + <div class="dense-row" style="min-height:2.5rem;padding:0.45rem 0.85rem;">
288 + <div class="flex items-center gap-2 min-w-0">
289 + {{avatar .ReviewerUsername .ReviewerAvatar}}
290 + <a href="/{{.ReviewerUsername}}" class="font-semibold text-sm">@{{.ReviewerUsername}}</a>
291 + {{if eq .State "approved"}}
292 + <span class="flex items-center gap-1 text-xs font-medium" style="color:hsl(var(--diff-add-fg));">{{icon "check" "size-3.5"}} {{t $.Lang "reviews.approved"}}</span>
293 + {{else}}
294 + <span class="flex items-center gap-1 text-xs font-medium" style="color:hsl(var(--diff-del-fg));">{{icon "alert-circle" "size-3.5"}} {{t $.Lang "reviews.changes_requested"}}</span>
295 + {{end}}
296 + </div>
297 + <div class="flex items-center gap-2 text-xs text-muted">
298 + {{if .Body}}<span class="truncate" style="max-width:22rem;">{{.Body}}</span>{{end}}
299 + <span>{{timeAgo .UpdatedAt $.Lang}}</span>
300 + </div>
301 + </div>
302 + {{end}}
303 + </div>
304 + {{end}}
305 + {{if .CanReview}}
306 + <div class="card-body" style="border-top:1px solid hsl(var(--border));">
307 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/review" class="flex flex-col gap-2.5">
308 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
309 + <textarea name="body" class="textarea" rows="2" placeholder="{{t .Lang "reviews.body_ph"}}" maxlength="10000"></textarea>
310 + <div class="flex items-center gap-2 justify-end">
311 + <button type="submit" name="state" value="changes_requested" class="btn btn-outline btn-sm">
312 + {{icon "alert-circle" "size-3.5"}}
313 + <span>{{t .Lang "reviews.request_changes"}}</span>
314 + </button>
315 + <button type="submit" name="state" value="approved" class="btn btn-primary btn-sm">
316 + {{icon "check" "size-3.5"}}
317 + <span>{{t .Lang "reviews.approve"}}</span>
318 + </button>
319 + </div>
320 + </form>
321 + </div>
322 + {{end}}
323 + </div>
324 + {{end}}
325 +
326 + <!-- Add Comment Form --> {{if .CurrentUser}}
327 + <div class="card">
328 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/{{.PR.Number}}/comments" class="card-body flex flex-col gap-2.5">
329 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
330 + <textarea name="body" class="textarea" rows="4" placeholder="{{t .Lang "pulls.comment_ph"}}" required maxlength="20000"></textarea>
331 + <div class="flex items-center justify-end">
332 + <button type="submit" class="btn btn-primary btn-sm">
333 + {{icon "message" "size-3.5"}}
334 + <span>{{t .Lang "pulls.comment_btn"}}</span>
335 + </button>
336 + </div>
337 + </form>
338 + </div>
339 + {{end}}
340 +
341 + {{end}}
342 +</div>
343 +{{end}}
internal/web/views/pages/pull_new.html
+187 -0
... ... @@ -0,0 +1,187 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="card mb-4">
6 + <div class="card-header flex items-center justify-between flex-wrap gap-3">
7 + <div class="flex items-center gap-2">
8 + {{icon "git-pull-request" "size-4"}}
9 + <span class="font-semibold text-sm">{{t .Lang "pulls.compare_branches"}}</span>
10 + </div>
11 + <div class="flex items-center gap-2 flex-wrap text-xs">
12 + {{if gt (len .HeadRepos) 1}}
13 + <label class="text-muted font-medium">{{t .Lang "pulls.head_repo"}}:</label>
14 + <select class="select font-mono text-xs" style="width:auto;min-width:150px;padding:0.25rem 0.5rem;" onchange="window.location.href='?head_repo='+encodeURIComponent(this.value)">
15 + {{range .HeadRepos}}
16 + <option value="{{.ID}}" {{if eq .ID $.HeadRepo.ID}}selected{{end}}>{{.OwnerUsername}}/{{.Name}}</option>
17 + {{end}}
18 + </select>
19 + {{end}}
20 + <label class="text-muted font-medium">{{t .Lang "pulls.base"}}:</label>
21 + <select class="select font-mono text-xs" style="width:auto;min-width:130px;padding:0.25rem 0.5rem;" onchange="window.location.href='?base='+encodeURIComponent(this.value)+'&head={{.HeadBranch}}&head_repo={{.HeadRepo.ID}}'">
22 + {{range .BaseBranches}}
23 + <option value="{{.}}" {{if eq . $.BaseBranch}}selected{{end}}>{{.}}</option>
24 + {{end}}
25 + </select>
26 +
27 + <span class="text-muted font-mono">←</span>
28 +
29 + <label class="text-muted font-medium">{{t .Lang "pulls.compare"}}:</label>
30 + <select class="select font-mono text-xs" style="width:auto;min-width:130px;padding:0.25rem 0.5rem;" onchange="window.location.href='?base={{.BaseBranch}}&head='+encodeURIComponent(this.value)+'&head_repo={{.HeadRepo.ID}}'">
31 + {{range .Branches}}
32 + <option value="{{.}}" {{if eq . $.HeadBranch}}selected{{end}}>{{.}}</option>
33 + {{end}}
34 + </select>
35 + </div>
36 + </div>
37 +
38 + {{if and (eq .BaseBranch .HeadBranch) (eq .HeadRepo.ID .Repo.ID)}}
39 + <div class="card-body text-xs text-muted" style="background:hsl(var(--muted)/0.2);">
40 + {{icon "alert-circle" "size-4"}} {{t .Lang "pulls.branches_identical"}}
41 + </div>
42 + {{else}}
43 + <div class="card-body text-xs flex items-center gap-2" style="background:hsl(var(--muted)/0.2);">
44 + {{if .CanMerge}}
45 + <span style="color:hsl(var(--diff-add-fg));" class="flex items-center gap-1.5 font-medium">
46 + {{icon "check" "size-4"}} {{t .Lang "pulls.able_to_merge"}}
47 + </span>
48 + {{else}}
49 + <span style="color:hsl(var(--diff-del-fg));" class="flex items-center gap-1.5 font-medium">
50 + {{icon "alert-circle" "size-4"}} {{t .Lang "pulls.cannot_merge_warning"}}
51 + </span>
52 + {{end}}
53 + </div>
54 + {{end}}
55 + </div>
56 +
57 + {{if or (ne .BaseBranch .HeadBranch) (ne .HeadRepo.ID .Repo.ID)}}
58 + <!-- Create PR Form -->
59 + <section class="card mb-4">
60 + <div class="card-header">
61 + <span class="font-semibold text-sm">{{t .Lang "pulls.new"}}</span>
62 + {{if ne .HeadRepo.ID .Repo.ID}}
63 + <span class="text-xs text-muted font-mono">{{.HeadRepo.OwnerUsername}}/{{.HeadRepo.Name}} → {{.Repo.OwnerUsername}}/{{.Repo.Name}}</span>
64 + {{end}}
65 + </div>
66 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new" class="card-body flex flex-col gap-3">
67 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
68 + <input type="hidden" name="base_branch" value="{{.BaseBranch}}">
69 + <input type="hidden" name="head_branch" value="{{.HeadBranch}}">
70 + {{if ne .HeadRepo.ID .Repo.ID}}<input type="hidden" name="head_repo" value="{{.HeadRepo.ID}}">{{end}}
71 +
72 + <div>
73 + <label class="label" for="title">{{t .Lang "pulls.title"}}</label>
74 + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "pulls.title_ph"}}" required maxlength="200" autofocus>
75 + </div>
76 +
77 + <div>
78 + <label class="label" for="body">{{t .Lang "pulls.body"}}</label>
79 + <textarea id="body" name="body" class="textarea" rows="6" placeholder="{{t .Lang "pulls.body_ph"}}" maxlength="20000"></textarea>
80 + </div>
81 +
82 + <div class="flex items-center justify-end">
83 + <button type="submit" class="btn btn-primary btn-sm">
84 + {{icon "git-pull-request" "size-3.5"}}
85 + <span>{{t .Lang "pulls.create_btn"}}</span>
86 + </button>
87 + </div>
88 + </form>
89 + </section>
90 +
91 + <!-- Commits in this comparison -->
92 + {{if .CompareCommits}}
93 + <div class="card mb-4">
94 + <div class="card-header flex items-center justify-between">
95 + <span class="font-semibold text-xs text-muted uppercase" style="letter-spacing:0.04em;">{{t .Lang "pulls.commits"}} ({{len .CompareCommits}})</span>
96 + </div>
97 + <div class="dense-list">
98 + {{range .CompareCommits}}
99 + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;">
100 + <div class="flex items-center gap-2 min-w-0">
101 + <span class="text-muted">{{icon "git-commit" "size-3.5"}}</span>
102 + <span class="font-semibold text-sm truncate">{{.Subject}}</span>
103 + </div>
104 + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0 font-mono">
105 + <span>{{.AuthorName}}</span>
106 + <span class="badge badge-outline">{{.ShortHash}}</span>
107 + </div>
108 + </div>
109 + {{end}}
110 + </div>
111 + </div>
112 + {{end}}
113 +
114 + <!-- Diff Viewer -->
115 + {{if .Compare}}
116 + {{if .Compare.Files}}
117 + <div class="flex items-center justify-between mb-3 text-xs text-muted font-mono">
118 + <span><strong>{{.Compare.FilesChanged}}</strong> {{pluralT .Lang .Compare.FilesChanged "repo.files_changed"}}</span>
119 + <div class="flex items-center gap-2">
120 + <span style="color:hsl(var(--diff-add-fg));">+{{.Compare.TotalAdditions}}</span>
121 + <span style="color:hsl(var(--diff-del-fg));">-{{.Compare.TotalDeletions}}</span>
122 + </div>
123 + </div>
124 +
125 + <div class="flex flex-col gap-4">
126 + {{range .Compare.Files}}
127 + <section class="card">
128 + <div class="card-header" style="padding:0.5rem 0.8rem;">
129 + <div class="flex items-center gap-2 font-mono text-xs min-w-0">
130 + {{icon "file-code" "size-4"}}
131 + {{if eq .Status "renamed"}}
132 + <span>{{.OldPath}} → <strong>{{.NewPath}}</strong></span>
133 + {{else}}
134 + <strong>{{.NewPath}}</strong>
135 + {{end}}
136 + </div>
137 + <div class="flex items-center gap-2 font-mono text-xs">
138 + <span style="color:hsl(var(--diff-add-fg));">+{{.Additions}}</span>
139 + <span style="color:hsl(var(--diff-del-fg));">-{{.Deletions}}</span>
140 + </div>
141 + </div>
142 +
143 + {{if .IsBinary}}
144 + <div class="card-body text-xs text-muted">{{t $.Lang "repo.binary_notice"}}</div>
145 + {{else}}
146 + <div style="overflow-x:auto;">
147 + <table class="diff-table">
148 + <tbody>
149 + {{range .Lines}}
150 + {{if eq .Type "hunk"}}
151 + <tr class="diff-row-hunk">
152 + <td class="diff-num">...</td>
153 + <td class="diff-num">...</td>
154 + <td class="diff-code">{{.Content}}</td>
155 + </tr>
156 + {{else if eq .Type "add"}}
157 + <tr class="diff-row-add">
158 + <td class="diff-num"></td>
159 + <td class="diff-num">{{.NewNum}}</td>
160 + <td class="diff-code">+{{.Content}}</td>
161 + </tr>
162 + {{else if eq .Type "del"}}
163 + <tr class="diff-row-del">
164 + <td class="diff-num">{{.OldNum}}</td>
165 + <td class="diff-num"></td>
166 + <td class="diff-code">-{{.Content}}</td>
167 + </tr>
168 + {{else}}
169 + <tr>
170 + <td class="diff-num">{{.OldNum}}</td>
171 + <td class="diff-num">{{.NewNum}}</td>
172 + <td class="diff-code"> {{.Content}}</td>
173 + </tr>
174 + {{end}}
175 + {{end}}
176 + </tbody>
177 + </table>
178 + </div>
179 + {{end}}
180 + </section>
181 + {{end}}
182 + </div>
183 + {{end}}
184 + {{end}}
185 + {{end}}
186 +</div>
187 +{{end}}
internal/web/views/pages/pulls.html
+79 -0
... ... @@ -0,0 +1,79 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
6 + <div class="flex items-center gap-1.5">
7 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=open" class="btn {{if eq .CurrentState "open"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
8 + <span style="color:hsl(var(--primary));">{{icon "git-pull-request" "size-3.5"}}</span>
9 + <span>{{t .Lang "pulls.open"}} ({{.OpenCount}})</span>
10 + </a>
11 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=merged" class="btn {{if eq .CurrentState "merged"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
12 + <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-3.5"}}</span>
13 + <span>{{t .Lang "pulls.merged"}} ({{.MergedCount}})</span>
14 + </a>
15 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=closed" class="btn {{if eq .CurrentState "closed"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm">
16 + <span class="text-muted">{{icon "close" "size-3.5"}}</span>
17 + <span>{{t .Lang "pulls.closed"}} ({{.ClosedCount}})</span>
18 + </a>
19 + </div>
20 +
21 + {{if .CurrentUser}}
22 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new" class="btn btn-primary btn-sm">
23 + {{icon "plus" "size-3.5"}}
24 + <span>{{t .Lang "pulls.new"}}</span>
25 + </a>
26 + {{end}}
27 + </div>
28 +
29 + {{if .Pulls}}
30 + <div class="card">
31 + <div class="dense-list">
32 + {{range .Pulls}}
33 + <div class="dense-row" style="min-height:3rem;padding:0.6rem 0.85rem;">
34 + <div class="flex items-center gap-2.5 min-w-0">
35 + {{if eq .Status "merged"}}
36 + <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-4"}}</span>
37 + {{else if eq .Status "closed"}}
38 + <span class="text-muted">{{icon "close" "size-4"}}</span>
39 + {{else}}
40 + <span style="color:hsl(var(--primary));">{{icon "git-pull-request" "size-4"}}</span>
41 + {{end}}
42 + <div class="flex flex-col min-w-0">
43 + <div class="flex items-center gap-2 min-w-0 flex-wrap">
44 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/pulls/{{.Number}}" class="font-semibold text-sm truncate">{{.Title}}</a>
45 + <span class="font-mono text-xs text-muted">#{{.Number}}</span>
46 + <span class="badge badge-outline font-mono text-xs" style="padding:0.1rem 0.4rem;">{{.HeadBranch}} → {{.BaseBranch}}</span>
47 + </div>
48 + <div class="text-xs text-muted flex items-center gap-1.5 mt-0.5">
49 + <span>@{{.AuthorUsername}}</span>
50 + <span>·</span>
51 + <span>{{timeAgo .CreatedAt $.Lang}}</span>
52 + {{if eq .Status "merged"}}
53 + <span>·</span>
54 + <span style="color:hsl(var(--accent));font-weight:500;">{{t $.Lang "pulls.merged"}}</span>
55 + {{end}}
56 + </div>
57 + </div>
58 + </div>
59 + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0">
60 + {{if gt .CommentCount 0}}
61 + <span class="flex items-center gap-1 font-mono">
62 + {{icon "message" "size-3.5"}}
63 + <span>{{.CommentCount}}</span>
64 + </span>
65 + {{end}}
66 + </div>
67 + </div>
68 + {{end}}
69 + </div>
70 + </div>
71 + {{else}}
72 + {{if .CurrentUser}}
73 + {{template "ui_empty_state" (dict "Icon" "git-pull-request" "Title" (t .Lang "pulls.empty") "ActionHref" (printf "/%s/%s/pulls/new" .Repo.OwnerUsername .Repo.Name) "ActionLabel" (t .Lang "pulls.new") "ActionIcon" "plus")}}
74 + {{else}}
75 + {{template "ui_empty_state" (dict "Icon" "git-pull-request" "Title" (t .Lang "pulls.empty"))}}
76 + {{end}}
77 + {{end}}
78 +</div>
79 +{{end}}
internal/web/views/pages/register.html
+42 -0
... ... @@ -0,0 +1,42 @@
1 +{{define "content"}}
2 +<div class="container" style="max-width:23rem;padding-top:2rem;">
3 + <div class="card">
4 + <div class="card-header">
5 + <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "auth.register.title"}}</h1>
6 + </div>
7 + {{if not .RegistrationEnabled}}
8 + <div class="card-body text-sm text-muted">
9 + <div class="flex items-center gap-2 text-destructive">
10 + {{icon "lock" "size-4"}}
11 + <span>{{t .Lang "auth.register.disabled"}}</span>
12 + </div>
13 + </div>
14 + {{else}}
15 + <form method="post" action="/register" class="card-body flex flex-col gap-3">
16 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
17 + <div>
18 + <label class="label" for="username">{{t .Lang "auth.username"}}</label>
19 + <input id="username" name="username" type="text" class="input font-mono" value="{{.FormUsername}}" placeholder="polar" required autofocus pattern="^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$">
20 + <div class="field-hint">{{t .Lang "auth.username_hint"}}</div>
21 + </div>
22 + <div>
23 + <label class="label" for="email">{{t .Lang "auth.email"}}</label>
24 + <input id="email" name="email" type="email" class="input" value="{{.FormEmail}}" placeholder="user@example.com" required autocomplete="email">
25 + </div>
26 + <div>
27 + <label class="label" for="password">{{t .Lang "auth.password"}}</label>
28 + <input id="password" name="password" type="password" class="input" minlength="8" required autocomplete="new-password">
29 + <div class="field-hint">{{t .Lang "auth.password_hint"}}</div>
30 + </div>
31 + <button type="submit" class="btn btn-primary w-full mt-1">
32 + <span>{{t .Lang "auth.submit_register"}}</span>
33 + </button>
34 + </form>
35 + {{end}}
36 + <div class="card-footer text-xs text-muted flex items-center justify-between">
37 + <span>{{t .Lang "auth.have_account"}}</span>
38 + <a href="/login" class="font-medium" style="color:hsl(var(--foreground));">{{t .Lang "nav.login"}} →</a>
39 + </div>
40 + </div>
41 +</div>
42 +{{end}}
internal/web/views/pages/release_new.html
+64 -0
... ... @@ -0,0 +1,64 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <section class="card mb-4">
6 + <div class="card-header">
7 + <span class="font-semibold text-sm flex items-center gap-2">
8 + {{icon "tag" "size-4"}}
9 + <span>{{t .Lang "releases.new"}}</span>
10 + </span>
11 + </div>
12 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/new" class="card-body flex flex-col gap-3">
13 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
14 +
15 + <div class="grid-2">
16 + <div>
17 + <label class="label" for="tag_name">{{t .Lang "releases.tag_name"}}</label>
18 + <input id="tag_name" name="tag_name" type="text" class="input font-mono" list="existing-tags" placeholder="v1.0.0" value="{{.SelectedTag}}" required autofocus>
19 + <datalist id="existing-tags">
20 + {{range .Tags}}
21 + <option value="{{.}}">
22 + {{end}}
23 + </datalist>
24 + <div class="text-xs text-muted mt-1">{{t .Lang "releases.tag_desc"}}</div>
25 + </div>
26 +
27 + <div>
28 + <label class="label" for="target_ref">{{t .Lang "releases.target"}}</label>
29 + <select id="target_ref" name="target_ref" class="select font-mono">
30 + {{range .Branches}}
31 + <option value="{{.}}" {{if eq . $.Repo.DefaultBranch}}selected{{end}}>{{.}}</option>
32 + {{end}}
33 + </select>
34 + <div class="text-xs text-muted mt-1">{{t .Lang "releases.target_desc"}}</div>
35 + </div>
36 + </div>
37 +
38 + <div>
39 + <label class="label" for="title">{{t .Lang "releases.title"}}</label>
40 + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "releases.title_ph"}}" maxlength="200">
41 + </div>
42 +
43 + <div>
44 + <label class="label" for="body">{{t .Lang "releases.body"}}</label>
45 + <textarea id="body" name="body" class="textarea" rows="8" placeholder="{{t .Lang "releases.body_ph"}}" maxlength="50000"></textarea>
46 + </div>
47 +
48 + <div>
49 + <label class="flex items-center gap-2 cursor-pointer text-sm">
50 + <input type="checkbox" name="is_prerelease" value="true" class="switch">
51 + <span>{{t .Lang "releases.is_prerelease"}}</span>
52 + </label>
53 + </div>
54 +
55 + <div class="flex items-center justify-end">
56 + <button type="submit" class="btn btn-primary btn-sm">
57 + {{icon "tag" "size-3.5"}}
58 + <span>{{t .Lang "releases.publish_btn"}}</span>
59 + </button>
60 + </div>
61 + </form>
62 + </section>
63 +</div>
64 +{{end}}
internal/web/views/pages/releases.html
+91 -0
... ... @@ -0,0 +1,91 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="flex items-center justify-between flex-wrap gap-2 mb-4">
6 + <div class="flex items-center gap-2 text-sm text-muted">
7 + <span class="font-medium text-foreground">{{len .Releases}}</span>
8 + <span>{{pluralT .Lang (len .Releases) "releases.count"}}</span>
9 + <span>·</span>
10 + <span class="font-medium text-foreground">{{.TagCount}}</span>
11 + <span>{{pluralT .Lang .TagCount "releases.tags"}}</span>
12 + </div>
13 +
14 + {{if .CanWrite}}
15 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/new" class="btn btn-primary btn-sm">
16 + {{icon "plus" "size-3.5"}}
17 + <span>{{t .Lang "releases.new"}}</span>
18 + </a>
19 + {{end}}
20 + </div>
21 +
22 + {{if .Releases}}
23 + <div class="flex flex-col gap-6">
24 + {{range .Releases}}
25 + <article class="card" id="{{.TagName}}">
26 + <div class="card-header flex items-center justify-between flex-wrap gap-2">
27 + <div class="flex items-center gap-2.5 flex-wrap min-w-0">
28 + <h2 class="text-base font-bold" style="margin:0;">{{.Title}}</h2>
29 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.TagName}}" class="badge badge-primary font-mono text-xs flex items-center gap-1">
30 + {{icon "tag" "size-3"}}
31 + <span>{{.TagName}}</span>
32 + </a>
33 + {{if .IsPrerelease}}
34 + <span class="badge badge-outline text-xs" style="color:hsl(var(--warning-fg, 38 92% 50%));border-color:currentColor;">
35 + {{t $.Lang "releases.prerelease"}}
36 + </span>
37 + {{end}}
38 + </div>
39 +
40 + <div class="flex items-center gap-2 text-xs text-muted flex-shrink-0">
41 + <div class="flex items-center gap-1.5">
42 + {{avatar .AuthorUsername .AuthorAvatar}}
43 + <a href="/{{.AuthorUsername}}" class="font-medium text-foreground">@{{.AuthorUsername}}</a>
44 + <span>·</span>
45 + <span>{{formatDate .PublishedAt}}</span>
46 + </div>
47 + {{if $.CanWrite}}
48 + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{.ID}}/delete" onsubmit="return confirm('{{t $.Lang "releases.delete_confirm"}}');" style="margin:0;">
49 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
50 + <button type="submit" class="btn btn-ghost btn-icon text-muted" style="width:1.8rem;height:1.8rem;" title="{{t $.Lang "releases.delete"}}">
51 + {{icon "trash" "size-3.5"}}
52 + </button>
53 + </form>
54 + {{end}}
55 + </div>
56 + </div>
57 +
58 + <div class="card-body markdown-body">
59 + {{if .BodyHTML}}
60 + {{.BodyHTML}}
61 + {{else}}
62 + <span class="text-xs text-muted">—</span>
63 + {{end}}
64 + </div>
65 +
66 + <!-- Release Assets (Source Code) -->
67 + <div class="card-body" style="border-top:1px solid hsl(var(--border));padding:0.6rem 0.9rem;background:hsl(var(--muted)/0.15);">
68 + <div class="text-xs font-semibold text-muted uppercase mb-2" style="letter-spacing:0.04em;">{{t $.Lang "releases.assets"}}</div>
69 + <div class="flex flex-col gap-1.5">
70 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.TagName}}.zip" class="flex items-center gap-2 text-xs font-mono font-medium hover:underline" style="color:hsl(var(--primary));">
71 + {{icon "download" "size-3.5"}}
72 + <span>{{t $.Lang "releases.source_zip"}}</span>
73 + </a>
74 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/archive/{{.TagName}}.tar.gz" class="flex items-center gap-2 text-xs font-mono font-medium hover:underline" style="color:hsl(var(--primary));">
75 + {{icon "download" "size-3.5"}}
76 + <span>{{t $.Lang "releases.source_tar"}}</span>
77 + </a>
78 + </div>
79 + </div>
80 + </article>
81 + {{end}}
82 + </div>
83 + {{else}}
84 + {{if .CanWrite}}
85 + {{template "ui_empty_state" (dict "Icon" "tag" "Title" (t .Lang "releases.empty") "ActionHref" (printf "/%s/%s/releases/new" .Repo.OwnerUsername .Repo.Name) "ActionLabel" (t .Lang "releases.new") "ActionIcon" "plus")}}
86 + {{else}}
87 + {{template "ui_empty_state" (dict "Icon" "tag" "Title" (t .Lang "releases.empty"))}}
88 + {{end}}
89 + {{end}}
90 +</div>
91 +{{end}}
internal/web/views/pages/repo.html
+218 -0
... ... @@ -0,0 +1,218 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + {{if .IsEmpty}}
6 + <div class="card">
7 + <div class="card-header">
8 + <span class="font-semibold flex items-center gap-2">{{icon "terminal" "size-4"}} {{t .Lang "repo.empty.title"}}</span>
9 + <a href="/settings#tokens" class="btn btn-outline btn-sm">
10 + {{icon "key" "size-3.5"}}
11 + <span>{{t .Lang "settings.tokens"}}</span>
12 + </a>
13 + </div>
14 + <div class="card-body flex flex-col gap-4">
15 + <div>
16 + <div class="flex items-center justify-between mb-1.5">
17 + <span class="text-xs font-semibold">1. {{t .Lang "repo.empty.new_cmd"}}</span>
18 + <button type="button" class="btn btn-ghost btn-sm" data-copy="{{.QuickStartNew}}">
19 + {{icon "copy" "size-3.5"}}
20 + <span>{{t .Lang "common.copy"}}</span>
21 + </button>
22 + </div>
23 + <pre class="card p-3 font-mono text-xs" style="margin:0;background:hsl(var(--muted)/0.4);overflow-x:auto;"><code>{{.QuickStartNew}}</code></pre>
24 + </div>
25 +
26 + <div>
27 + <div class="flex items-center justify-between mb-1.5">
28 + <span class="text-xs font-semibold">2. {{t .Lang "repo.empty.push_cmd"}}</span>
29 + <button type="button" class="btn btn-ghost btn-sm" data-copy="{{.QuickStartExisting}}">
30 + {{icon "copy" "size-3.5"}}
31 + <span>{{t .Lang "common.copy"}}</span>
32 + </button>
33 + </div>
34 + <pre class="card p-3 font-mono text-xs" style="margin:0;background:hsl(var(--muted)/0.4);overflow-x:auto;"><code>{{.QuickStartExisting}}</code></pre>
35 + </div>
36 + </div>
37 + </div>
38 + {{else}}
39 + <div class="repo-layout">
40 + <div class="repo-main">
41 + <!-- Controls Bar: Branch/Tag Switcher + Breadcrumb + File Filter -->
42 + <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
43 + <div class="flex items-center gap-2 flex-wrap">
44 + <div class="dropdown" data-dropdown>
45 + <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false">
46 + {{icon "git-branch" "size-3.5"}}
47 + <span>{{.Ref}}</span>
48 + {{icon "chevron-down" "size-3.5"}}
49 + </button>
50 + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;">
51 + <div class="palette-group-title">{{t .Lang "repo.branches"}} ({{len .Branches}})</div>
52 + {{range .Branches}}
53 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
54 + <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span>
55 + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
56 + </a>
57 + {{end}}
58 + {{if .Tags}}
59 + <hr class="separator" style="margin:0.25rem 0;">
60 + <div class="palette-group-title">{{t .Lang "repo.tags"}} ({{len .Tags}})</div>
61 + {{range .Tags}}
62 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
63 + <span class="flex items-center gap-2">{{icon "tag" "size-3.5"}} {{.}}</span>
64 + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
65 + </a>
66 + {{end}}
67 + {{end}}
68 + </div>
69 + </div>
70 +
71 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.branches"}}">
72 + {{icon "git-branch" "size-3.5"}}
73 + <span class="text-xs">{{len .Branches}}</span>
74 + </a>
75 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commits/{{.Ref}}" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.commits"}}">
76 + {{icon "history" "size-3.5"}}
77 + <span class="text-xs">{{t .Lang "repo.tab.commits"}}</span>
78 + </a>
79 +
80 + {{if .Breadcrumbs}}
81 + <nav class="breadcrumb font-mono text-xs" aria-label="Directory path">
82 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}" class="text-muted">{{.Repo.Name}}</a>
83 + {{range .Breadcrumbs}}
84 + <span class="breadcrumb-sep">/</span>
85 + {{if .IsLast}}
86 + <span class="font-semibold">{{.Name}}</span>
87 + {{else}}
88 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="text-muted">{{.Name}}</a>
89 + {{end}}
90 + {{end}}
91 + </nav>
92 + {{end}}
93 + </div>
94 +
95 + <div class="flex items-center" style="position:relative;">
96 + <input type="text" class="input" style="height:1.75rem;width:13rem;padding-right:1.75rem;font-size:0.78rem;" placeholder="{{t .Lang "repo.find_file"}}" data-file-filter>
97 + <kbd class="kbd" style="position:absolute;right:0.35rem;pointer-events:none;">t</kbd>
98 + </div>
99 + </div>
100 +
101 + <!-- Unified Repository Card: Latest Commit Header + File List -->
102 + <div class="card mb-4">
103 + {{if .LatestCommit}}
104 + <div class="card-header text-xs" style="padding:0.5rem 0.8rem;background:hsl(var(--muted)/0.3);">
105 + <div class="flex items-center gap-2 min-w-0">
106 + <span class="avatar" style="width:1.25rem;height:1.25rem;font-size:0.62rem;">{{avatarInitial .LatestCommit.AuthorName}}</span>
107 + <span class="font-medium">{{.LatestCommit.AuthorName}}</span>
108 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="text-muted truncate">{{.LatestCommit.Subject}}</a>
109 + </div>
110 + <div class="flex items-center gap-2 text-muted font-mono">
111 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="badge badge-outline font-mono">{{.LatestCommit.ShortHash}}</a>
112 + <span>{{timeAgo .LatestCommit.AuthoredAt .Lang}}</span>
113 + </div>
114 + </div>
115 + {{end}}
116 +
117 + <div class="dense-list">
118 + {{if .SubPath}}
119 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}{{if ne .ParentPath "."}}/{{.ParentPath}}{{end}}" class="dense-row font-mono text-xs text-muted">
120 + <span class="flex items-center gap-2">{{icon "arrow-left" "size-3.5"}} ..</span>
121 + </a>
122 + {{end}}
123 + {{range .Entries}}
124 + <div class="dense-row" data-tree-row data-tree-name="{{.Name}}">
125 + <div class="flex items-center gap-2 min-w-0" style="flex:1 1 40%;">
126 + {{if eq .Type "tree"}}
127 + {{icon "folder" "size-4"}}
128 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="font-medium truncate">{{.Name}}</a>
129 + {{else}}
130 + {{icon "file" "size-4"}}
131 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}" class="truncate">{{.Name}}</a>
132 + {{end}}
133 + </div>
134 + <div class="hidden md-flex text-xs text-muted truncate" style="flex:1 1 45%;">
135 + {{if .CommitHash}}
136 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.CommitHash}}" class="truncate text-muted">{{.CommitMsg}}</a>
137 + {{end}}
138 + </div>
139 + <div class="text-xs text-muted font-mono" style="text-align:right;min-width:5rem;">
140 + {{if not .CommitTime.IsZero}}
141 + {{timeAgo .CommitTime $.Lang}}
142 + {{else if eq .Type "blob"}}
143 + {{formatSize .Size}}
144 + {{end}}
145 + </div>
146 + </div>
147 + {{end}}
148 + </div>
149 + </div>
150 +
151 + {{if .ReadmeHTML}}
152 + <section class="card">
153 + <div class="card-header" style="padding:0.55rem 0.85rem;">
154 + <span class="font-semibold text-xs flex items-center gap-2 font-mono">
155 + {{icon "book-open" "size-3.5"}}
156 + <span>{{.ReadmeName}}</span>
157 + </span>
158 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.ReadmeName}}" class="text-xs text-muted">{{t .Lang "repo.view_file"}}</a>
159 + </div>
160 + <div class="card-body markdown-body">
161 + {{.ReadmeHTML}}
162 + </div>
163 + </section>
164 + {{end}}
165 + </div>
166 +
167 + <aside class="repo-side">
168 + <div class="side-card">
169 + <div class="side-card-header">{{icon "info" "size-3.5"}} {{t .Lang "repo.about"}}</div>
170 + <div class="side-card-body">
171 + {{if .Repo.Description}}
172 + <p style="margin:0;">{{.Repo.Description}}</p>
173 + {{end}}
174 + {{if .Repo.ForkedFromRepoID}}
175 + <div class="meta-row">
176 + <span>{{t .Lang "fork.forked_from"}}</span>
177 + <a href="/{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}" class="font-medium font-mono">{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}</a>
178 + </div>
179 + {{end}}
180 + <div class="meta-row">
181 + <span>{{t .Lang "repo.default_branch"}}</span>
182 + <span class="badge badge-outline font-mono">{{.Repo.DefaultBranch}}</span>
183 + </div>
184 + <div class="meta-row">
185 + <span>{{t .Lang "fork.forks"}}</span>
186 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks" class="font-mono">{{.Repo.ForkCount}}</a>
187 + </div>
188 + <div class="meta-row">
189 + <span>{{t .Lang "repo.created"}}</span>
190 + <span class="font-mono">{{formatDate .Repo.CreatedAt}}</span>
191 + </div>
192 + <div class="meta-row">
193 + <span>{{t .Lang "repo.updated"}}</span>
194 + <span class="font-mono">{{formatDate .Repo.UpdatedAt}}</span>
195 + </div>
196 + </div>
197 + </div>
198 +
199 + <div class="side-card">
200 + <div class="side-card-header">{{icon "terminal" "size-3.5"}} {{t .Lang "repo.clone_title"}}</div>
201 + <div class="side-card-body">
202 + <div class="clone-box">
203 + <code>{{.CloneURL}}</code>
204 + <button type="button" class="btn btn-ghost btn-icon" style="width:1.6rem;height:1.6rem;" data-copy="{{.CloneURL}}" title="{{t .Lang "common.copy"}}">
205 + {{icon "copy" "size-3.5"}}
206 + </button>
207 + </div>
208 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/archive/{{.Ref}}.zip" class="btn btn-outline btn-sm w-full justify-center">
209 + {{icon "download" "size-3.5"}}
210 + <span>{{t .Lang "repo.download_zip"}}</span>
211 + </a>
212 + </div>
213 + </div>
214 + </aside>
215 + </div>
216 + {{end}}
217 +</div>
218 +{{end}}
internal/web/views/pages/repo_new.html
+55 -0
... ... @@ -0,0 +1,55 @@
1 +{{define "content"}}
2 +<div class="container" style="max-width:32rem;">
3 + <div class="card">
4 + <div class="card-header">
5 + <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "repo.new.title"}}</h1>
6 + </div>
7 +
8 + <form method="post" action="/new" class="card-body flex flex-col gap-3">
9 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
10 +
11 + <div>
12 + <label class="label" for="name">{{t .Lang "repo.name"}}</label>
13 + <div class="flex items-center gap-2">
14 + <span class="text-sm text-muted font-mono flex-shrink-0">{{.CurrentUser.Username}} /</span>
15 + <input id="name" name="name" type="text" class="input font-mono flex-1 min-w-0" value="{{.FormName}}" placeholder="project" required autofocus pattern="^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$">
16 + </div>
17 + <div class="field-hint">{{t .Lang "repo.name_hint"}}</div>
18 + </div>
19 +
20 + <div>
21 + <label class="label" for="description">{{t .Lang "repo.description"}}</label>
22 + <input id="description" name="description" type="text" class="input" value="{{.FormDescription}}" maxlength="500" placeholder="{{t .Lang "repo.description_ph"}}">
23 + </div>
24 +
25 + <div>
26 + <span class="label">{{t .Lang "repo.visibility"}}</span>
27 + <div class="flex flex-col gap-2">
28 + <label class="card p-3 flex items-start gap-2.5" style="cursor:pointer;">
29 + <input type="radio" name="visibility" value="public" checked style="margin-top:0.2rem;">
30 + <div>
31 + <div class="font-medium text-sm flex items-center gap-1.5">{{icon "globe" "size-4"}} {{t .Lang "repo.public"}}</div>
32 + <div class="text-xs text-muted mt-1">{{t .Lang "repo.public_desc"}}</div>
33 + </div>
34 + </label>
35 + <label class="card p-3 flex items-start gap-2.5" style="cursor:pointer;">
36 + <input type="radio" name="visibility" value="private" style="margin-top:0.2rem;">
37 + <div>
38 + <div class="font-medium text-sm flex items-center gap-1.5">{{icon "lock" "size-4"}} {{t .Lang "repo.private"}}</div>
39 + <div class="text-xs text-muted mt-1">{{t .Lang "repo.private_desc"}}</div>
40 + </div>
41 + </label>
42 + </div>
43 + </div>
44 +
45 + <div class="flex items-center justify-end gap-2 pt-2">
46 + <a href="/repos" class="btn btn-outline">{{t .Lang "common.cancel"}}</a>
47 + <button type="submit" class="btn btn-primary">
48 + {{icon "plus" "size-4"}}
49 + <span>{{t .Lang "repo.create_btn"}}</span>
50 + </button>
51 + </div>
52 + </form>
53 + </div>
54 +</div>
55 +{{end}}
internal/web/views/pages/repo_settings.html
+210 -0
... ... @@ -0,0 +1,210 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <!-- General Settings -->
6 + <section class="card mb-4">
7 + <div class="card-header">
8 + <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "repo.tab.settings"}}</span>
9 + </div>
10 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" class="card-body flex flex-col gap-3">
11 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
12 +
13 + <div>
14 + <label class="label" for="description">{{t .Lang "repo.description"}}</label>
15 + <input id="description" name="description" type="text" class="input" value="{{.Repo.Description}}" maxlength="500" placeholder="{{t .Lang "repo.description_ph"}}">
16 + </div>
17 +
18 + <div class="grid-2">
19 + <div>
20 + <label class="label" for="default_branch">{{t .Lang "repo.default_branch"}}</label>
21 + <select id="default_branch" name="default_branch" class="select font-mono">
22 + {{if .Branches}}
23 + {{range .Branches}}
24 + <option value="{{.}}" {{if eq . $.Repo.DefaultBranch}}selected{{end}}>{{.}}</option>
25 + {{end}}
26 + {{else}}
27 + <option value="{{.Repo.DefaultBranch}}" selected>{{.Repo.DefaultBranch}}</option>
28 + {{end}}
29 + </select>
30 + </div>
31 +
32 + <div>
33 + <label class="label" for="visibility">{{t .Lang "repo.visibility"}}</label>
34 + <select id="visibility" name="visibility" class="select">
35 + <option value="public" {{if not .Repo.IsPrivate}}selected{{end}}>{{t .Lang "repo.public"}}</option>
36 + <option value="private" {{if .Repo.IsPrivate}}selected{{end}}>{{t .Lang "repo.private"}}</option>
37 + </select>
38 + </div>
39 + </div>
40 +
41 + <!-- Feature Toggles for Repository -->
42 + <div class="card p-3 mt-1" style="background:hsl(var(--muted) / 0.25);">
43 + <div class="font-semibold text-xs text-muted mb-2 uppercase" style="letter-spacing:0.04em;">{{t .Lang "repo.features"}}</div>
44 + <div class="flex flex-col gap-2.5">
45 + <label class="flex items-center justify-between cursor-pointer">
46 + <span class="flex items-center gap-2 text-sm font-medium">
47 + {{icon "issue" "size-4"}}
48 + <span>{{t .Lang "repo.tab.issues"}}</span>
49 + </span>
50 + <input type="checkbox" name="enable_issues" value="true" class="switch" {{if .Repo.EnableIssues}}checked{{end}}>
51 + </label>
52 + <label class="flex items-center justify-between cursor-pointer">
53 + <span class="flex items-center gap-2 text-sm font-medium">
54 + {{icon "git-pull-request" "size-4"}}
55 + <span>{{t .Lang "repo.tab.pulls"}}</span>
56 + </span>
57 + <input type="checkbox" name="enable_pulls" value="true" class="switch" {{if .Repo.EnablePulls}}checked{{end}}>
58 + </label>
59 + <label class="flex items-center justify-between cursor-pointer">
60 + <span class="flex items-center gap-2 text-sm font-medium">
61 + {{icon "tag" "size-4"}}
62 + <span>{{t .Lang "repo.tab.releases"}}</span>
63 + </span>
64 + <input type="checkbox" name="enable_releases" value="true" class="switch" {{if .Repo.EnableReleases}}checked{{end}}>
65 + </label>
66 + <label class="flex items-center justify-between cursor-pointer">
67 + <span class="flex items-center gap-2 text-sm font-medium">
68 + {{icon "activity" "size-4"}}
69 + <span>{{t .Lang "repo.tab.activity"}}</span>
70 + </span>
71 + <input type="checkbox" name="enable_activity" value="true" class="switch" {{if .Repo.EnableActivity}}checked{{end}}>
72 + </label>
73 + </div>
74 + </div>
75 +
76 + <div class="flex items-center justify-end">
77 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "repo.save"}}</button>
78 + </div>
79 + </form>
80 + </section>
81 +
82 + <!-- Collaborators -->
83 + <section class="card mb-4" id="collaborators">
84 + <div class="card-header">
85 + <span class="font-semibold flex items-center gap-2">{{icon "users" "size-4"}} {{t .Lang "collab.title"}}</span>
86 + <span class="text-xs text-muted">{{t .Lang "collab.desc"}}</span>
87 + </div>
88 +
89 + <div class="dense-list" style="border-bottom:1px solid hsl(var(--border));">
90 + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;">
91 + <div class="flex items-center gap-2.5 min-w-0">
92 + {{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL}}
93 + <div class="min-w-0">
94 + <a href="/{{.Repo.OwnerUsername}}" class="font-semibold text-sm">{{.Repo.OwnerUsername}}</a>
95 + </div>
96 + </div>
97 + <span class="badge badge-outline">{{t .Lang "collab.owner"}}</span>
98 + </div>
99 + {{range .Collaborators}}
100 + <div class="dense-row" style="min-height:2.6rem;padding:0.45rem 0.85rem;">
101 + <div class="flex items-center gap-2.5 min-w-0">
102 + {{avatar .Username .AvatarURL}}
103 + <div class="min-w-0">
104 + <a href="/{{.Username}}" class="font-semibold text-sm">{{.Username}}</a>
105 + {{if .FullName}}<div class="text-xs text-muted truncate">{{.FullName}}</div>{{end}}
106 + </div>
107 + </div>
108 + <div class="flex items-center gap-2">
109 + <span class="badge badge-outline font-mono">{{if eq .Role "read"}}{{t $.Lang "collab.role_read"}}{{else}}{{t $.Lang "collab.role_write"}}{{end}}</span>
110 + {{if $.CanManageCollab}}
111 + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/settings/collaborators/remove" style="margin:0;">
112 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
113 + <input type="hidden" name="user_id" value="{{.UserID}}">
114 + <button type="submit" class="btn btn-ghost btn-icon" style="width:1.75rem;height:1.75rem;" title="{{t $.Lang "collab.remove"}}">
115 + {{icon "close" "size-3.5"}}
116 + </button>
117 + </form>
118 + {{end}}
119 + </div>
120 + </div>
121 + {{else}}
122 + <div class="card-body text-xs text-muted">{{t .Lang "collab.empty"}}</div>
123 + {{end}}
124 + </div>
125 +
126 + {{if .CanManageCollab}}
127 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings/collaborators/add" class="card-body flex items-end gap-2 flex-wrap">
128 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
129 + <div style="flex:1 1 12rem;">
130 + <label class="label" for="collab_username">{{t .Lang "collab.username"}}</label>
131 + <input id="collab_username" name="username" type="text" class="input font-mono" placeholder="{{t .Lang "collab.username_ph"}}" required>
132 + </div>
133 + <div>
134 + <label class="label" for="collab_role">{{t .Lang "collab.role"}}</label>
135 + <select id="collab_role" name="role" class="select">
136 + <option value="write">{{t .Lang "collab.role_write"}}</option>
137 + <option value="read">{{t .Lang "collab.role_read"}}</option>
138 + </select>
139 + </div>
140 + <button type="submit" class="btn btn-primary btn-sm">
141 + {{icon "plus" "size-3.5"}}
142 + <span>{{t .Lang "collab.add"}}</span>
143 + </button>
144 + </form>
145 + {{end}}
146 + </section>
147 +
148 + <!-- Rename Repository --> <section class="card mb-4">
149 + <div class="card-header">
150 + <span class="font-semibold flex items-center gap-2">{{icon "edit" "size-4"}} {{t .Lang "repo.rename"}}</span>
151 + </div>
152 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings/rename" class="card-body flex items-center gap-2">
153 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
154 + <input name="new_name" type="text" class="input font-mono" value="{{.Repo.Name}}" required pattern="^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$">
155 + <button type="submit" class="btn btn-outline">{{t .Lang "repo.rename"}}</button>
156 + </form>
157 + </section>
158 +
159 + <!-- Danger Zone -->
160 + <section class="card" style="border-color:hsl(var(--destructive) / 0.45);">
161 + <div class="card-header" style="border-bottom-color:hsl(var(--destructive) / 0.2);">
162 + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "alert-circle" "size-4"}} {{t .Lang "repo.danger_zone"}}</span>
163 + </div>
164 + <div class="card-body flex items-center justify-between flex-wrap gap-3">
165 + <div>
166 + <div class="font-medium text-sm">{{t .Lang "repo.delete"}}</div>
167 + <div class="text-xs text-muted">{{t .Lang "repo.delete_desc"}}</div>
168 + </div>
169 + <button type="button" class="btn btn-destructive btn-sm" data-dialog-open="delete-repo-dialog">
170 + {{icon "trash" "size-3.5"}}
171 + <span>{{t .Lang "repo.delete"}}</span>
172 + </button>
173 + </div>
174 + </section>
175 +</div>
176 +
177 +<!-- Delete Confirmation Modal -->
178 +<div id="delete-repo-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "repo.delete"}}" aria-hidden="true">
179 + <div class="dialog-box">
180 + <div class="card-header">
181 + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "repo.delete"}}</span>
182 + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button>
183 + </div>
184 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings/delete">
185 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
186 + <div class="card-body flex flex-col gap-3">
187 + <p class="text-sm text-muted" style="margin:0;">
188 + {{t .Lang "repo.delete_confirm"}}: <strong class="font-mono" style="color:hsl(var(--foreground));">{{.Repo.Name}}</strong>
189 + </p>
190 + <input
191 + type="text"
192 + name="confirm_name"
193 + class="input font-mono"
194 + placeholder="{{.Repo.Name}}"
195 + autocomplete="off"
196 + required
197 + data-confirm-input="{{.Repo.Name}}"
198 + data-confirm-button="confirm-delete-btn">
199 + </div>
200 + <div class="card-footer flex items-center justify-end gap-2">
201 + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button>
202 + <button id="confirm-delete-btn" type="submit" class="btn btn-destructive btn-sm" disabled>
203 + {{icon "trash" "size-3.5"}}
204 + <span>{{t .Lang "repo.delete"}}</span>
205 + </button>
206 + </div>
207 + </form>
208 + </div>
209 +</div>
210 +{{end}}
internal/web/views/pages/repos.html
+78 -0
... ... @@ -0,0 +1,78 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{if .ProfileUser}}
4 + <div class="card p-4 mb-4 flex items-center justify-between flex-wrap gap-3">
5 + <div class="flex items-center gap-3">
6 + <span class="avatar avatar-lg">{{avatarInitial .ProfileUser.Username}}</span>
7 + <div>
8 + <div class="flex items-center gap-2">
9 + <h1 class="text-lg font-semibold" style="margin:0;">{{.ProfileUser.Username}}</h1>
10 + {{if .ProfileUser.IsAdmin}}
11 + <span class="badge">{{t .Lang "admin.role.admin"}}</span>
12 + {{end}}
13 + </div>
14 + {{if .ProfileUser.Bio}}
15 + <p class="text-sm text-muted mt-1" style="margin-bottom:0;">{{.ProfileUser.Bio}}</p>
16 + {{end}}
17 + </div>
18 + </div>
19 + {{if .CurrentUser}}
20 + {{if eq .CurrentUser.ID .ProfileUser.ID}}
21 + <a href="/settings" class="btn btn-outline btn-sm">{{icon "settings" "size-3.5"}} {{t .Lang "nav.settings"}}</a>
22 + {{end}}
23 + {{end}}
24 + </div>
25 + {{end}}
26 +
27 + <div class="flex items-center justify-between flex-wrap gap-3 mb-4">
28 + <h2 class="text-lg font-semibold" style="margin:0;">{{.PageHeading}}</h2>
29 + <div class="flex items-center gap-2">
30 + <div class="flex items-center" style="position:relative;">
31 + <input type="text" class="input" style="height:1.9rem;width:14rem;padding-right:1.8rem;" placeholder="{{t .Lang "repo.filter_ph"}}" data-search-input data-file-filter>
32 + <kbd class="kbd" style="position:absolute;right:0.4rem;pointer-events:none;">/</kbd>
33 + </div>
34 + {{if .CurrentUser}}
35 + <a href="/new" class="btn btn-primary">
36 + {{icon "plus" "size-4"}}
37 + <span>{{t .Lang "nav.new_repo"}}</span>
38 + </a>
39 + {{end}}
40 + </div>
41 + </div>
42 +
43 + {{if .Repos}}
44 + <div class="card">
45 + {{range .Repos}}
46 + <div class="dense-row" style="min-height:2.5rem;padding:0.4rem 0.85rem;" data-tree-row data-tree-name="{{.OwnerUsername}}/{{.Name}} {{.Description}}">
47 + <div class="flex items-center gap-2 min-w-0">
48 + {{if .IsPrivate}}
49 + {{icon "lock" "size-4"}}
50 + {{else}}
51 + {{icon "git-branch" "size-4"}}
52 + {{end}}
53 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-medium text-sm">
54 + <span class="text-muted">{{.OwnerUsername}} /</span> <strong>{{.Name}}</strong>
55 + </a>
56 + {{if .IsPrivate}}
57 + <span class="badge badge-outline">{{t $.Lang "repo.private"}}</span>
58 + {{end}}
59 + {{if .Description}}
60 + <span class="text-xs text-muted truncate">— {{.Description}}</span>
61 + {{end}}
62 + </div>
63 + <div class="flex items-center gap-2.5 text-xs text-muted">
64 + <span class="font-mono">{{.DefaultBranch}}</span>
65 + <span>{{timeAgo .UpdatedAt $.Lang}}</span>
66 + </div>
67 + </div>
68 + {{end}}
69 + </div>
70 + {{else}}
71 + {{if .CurrentUser}}
72 + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list") "Subtitle" (t .Lang "repo.empty_list_sub") "ActionHref" "/new" "ActionLabel" (t .Lang "repo.new.title") "ActionIcon" "plus")}}
73 + {{else}}
74 + {{template "ui_empty_state" (dict "Icon" "git-branch" "Title" (t .Lang "repo.empty_list"))}}
75 + {{end}}
76 + {{end}}
77 +</div>
78 +{{end}}
internal/web/views/pages/settings.html
+184 -0
... ... @@ -0,0 +1,184 @@
1 +{{define "content"}}
2 +<div class="container">
3 + <div class="flex items-center justify-between mb-4">
4 + <h1 class="text-lg font-semibold" style="margin:0;">{{t .Lang "settings.title"}}</h1>
5 + <a href="/{{.CurrentUser.Username}}" class="btn btn-outline btn-sm">
6 + {{icon "user" "size-3.5"}}
7 + <span>@{{.CurrentUser.Username}}</span>
8 + </a>
9 + </div>
10 +
11 + <!-- Avatar & Profile -->
12 + <section class="card mb-4">
13 + <div class="card-header">
14 + <span class="font-semibold flex items-center gap-2">{{icon "user" "size-4"}} {{t .Lang "settings.profile"}}</span>
15 + </div>
16 + <form method="post" action="/settings/profile" enctype="multipart/form-data" class="card-body flex flex-col gap-4">
17 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
18 + <input type="hidden" id="avatar-data-input" name="avatar_data" value="">
19 + <input type="hidden" id="remove-avatar-input" name="remove_avatar" value="false">
20 +
21 + <!-- Avatar Picker Row -->
22 + <div class="flex items-center gap-4 flex-wrap pb-3" style="border-bottom:1px solid hsl(var(--border));">
23 + <div id="avatar-preview-wrap">
24 + {{avatar .CurrentUser.Username .CurrentUser.AvatarURL "avatar-xl"}}
25 + </div>
26 + <div class="flex flex-col gap-1.5">
27 + <div class="text-sm font-medium">{{t .Lang "settings.avatar"}}</div>
28 + <div class="flex items-center gap-2 flex-wrap">
29 + <label class="btn btn-outline btn-sm" style="cursor:pointer;">
30 + {{icon "image" "size-3.5"}}
31 + <span>{{t .Lang "settings.avatar_upload"}}</span>
32 + <input id="avatar-file-input" name="avatar_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none;">
33 + </label>
34 + {{if .CurrentUser.AvatarURL}}
35 + <button type="button" id="remove-avatar-btn" class="btn btn-ghost btn-sm text-destructive">
36 + {{icon "trash" "size-3.5"}}
37 + <span>{{t .Lang "settings.avatar_remove"}}</span>
38 + </button>
39 + {{end}}
40 + </div>
41 + <div class="field-hint" style="margin:0;">{{t .Lang "settings.avatar_hint"}}</div>
42 + </div>
43 + </div>
44 +
45 + <div class="grid-2">
46 + <div>
47 + <label class="label">{{t .Lang "auth.username"}}</label>
48 + <input type="text" class="input font-mono" value="{{.CurrentUser.Username}}" disabled>
49 + </div>
50 + <div>
51 + <label class="label" for="full_name">{{t .Lang "settings.full_name"}}</label>
52 + <input id="full_name" name="full_name" type="text" class="input" value="{{.CurrentUser.FullName}}" maxlength="80" placeholder="{{t .Lang "settings.full_name_ph"}}">
53 + </div>
54 + </div>
55 +
56 + <div class="grid-2">
57 + <div>
58 + <label class="label" for="email">{{t .Lang "auth.email"}}</label>
59 + <input id="email" name="email" type="email" class="input" value="{{.CurrentUser.Email}}" required>
60 + </div>
61 + <div>
62 + <label class="label" for="location">{{t .Lang "settings.location"}}</label>
63 + <input id="location" name="location" type="text" class="input" value="{{.CurrentUser.Location}}" maxlength="80" placeholder="{{t .Lang "settings.location_ph"}}">
64 + </div>
65 + </div>
66 +
67 + <div class="grid-2">
68 + <div>
69 + <label class="label" for="bio">{{t .Lang "settings.bio"}}</label>
70 + <input id="bio" name="bio" type="text" class="input" value="{{.CurrentUser.Bio}}" maxlength="280" placeholder="{{t .Lang "settings.bio_ph"}}">
71 + </div>
72 + <div>
73 + <label class="label" for="website">{{t .Lang "settings.website"}}</label>
74 + <input id="website" name="website" type="text" class="input" value="{{.CurrentUser.Website}}" maxlength="200" placeholder="{{t .Lang "settings.website_ph"}}">
75 + </div>
76 + </div>
77 +
78 + <div class="flex items-center justify-between flex-wrap gap-2 pt-2">
79 + <div class="flex items-center gap-1.5 flex-wrap">
80 + <span class="text-xs text-muted">{{t .Lang "nav.theme"}}:</span>
81 + <button type="button" class="btn btn-outline btn-sm" data-set-theme="light">{{t .Lang "theme.light"}}</button>
82 + <button type="button" class="btn btn-outline btn-sm" data-set-theme="dark">{{t .Lang "theme.dark"}}</button>
83 + <button type="button" class="btn btn-outline btn-sm" data-set-theme="system">{{t .Lang "theme.system"}}</button>
84 + </div>
85 + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "repo.save"}}</button>
86 + </div>
87 + </form>
88 + </section>
89 +
90 + <!-- Personal Access Tokens -->
91 + <section id="tokens" class="card mb-4">
92 + <div class="card-header">
93 + <span class="font-semibold flex items-center gap-2">{{icon "key" "size-4"}} {{t .Lang "settings.tokens"}}</span>
94 + </div>
95 + <div class="card-body flex flex-col gap-3">
96 + <p class="text-xs text-muted" style="margin:0;">{{t .Lang "settings.tokens.desc"}}</p>
97 +
98 + {{if .NewRawToken}}
99 + <div class="card p-3" style="border-color:hsl(var(--primary));background:hsl(var(--muted)/0.5);">
100 + <div class="text-xs font-medium mb-2">{{t .Lang "settings.token.created_once"}}</div>
101 + <div class="flex items-center justify-between gap-2">
102 + <code class="font-mono text-sm select-all" id="created-token-val">{{.NewRawToken}}</code>
103 + <button type="button" class="btn btn-secondary btn-sm" data-copy="{{.NewRawToken}}">
104 + {{icon "copy" "size-3.5"}}
105 + <span>{{t .Lang "common.copy"}}</span>
106 + </button>
107 + </div>
108 + </div>
109 + {{end}}
110 +
111 + <form method="post" action="/settings/tokens" class="flex items-center gap-2">
112 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
113 + <input name="name" type="text" class="input" placeholder="{{t .Lang "settings.token.name"}}" required maxlength="64">
114 + <button type="submit" class="btn btn-primary">
115 + {{icon "plus" "size-4"}}
116 + <span>{{t .Lang "settings.token.create"}}</span>
117 + </button>
118 + </form>
119 +
120 + {{if .Tokens}}
121 + <div class="table-wrap">
122 + <table class="table">
123 + <thead>
124 + <tr>
125 + <th>{{t .Lang "settings.token.col_name"}}</th>
126 + <th>{{t .Lang "settings.token.col_prefix"}}</th>
127 + <th>{{t .Lang "settings.token.col_created"}}</th>
128 + <th>{{t .Lang "settings.token.col_used"}}</th>
129 + <th style="text-align:right;"></th>
130 + </tr>
131 + </thead>
132 + <tbody>
133 + {{range .Tokens}}
134 + <tr>
135 + <td class="font-medium">{{.Name}}</td>
136 + <td><code class="font-mono text-xs text-muted">{{.TokenPrefix}}…</code></td>
137 + <td class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</td>
138 + <td class="text-xs text-muted">
139 + {{if .LastUsedAt}}{{timeAgo .LastUsedAt $.Lang}}{{else}}{{t $.Lang "common.never"}}{{end}}
140 + </td>
141 + <td style="text-align:right;">
142 + <form method="post" action="/settings/tokens/{{.ID}}/revoke" style="display:inline;">
143 + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
144 + <button type="submit" class="btn btn-destructive btn-sm">
145 + {{icon "trash" "size-3.5"}}
146 + <span>{{t $.Lang "settings.token.revoke"}}</span>
147 + </button>
148 + </form>
149 + </td>
150 + </tr>
151 + {{end}}
152 + </tbody>
153 + </table>
154 + </div>
155 + {{else}}
156 + <div class="text-xs text-muted">{{t .Lang "settings.token.empty"}}</div>
157 + {{end}}
158 + </div>
159 + </section>
160 +
161 + <!-- Change Password -->
162 + <section class="card">
163 + <div class="card-header">
164 + <span class="font-semibold flex items-center gap-2">{{icon "lock" "size-4"}} {{t .Lang "auth.password"}}</span>
165 + </div>
166 + <form method="post" action="/settings/password" class="card-body flex flex-col gap-3">
167 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
168 + <div class="grid-2">
169 + <div>
170 + <label class="label" for="current_password">{{t .Lang "settings.password.current"}}</label>
171 + <input id="current_password" name="current_password" type="password" class="input" required>
172 + </div>
173 + <div>
174 + <label class="label" for="new_password">{{t .Lang "settings.password.new"}}</label>
175 + <input id="new_password" name="new_password" type="password" class="input" minlength="8" required>
176 + </div>
177 + </div>
178 + <div class="flex items-center justify-end">
179 + <button type="submit" class="btn btn-outline btn-sm">{{t .Lang "settings.password.update"}}</button>
180 + </div>
181 + </form>
182 + </section>
183 +</div>
184 +{{end}}
internal/web/views/partials/components.html
+160 -0
... ... @@ -0,0 +1,160 @@
1 +{{define "ui_button"}}
2 + <button
3 + type="{{if .Type}}{{.Type}}{{else}}button{{end}}"
4 + class="btn {{if .Variant}}btn-{{.Variant}}{{else}}btn-default{{end}} {{if .Size}}btn-{{.Size}}{{end}} {{.Class}}"
5 + {{if .ID}}id="{{.ID}}"{{end}}
6 + {{if .Disabled}}disabled{{end}}>
7 + {{if .Icon}}{{icon .Icon "size-4"}}{{end}}
8 + {{if .Label}}<span>{{.Label}}</span>{{end}}
9 + </button>
10 +{{end}}
11 +
12 +{{define "ui_badge"}}
13 + <span class="badge {{if .Variant}}badge-{{.Variant}}{{end}}">
14 + {{if .Icon}}{{icon .Icon "size-3.5"}}{{end}}
15 + <span>{{.Text}}</span>
16 + </span>
17 +{{end}}
18 +
19 +{{define "ui_kbd"}}
20 + <kbd class="kbd">{{.}}</kbd>
21 +{{end}}
22 +
23 +{{define "ui_avatar"}}
24 + {{if .Large}}{{avatar .Username .AvatarURL "avatar-lg"}}{{else}}{{avatar .Username .AvatarURL}}{{end}}
25 +{{end}}
26 +
27 +{{define "ui_empty_state"}}
28 + <div class="empty-state">
29 + {{if .Icon}}
30 + <div class="mb-2" style="color:hsl(var(--muted-foreground));">{{icon .Icon "size-6"}}</div>
31 + {{end}}
32 + <div class="font-semibold text-base" style="color:hsl(var(--foreground));">{{.Title}}</div>
33 + {{if .Subtitle}}<div class="text-sm text-muted mt-1">{{.Subtitle}}</div>{{end}}
34 + {{if .ActionHref}}
35 + <div class="mt-4">
36 + <a href="{{.ActionHref}}" class="btn btn-primary btn-sm">
37 + {{if .ActionIcon}}{{icon .ActionIcon "size-3.5"}}{{end}}
38 + <span>{{.ActionLabel}}</span>
39 + </a>
40 + </div>
41 + {{end}}
42 + </div>
43 +{{end}}
44 +
45 +{{define "repo_header"}}
46 + <div class="repo-banner mb-3">
47 + <div class="repo-banner-main">
48 + <a href="/{{.Repo.OwnerUsername}}" class="flex-shrink-0">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL}}</a>
49 + <div class="min-w-0">
50 + <div class="flex items-center gap-2 flex-wrap">
51 + <nav class="breadcrumb" aria-label="Repository breadcrumb">
52 + <a href="/{{.Repo.OwnerUsername}}" class="text-sm font-medium text-muted">{{.Repo.OwnerUsername}}</a>
53 + <span class="breadcrumb-sep">/</span>
54 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" class="font-semibold text-lg tracking-tight" style="color:hsl(var(--foreground));">{{.Repo.Name}}</a>
55 + </nav>
56 + {{if .Repo.IsPrivate}}
57 + <span class="badge badge-outline">{{icon "lock" "size-3.5"}} {{t .Lang "repo.private"}}</span>
58 + {{else}}
59 + <span class="badge badge-outline">{{icon "globe" "size-3.5"}} {{t .Lang "repo.public"}}</span>
60 + {{end}}
61 + </div>
62 + <div class="text-xs text-muted mt-1 flex items-center gap-2 flex-wrap">
63 + {{if .Repo.ForkedFromRepoID}}
64 + <span class="flex items-center gap-1.5">
65 + {{icon "git-branch" "size-3.5"}}
66 + {{t .Lang "fork.forked_from"}}
67 + <a class="font-medium" style="color:hsl(var(--foreground));" href="/{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}">{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}</a>
68 + </span>
69 + {{else if .Repo.Description}}
70 + <span class="truncate" style="max-width:36rem;">{{.Repo.Description}}</span>
71 + {{end}}
72 + </div>
73 + </div>
74 + </div>
75 +
76 + <div class="repo-banner-actions">
77 + {{if .CurrentUser}}
78 + {{if .IsRepoOwner}}
79 + <a class="btn btn-outline btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks">{{icon "git-branch" "size-3.5"}}<span>{{if gt .Repo.ForkCount 0}}{{.Repo.ForkCount}} {{end}}{{t .Lang "fork.forks_btn"}}</span></a>
80 + {{else if .ViewerFork}}
81 + <a class="btn btn-outline btn-sm" href="/{{.ViewerFork.OwnerUsername}}/{{.ViewerFork.Name}}" title="{{t .Lang "fork.your_fork_desc"}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.your_fork"}}</span></a>
82 + {{else}}
83 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/fork" style="margin:0;">
84 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
85 + <button class="btn btn-outline btn-sm" type="submit" title="{{t .Lang "fork.fork_btn_desc"}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.fork_btn"}}</span></button>
86 + </form>
87 + {{end}}
88 + {{else}}
89 + <a class="btn btn-outline btn-sm" href="/login?next=/{{.Repo.OwnerUsername}}/{{.Repo.Name}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.fork_btn"}}</span></a>
90 + {{end}}
91 + {{if .Repo.EnableIssues}}
92 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new">{{icon "issue" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "issues.new"}}</span></a>
93 + {{end}}
94 + {{if .Repo.EnablePulls}}
95 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new">{{icon "git-pull-request" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "pulls.new"}}</span></a>
96 + {{end}}
97 + </div>
98 + </div>
99 +
100 + <div class="repo-stats mb-3">
101 + {{if .Repo.EnableIssues}}
102 + <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues">{{icon "issue" "size-3.5"}}<span>{{t .Lang "repo.tab.issues"}}</span> <b>{{.Repo.OpenIssueCount}}</b></a>
103 + {{end}}
104 + {{if .Repo.EnablePulls}}
105 + <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls">{{icon "git-pull-request" "size-3.5"}}<span>{{t .Lang "repo.tab.pulls"}}</span> <b>{{.Repo.OpenPRCount}}</b></a>
106 + {{end}}
107 + {{if .Repo.EnableReleases}}
108 + <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases">{{icon "tag" "size-3.5"}}<span>{{t .Lang "repo.tab.releases"}}</span> <b>{{.Repo.ReleaseCount}}</b></a>
109 + {{end}}
110 + <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.forks"}}</span> <b>{{.Repo.ForkCount}}</b></a>
111 + <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches">{{icon "history" "size-3.5"}}<span>{{t .Lang "repo.tab.branches"}}</span></a>
112 + </div>
113 +
114 + <div class="tabs-nav" role="tablist">
115 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" class="tab-link {{if eq .ActiveTab "code"}}active{{end}}" role="tab">
116 + {{icon "code" "size-4"}}
117 + <span>{{t .Lang "repo.tab.code"}}</span>
118 + </a>
119 + {{if .Repo.EnableIssues}}
120 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues" class="tab-link {{if eq .ActiveTab "issues"}}active{{end}}" role="tab">
121 + {{icon "issue" "size-4"}}
122 + <span>{{t .Lang "repo.tab.issues"}}</span>
123 + {{if gt .Repo.OpenIssueCount 0}}
124 + <span class="badge" style="padding:0 0.35rem;font-size:0.68rem;">{{.Repo.OpenIssueCount}}</span>
125 + {{end}}
126 + </a>
127 + {{end}}
128 + {{if .Repo.EnablePulls}}
129 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls" class="tab-link {{if eq .ActiveTab "pulls"}}active{{end}}" role="tab">
130 + {{icon "git-pull-request" "size-4"}}
131 + <span>{{t .Lang "repo.tab.pulls"}}</span>
132 + {{if gt .Repo.OpenPRCount 0}}
133 + <span class="badge" style="padding:0 0.35rem;font-size:0.68rem;">{{.Repo.OpenPRCount}}</span>
134 + {{end}}
135 + </a>
136 + {{end}}
137 + {{if .Repo.EnableReleases}}
138 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases" class="tab-link {{if eq .ActiveTab "releases"}}active{{end}}" role="tab">
139 + {{icon "tag" "size-4"}}
140 + <span>{{t .Lang "repo.tab.releases"}}</span>
141 + {{if gt .Repo.ReleaseCount 0}}
142 + <span class="badge" style="padding:0 0.35rem;font-size:0.68rem;">{{.Repo.ReleaseCount}}</span>
143 + {{end}}
144 + </a>
145 + {{end}}
146 + {{if .Repo.EnableActivity}}
147 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/activity" class="tab-link {{if eq .ActiveTab "activity"}}active{{end}}" role="tab">
148 + {{icon "activity" "size-4"}}
149 + <span>{{t .Lang "repo.tab.activity"}}</span>
150 + </a>
151 + {{end}}
152 + {{if .CanWrite}}
153 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" class="tab-link {{if eq .ActiveTab "settings"}}active{{end}}" role="tab">
154 + {{icon "settings" "size-4"}}
155 + <span>{{t .Lang "repo.tab.settings"}}</span>
156 + </a>
157 + {{end}}
158 + </div>
159 +{{end}}
160 +
internal/web/views/views.go
+342 -0
... ... @@ -0,0 +1,342 @@
1 +package views
2 +
3 +import (
4 + "bytes"
5 + "embed"
6 + "fmt"
7 + "html"
8 + "html/template"
9 + "io"
10 + "strings"
11 + "time"
12 +
13 + "polar/internal/i18n"
14 + "polar/internal/web/static"
15 +
16 + "github.com/alecthomas/chroma/v2"
17 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
18 + "github.com/alecthomas/chroma/v2/lexers"
19 + "github.com/alecthomas/chroma/v2/styles"
20 + "github.com/microcosm-cc/bluemonday"
21 + "github.com/yuin/goldmark"
22 + "github.com/yuin/goldmark/extension"
23 + "github.com/yuin/goldmark/parser"
24 +)
25 +
26 +//go:embed layouts/*.html partials/*.html pages/*.html
27 +var templatesFS embed.FS
28 +
29 +type HighlightedLine struct {
30 + Num int
31 + HTML template.HTML
32 +}
33 +
34 +type Engine struct {
35 + pages map[string]*template.Template
36 +}
37 +
38 +func NewEngine() (*Engine, error) {
39 + funcMap := template.FuncMap{
40 + "icon": renderIcon,
41 + "sprite": func() template.HTML { return template.HTML(static.SpriteSVG) },
42 + "inlineCSS": func() template.CSS { return template.CSS(static.AppCSS) },
43 + "assetVer": static.AssetVersion,
44 + "t": i18n.T,
45 + "timeAgo": formatTimeAgo,
46 + "pluralT": pluralT,
47 + "activityBadge": activityBadge,
48 + "formatDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02 15:04 UTC") },
49 + "formatShortDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02") },
50 + "formatSize": formatBytes,
51 + "avatarInitial": avatarInitial,
52 + "avatar": renderAvatar,
53 + "renderMD": func(s string) template.HTML { return RenderMarkdown([]byte(s)) },
54 + "add": func(a, b int) int { return a + b },
55 + "sub": func(a, b int) int { return a - b },
56 + "dict": makeDict,
57 + "safeHTML": func(s string) template.HTML { return template.HTML(s) },
58 + "upper": strings.ToUpper,
59 + "lower": strings.ToLower,
60 + }
61 +
62 + entries, err := templatesFS.ReadDir("pages")
63 + if err != nil {
64 + return nil, fmt.Errorf("read pages dir: %w", err)
65 + }
66 +
67 + pages := make(map[string]*template.Template, len(entries))
68 + for _, entry := range entries {
69 + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".html") {
70 + continue
71 + }
72 + name := strings.TrimSuffix(entry.Name(), ".html")
73 + tmpl, err := template.New("base.html").Funcs(funcMap).ParseFS(
74 + templatesFS,
75 + "layouts/base.html",
76 + "partials/components.html",
77 + "pages/"+entry.Name(),
78 + )
79 + if err != nil {
80 + return nil, fmt.Errorf("parse template %s: %w", entry.Name(), err)
81 + }
82 + pages[name] = tmpl
83 + }
84 +
85 + return &Engine{pages: pages}, nil
86 +}
87 +
88 +func (e *Engine) Render(w io.Writer, page string, data any) error {
89 + tmpl, ok := e.pages[page]
90 + if !ok {
91 + return fmt.Errorf("template %q not found", page)
92 + }
93 + return tmpl.ExecuteTemplate(w, "base.html", data)
94 +}
95 +
96 +func renderIcon(name string, classes ...string) template.HTML {
97 + cls := "size-4"
98 + if len(classes) > 0 && strings.TrimSpace(classes[0]) != "" {
99 + cls = strings.Join(classes, " ")
100 + }
101 + safeName := html.EscapeString(strings.TrimSpace(name))
102 + safeCls := html.EscapeString(cls)
103 + return template.HTML(fmt.Sprintf(
104 + `<svg class="icon %s" aria-hidden="true"><use href="#hi-%s"></use></svg>`,
105 + safeCls, safeName,
106 + ))
107 +}
108 +
109 +func makeDict(values ...any) (map[string]any, error) {
110 + if len(values)%2 != 0 {
111 + return nil, fmt.Errorf("dict requires an even number of arguments")
112 + }
113 + m := make(map[string]any, len(values)/2)
114 + for i := 0; i < len(values); i += 2 {
115 + k, ok := values[i].(string)
116 + if !ok {
117 + return nil, fmt.Errorf("dict keys must be strings")
118 + }
119 + m[k] = values[i+1]
120 + }
121 + return m, nil
122 +}
123 +
124 +func avatarInitial(username string) string {
125 + username = strings.TrimSpace(username)
126 + if username == "" {
127 + return "?"
128 + }
129 + runes := []rune(username)
130 + return strings.ToUpper(string(runes[0]))
131 +}
132 +
133 +func renderAvatar(args ...any) template.HTML {
134 + if len(args) == 0 {
135 + return template.HTML(`<span class="avatar">?</span>`)
136 + }
137 + var username, avatarURL, extraClass string
138 + if args[0] != nil {
139 + username = fmt.Sprint(args[0])
140 + }
141 + if len(args) > 1 && args[1] != nil {
142 + avatarURL = fmt.Sprint(args[1])
143 + }
144 + if len(args) > 2 {
145 + var classes []string
146 + for _, c := range args[2:] {
147 + if c != nil && fmt.Sprint(c) != "" {
148 + classes = append(classes, fmt.Sprint(c))
149 + }
150 + }
151 + extraClass = strings.Join(classes, " ")
152 + }
153 + if strings.HasPrefix(avatarURL, "avatar-") && extraClass == "" {
154 + extraClass = avatarURL
155 + avatarURL = ""
156 + }
157 +
158 + cls := "avatar"
159 + if extraClass != "" {
160 + cls = "avatar " + extraClass
161 + }
162 + safeCls := html.EscapeString(cls)
163 + safeUser := html.EscapeString(strings.TrimSpace(username))
164 + avatarURL = strings.TrimSpace(avatarURL)
165 + if avatarURL != "" && (strings.HasPrefix(avatarURL, "data:image/") || strings.HasPrefix(avatarURL, "http://") || strings.HasPrefix(avatarURL, "https://") || strings.HasPrefix(avatarURL, "/")) {
166 + safeURL := html.EscapeString(avatarURL)
167 + return template.HTML(fmt.Sprintf(
168 + `<span class="%s" title="%s"><img src="%s" alt="%s" class="avatar-img" loading="lazy"></span>`,
169 + safeCls, safeUser, safeURL, safeUser,
170 + ))
171 + }
172 + return template.HTML(fmt.Sprintf(
173 + `<span class="%s" title="%s">%s</span>`,
174 + safeCls, safeUser, html.EscapeString(avatarInitial(username)),
175 + ))
176 +}
177 +
178 +func formatBytes(b int64) string {
179 + if b < 1024 {
180 + return fmt.Sprintf("%d B", b)
181 + }
182 + kb := float64(b) / 1024.0
183 + if kb < 1024 {
184 + return fmt.Sprintf("%.1f KB", kb)
185 + }
186 + mb := kb / 1024.0
187 + return fmt.Sprintf("%.1f MB", mb)
188 +}
189 +
190 +func formatTimeAgo(tm time.Time, lang i18n.Lang) string {
191 + if tm.IsZero() {
192 + return "—"
193 + }
194 + d := time.Since(tm)
195 + isEN := i18n.Normalize(string(lang)) == i18n.EN
196 + switch {
197 + case d < time.Minute:
198 + if isEN {
199 + return "just now"
200 + }
201 + return "только что"
202 + case d < time.Hour:
203 + m := int(d.Minutes())
204 + if isEN {
205 + return fmt.Sprintf("%dm ago", m)
206 + }
207 + return fmt.Sprintf("%d мин. назад", m)
208 + case d < 24*time.Hour:
209 + h := int(d.Hours())
210 + if isEN {
211 + return fmt.Sprintf("%dh ago", h)
212 + }
213 + return fmt.Sprintf("%d ч. назад", h)
214 + case d < 30*24*time.Hour:
215 + days := int(d.Hours() / 24)
216 + if isEN {
217 + return fmt.Sprintf("%dd ago", days)
218 + }
219 + return fmt.Sprintf("%d дн. назад", days)
220 + default:
221 + return tm.UTC().Format("2006-01-02")
222 + }
223 +}
224 +
225 +// pluralT returns the count-dependent form of key ("<key>_one", "<key>_few",
226 +// "<key>_many") for the given language, falling back to the base key.
227 +func pluralT(lang i18n.Lang, n int, key string) string {
228 + suffix := "_many"
229 + if i18n.Normalize(string(lang)) == i18n.EN {
230 + if n == 1 {
231 + suffix = "_one"
232 + }
233 + } else {
234 + mod10, mod100 := n%10, n%100
235 + switch {
236 + case mod10 == 1 && mod100 != 11:
237 + suffix = "_one"
238 + case mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14):
239 + suffix = "_few"
240 + }
241 + }
242 + k := key + suffix
243 + if v := i18n.T(lang, k); v != k {
244 + return v
245 + }
246 + return i18n.T(lang, key)
247 +}
248 +
249 +// activityBadge localizes the status badge of an activity feed entry.
250 +func activityBadge(lang i18n.Lang, itemType, badge string) string {
251 + switch itemType {
252 + case "commit":
253 + return i18n.T(lang, "activity.badge_commit")
254 + case "release":
255 + return i18n.T(lang, "activity.badge_release")
256 + case "pull":
257 + return i18n.T(lang, "pulls.status."+badge)
258 + case "issue":
259 + return i18n.T(lang, "issue.status."+badge)
260 + default:
261 + return badge
262 + }
263 +}
264 +
265 +var mdParser = goldmark.New(
266 + goldmark.WithExtensions(
267 + extension.GFM,
268 + ),
269 + goldmark.WithParserOptions(
270 + parser.WithAutoHeadingID(),
271 + ),
272 +)
273 +
274 +func RenderMarkdown(src []byte) template.HTML {
275 + var buf bytes.Buffer
276 + if err := mdParser.Convert(src, &buf); err != nil {
277 + return template.HTML("<pre>" + html.EscapeString(string(src)) + "</pre>")
278 + }
279 + policy := bluemonday.UGCPolicy()
280 + policy.AllowAttrs("class").OnElements("code", "pre", "span")
281 + policy.AllowAttrs("type", "checked", "disabled").OnElements("input")
282 + sanitized := policy.SanitizeBytes(buf.Bytes())
283 + return template.HTML(sanitized)
284 +}
285 +
286 +func HighlightCode(filename, code string) []HighlightedLine {
287 + code = strings.ReplaceAll(code, "\r\n", "\n")
288 + lexer := lexers.Match(filename)
289 + if lexer == nil {
290 + lexer = lexers.Fallback
291 + }
292 + lexer = chroma.Coalesce(lexer)
293 +
294 + formatter := chromahtml.New(
295 + chromahtml.WithClasses(true),
296 + chromahtml.PreventSurroundingPre(true),
297 + )
298 + style := styles.Get("monokai")
299 + if style == nil {
300 + style = styles.Fallback
301 + }
302 +
303 + rawLines := strings.Split(code, "\n")
304 + if len(rawLines) > 0 && rawLines[len(rawLines)-1] == "" {
305 + rawLines = rawLines[:len(rawLines)-1]
306 + }
307 +
308 + result := make([]HighlightedLine, 0, len(rawLines))
309 + iterator, err := lexer.Tokenise(nil, code)
310 + if err != nil {
311 + for idx, l := range rawLines {
312 + result = append(result, HighlightedLine{
313 + Num: idx + 1,
314 + HTML: template.HTML(html.EscapeString(l)),
315 + })
316 + }
317 + return result
318 + }
319 +
320 + var buf bytes.Buffer
321 + if err := formatter.Format(&buf, style, iterator); err != nil {
322 + for idx, l := range rawLines {
323 + result = append(result, HighlightedLine{
324 + Num: idx + 1,
325 + HTML: template.HTML(html.EscapeString(l)),
326 + })
327 + }
328 + return result
329 + }
330 +
331 + htmlLines := strings.Split(buf.String(), "\n")
332 + if len(htmlLines) > 0 && htmlLines[len(htmlLines)-1] == "" {
333 + htmlLines = htmlLines[:len(htmlLines)-1]
334 + }
335 + for idx, hl := range htmlLines {
336 + result = append(result, HighlightedLine{
337 + Num: idx + 1,
338 + HTML: template.HTML(hl),
339 + })
340 + }
341 + return result
342 +}
polar.example.toml
+14 -0
... ... @@ -0,0 +1,14 @@
1 +[server]
2 +host = "0.0.0.0"
3 +port = 3000
4 +base_url = "http://localhost:3000"
5 +
6 +[storage]
7 +data_dir = "./data"
8 +
9 +[auth]
10 +registration_enabled = true
11 +
12 +[landing]
13 +enabled = true
14 +default_lang = "en"
scripts/git-credential-polar.ps1
+155 -0
... ... @@ -0,0 +1,155 @@
1 +# Git credential helper for Polar (RFC 8628 device flow).
2 +#
3 +# Usage:
4 +# git config --global credential.helper "!powershell -NoProfile -ExecutionPolicy Bypass -File C:/path/to/polar/scripts/git-credential-polar.ps1"
5 +#
6 +# On "get" the helper reuses a cached token for the server, otherwise it starts
7 +# a device authorization flow, opens the verification page in the browser and
8 +# waits until the request is approved. Tokens are cached in
9 +# ~/.polar/credentials.json.
10 +param([Parameter(Position = 0)][string]$Operation = 'get')
11 +
12 +$ErrorActionPreference = 'Stop'
13 +
14 +function Read-CredentialInput {
15 + $values = @{}
16 + while ($null -ne ($line = [Console]::In.ReadLine())) {
17 + if ($line -match '^([^=]+)=(.*)$') {
18 + $values[$Matches[1]] = $Matches[2]
19 + }
20 + }
21 + return $values
22 +}
23 +
24 +function Get-ServerBase([hashtable]$values) {
25 + if ($env:POLAR_URL) { return $env:POLAR_URL.TrimEnd('/') }
26 + $hostName = $values['host']
27 + if ($hostName) {
28 + $scheme = $values['protocol']
29 + if (-not $scheme) { $scheme = 'https' }
30 + return ('{0}://{1}' -f $scheme, $hostName).TrimEnd('/')
31 + }
32 + return 'http://localhost:3100'
33 +}
34 +
35 +function Get-CachePath {
36 + $dir = Join-Path $HOME '.polar'
37 + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
38 + return (Join-Path $dir 'credentials.json')
39 +}
40 +
41 +function Read-Cache {
42 + $path = Get-CachePath
43 + if (-not (Test-Path $path)) { return @{} }
44 + try {
45 + $raw = Get-Content -Raw -Path $path | ConvertFrom-Json
46 + $table = @{}
47 + foreach ($prop in $raw.PSObject.Properties) {
48 + $table[$prop.Name] = @{ username = $prop.Value.username; token = $prop.Value.token }
49 + }
50 + return $table
51 + } catch {
52 + return @{}
53 + }
54 +}
55 +
56 +function Write-Cache([hashtable]$cache) {
57 + $obj = [ordered]@{}
58 + foreach ($key in $cache.Keys) { $obj[$key] = @{ username = $cache[$key].username; token = $cache[$key].token } }
59 + ($obj | ConvertTo-Json -Depth 4) | Set-Content -Path (Get-CachePath) -Encoding UTF8
60 +}
61 +
62 +function Write-Credential([string]$user, [string]$token) {
63 + [Console]::Out.WriteLine('username=' + $user)
64 + [Console]::Out.WriteLine('password=' + $token)
65 +}
66 +
67 +function Read-ErrorBody($exception) {
68 + try {
69 + $stream = $exception.Response.GetResponseStream()
70 + $reader = New-Object System.IO.StreamReader($stream)
71 + return $reader.ReadToEnd()
72 + } catch {
73 + return ''
74 + }
75 +}
76 +
77 +$values = Read-CredentialInput
78 +$base = Get-ServerBase $values
79 +$cache = Read-Cache
80 +
81 +switch ($Operation) {
82 + 'get' {
83 + if ($cache.ContainsKey($base)) {
84 + Write-Credential $cache[$base].username $cache[$base].token
85 + exit 0
86 + }
87 +
88 + $clientId = 'git-credential-polar'
89 + $code = $null
90 + try {
91 + $code = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/code" -Body @{ client_id = $clientId }
92 + } catch {
93 + [Console]::Error.WriteLine("polar: could not reach $base/api/v1/device/code: $($_.Exception.Message)")
94 + exit 1
95 + }
96 +
97 + [Console]::Error.WriteLine('')
98 + [Console]::Error.WriteLine("polar: open $($code.verification_uri) and enter code $($code.user_code)")
99 + [Console]::Error.WriteLine('polar: waiting for approval...')
100 + try { Start-Process $code.verification_uri_complete | Out-Null } catch { }
101 +
102 + $interval = 5
103 + if ($code.interval) { $interval = [int]$code.interval }
104 + if ($interval -lt 3) { $interval = 3 }
105 + $expiresIn = 900
106 + if ($code.expires_in) { $expiresIn = [int]$code.expires_in }
107 + $deadline = (Get-Date).AddSeconds($expiresIn)
108 +
109 + while ((Get-Date) -lt $deadline) {
110 + Start-Sleep -Seconds $interval
111 + try {
112 + $resp = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/token" -Body @{ device_code = $code.device_code; client_id = $clientId }
113 + if ($resp.access_token) {
114 + $username = $resp.username
115 + if (-not $username) { $username = 'x-access-token' }
116 + $cache[$base] = @{ username = $username; token = $resp.access_token }
117 + Write-Cache $cache
118 + Write-Credential $username $resp.access_token
119 + exit 0
120 + }
121 + } catch {
122 + $status = 0
123 + try { $status = [int]$_.Exception.Response.StatusCode } catch { }
124 + if ($status -eq 429) { continue }
125 + $body = Read-ErrorBody $_.Exception
126 + if ($body -match 'access_denied') {
127 + [Console]::Error.WriteLine('polar: access denied')
128 + exit 1
129 + }
130 + if ($body -match 'expired_token') {
131 + [Console]::Error.WriteLine('polar: device code expired, run the command again')
132 + exit 1
133 + }
134 + [Console]::Error.WriteLine("polar: authorization failed: $body")
135 + exit 1
136 + }
137 + }
138 + [Console]::Error.WriteLine('polar: timed out waiting for approval')
139 + exit 1
140 + }
141 + 'store' {
142 + # The helper keeps its own cache; nothing to do.
143 + exit 0
144 + }
145 + 'erase' {
146 + if ($cache.ContainsKey($base)) {
147 + $cache.Remove($base)
148 + Write-Cache $cache
149 + }
150 + exit 0
151 + }
152 + default {
153 + exit 0
154 + }
155 +}
scripts/git-credential-polar.sh
+110 -0
... ... @@ -0,0 +1,110 @@
1 +#!/bin/sh
2 +# Git credential helper for Polar (RFC 8628 device flow).
3 +#
4 +# Usage:
5 +# git config --global credential.helper '!sh /path/to/polar/scripts/git-credential-polar.sh'
6 +#
7 +# On "get" the helper reuses a cached token for the server, otherwise it starts
8 +# a device authorization flow, opens the verification page in the browser and
9 +# waits until the request is approved. Tokens are cached in
10 +# ~/.polar/credentials.json.
11 +
12 +op="${1:-get}"
13 +base="${POLAR_URL:-}"
14 +cache_dir="${HOME}/.polar"
15 +cache_file="${cache_dir}/credentials.json"
16 +client_id="git-credential-polar"
17 +
18 +input="$(cat)"
19 +if [ -z "$base" ]; then
20 + host="$(printf '%s\n' "$input" | sed -n 's/^host=//p' | head -n1)"
21 + proto="$(printf '%s\n' "$input" | sed -n 's/^protocol=//p' | head -n1)"
22 + [ -z "$proto" ] && proto="https"
23 + if [ -n "$host" ]; then
24 + base="${proto}://${host}"
25 + else
26 + base="http://localhost:3100"
27 + fi
28 +fi
29 +base="${base%/}"
30 +
31 +json_field() {
32 + # $1 = json, $2 = field
33 + printf '%s' "$1" | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1
34 +}
35 +
36 +case "$op" in
37 + get)
38 + if [ -f "$cache_file" ]; then
39 + cached="$(grep -F "\"${base}\":" "$cache_file" | head -n1)"
40 + if [ -n "$cached" ]; then
41 + token="$(json_field "$cached" token)"
42 + username="$(json_field "$cached" username)"
43 + if [ -n "$token" ] && [ -n "$username" ]; then
44 + printf 'username=%s\npassword=%s\n' "$username" "$token"
45 + exit 0
46 + fi
47 + fi
48 + fi
49 +
50 + code="$(curl -fsS -X POST "${base}/api/v1/device/code" -d "client_id=${client_id}" 2>/dev/null)" || {
51 + echo "polar: could not reach ${base}/api/v1/device/code" >&2
52 + exit 1
53 + }
54 + device_code="$(json_field "$code" device_code)"
55 + user_code="$(json_field "$code" user_code)"
56 + verify_uri="$(json_field "$code" verification_uri)"
57 + verify_full="$(json_field "$code" verification_uri_complete)"
58 + interval="$(json_field "$code" interval)"
59 + [ -z "$interval" ] && interval=5
60 + [ "$interval" -lt 3 ] && interval=3
61 +
62 + echo "" >&2
63 + echo "polar: open ${verify_uri} and enter code ${user_code}" >&2
64 + echo "polar: waiting for approval..." >&2
65 + if command -v xdg-open >/dev/null 2>&1; then
66 + xdg-open "$verify_full" >/dev/null 2>&1 &
67 + elif command -v open >/dev/null 2>&1; then
68 + open "$verify_full" >/dev/null 2>&1 &
69 + fi
70 +
71 + elapsed=0
72 + while [ "$elapsed" -lt 900 ]; do
73 + sleep "$interval"
74 + elapsed=$((elapsed + interval))
75 + resp="$(curl -sS -X POST "${base}/api/v1/device/token" -d "device_code=${device_code}&client_id=${client_id}" 2>/dev/null)"
76 + token="$(json_field "$resp" access_token)"
77 + if [ -n "$token" ]; then
78 + username="$(json_field "$resp" username)"
79 + [ -z "$username" ] && username="x-access-token"
80 + mkdir -p "$cache_dir"
81 + printf '{"%s":{"username":"%s","token":"%s"}}\n' "$base" "$username" "$token" >>"$cache_file"
82 + printf 'username=%s\npassword=%s\n' "$username" "$token"
83 + exit 0
84 + fi
85 + case "$resp" in
86 + *authorization_pending*) continue ;;
87 + *access_denied*)
88 + echo "polar: access denied" >&2
89 + exit 1
90 + ;;
91 + *expired_token*)
92 + echo "polar: device code expired, run the command again" >&2
93 + exit 1
94 + ;;
95 + *)
96 + echo "polar: authorization failed: $resp" >&2
97 + exit 1
98 + ;;
99 + esac
100 + done
101 + echo "polar: timed out waiting for approval" >&2
102 + exit 1
103 + ;;
104 + store|erase)
105 + exit 0
106 + ;;
107 + *)
108 + exit 0
109 + ;;
110 +esac