feat: profile media, account deletion, registration modes and admin controls
07bc77b
Файлы на момент коммита
R
rin
·
2026-10-17 06:16 UTC
25 файлов изменено
+1616
-107
README.md
+10
-2
| ... | ... | @@ -14,6 +14,8 @@ Polar is a small, fast alternative to heavyweight Git servers: push over HTTP, b |
| 14 | 14 | - **Command palette** — press `Ctrl+K` / `Cmd+K` for quick navigation and actions. |
| 15 | 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 | 16 | - **Bilingual interface** — Russian and English, with a configurable default language. |
| 17 | +- **Profiles and repository identity** — avatars, profile READMEs, and full-width background images with a soft vignette in both light and dark themes. Repositories can use their own avatar. | |
| 18 | +- **Administration** — public, confirm-only, or closed registration; an approval queue; role management; and permanent deletion of accounts and repositories (including all on-disk data). | |
| 17 | 19 | - **Practical security** — Argon2id password hashing, CSRF protection, rate-limited login and registration, session cookies, and revocable access tokens. |
| 18 | 20 | |
| 19 | 21 | ## Quick Start |
| ... | ... | @@ -27,7 +29,7 @@ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o polar ./cmd/polar |
| 27 | 29 | ./polar |
| 28 | 30 | ``` |
| 29 | 31 | |
| 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. | |
| 32 | +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, choose **Public Registration**, **Confirm-only Registration**, or **No Registration** in the admin panel. Confirm-only mode keeps new accounts pending until an administrator approves them. | |
| 31 | 33 | |
| 32 | 34 | ### Docker |
| 33 | 35 | |
| ... | ... | @@ -90,7 +92,7 @@ default_lang = "en" # "en" or "ru" |
| 90 | 92 | | `POLAR_SERVER_PORT` | HTTP listen port | `3000` | |
| 91 | 93 | | `POLAR_SERVER_BASE_URL` | Public URL used for clone links | `http://localhost:3000` | |
| 92 | 94 | | `POLAR_STORAGE_DATA_DIR` | Directory for the database and bare repositories | `./data` | |
| 93 | -| `POLAR_AUTH_REGISTRATION_ENABLED` | Allow public registration | `true` | | |
| 95 | +| `POLAR_AUTH_REGISTRATION_ENABLED` | Initial registration policy before the admin panel setting is stored (`false` means closed) | `true` | | |
| 94 | 96 | | `POLAR_LANDING_ENABLED` | Serve the landing page at `/` | `true` | |
| 95 | 97 | | `POLAR_LANDING_DEFAULT_LANG` | Default UI language (`ru` or `en`) | `ru` | |
| 96 | 98 | | `POLAR_CONFIG` | Path to the configuration file | `polar.toml` if present | |
| ... | ... | @@ -146,6 +148,12 @@ Device codes expire after 15 minutes, are single-use, and the issued token can b |
| 146 | 148 | |
| 147 | 149 | **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 | 150 | |
| 151 | +## Accounts and Administration | |
| 152 | + | |
| 153 | +Administrators can switch registration between **Public Registration**, **Confirm-only Registration**, and **No Registration** at any time. Confirm-only accounts cannot sign in until approved from the admin panel. | |
| 154 | + | |
| 155 | +Deleting an account from **Settings → Delete account** or from the admin panel permanently removes the user, all repositories they own, uploaded release assets, sessions, and repository files on disk. The last administrator account is protected from deletion. Repository deletion through the admin panel follows the same irreversible path. | |
| 156 | + | |
| 149 | 157 | ## Themes |
| 150 | 158 | |
| 151 | 159 | Polar ships exactly three themes, selectable from the theme switcher (or the command palette): |
internal/db/db.go
+54
-1
| ... | ... | @@ -78,6 +78,17 @@ func Open(ctx context.Context, dbPath string, defaultRegEnabled bool) (*DB, erro |
| 78 | 78 | } |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | + if _, err := d.GetSetting(pingCtx, "registration_mode"); errors.Is(err, sql.ErrNoRows) { | |
| 82 | + mode := RegistrationModeClosed | |
| 83 | + if d.IsRegistrationEnabled(pingCtx, defaultRegEnabled) { | |
| 84 | + mode = RegistrationModePublic | |
| 85 | + } | |
| 86 | + if err := d.SetSetting(pingCtx, "registration_mode", mode); err != nil { | |
| 87 | + _ = sqlDB.Close() | |
| 88 | + return nil, fmt.Errorf("seed registration_mode setting: %w", err) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 81 | 92 | return d, nil |
| 82 | 93 | } |
| 83 | 94 | |
| ... | ... | @@ -181,12 +192,54 @@ func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error |
| 181 | 192 | return d.SetSetting(ctx, key, strconv.FormatBool(enabled)) |
| 182 | 193 | } |
| 183 | 194 | |
| 195 | +const ( | |
| 196 | + RegistrationModePublic = "public" | |
| 197 | + RegistrationModeConfirm = "confirm" | |
| 198 | + RegistrationModeClosed = "closed" | |
| 199 | +) | |
| 200 | + | |
| 201 | +func ValidRegistrationMode(mode string) bool { | |
| 202 | + switch mode { | |
| 203 | + case RegistrationModePublic, RegistrationModeConfirm, RegistrationModeClosed: | |
| 204 | + return true | |
| 205 | + default: | |
| 206 | + return false | |
| 207 | + } | |
| 208 | +} | |
| 209 | + | |
| 210 | +// GetRegistrationMode returns the current registration policy. Older databases | |
| 211 | +// only have the boolean registration_enabled setting, so fall back to it. | |
| 212 | +func (d *DB) GetRegistrationMode(ctx context.Context, fallback bool) string { | |
| 213 | + mode, err := d.GetSetting(ctx, "registration_mode") | |
| 214 | + if err == nil && ValidRegistrationMode(mode) { | |
| 215 | + return mode | |
| 216 | + } | |
| 217 | + if d.IsRegistrationEnabled(ctx, fallback) { | |
| 218 | + return RegistrationModePublic | |
| 219 | + } | |
| 220 | + return RegistrationModeClosed | |
| 221 | +} | |
| 222 | + | |
| 223 | +func (d *DB) SetRegistrationMode(ctx context.Context, mode string) error { | |
| 224 | + if !ValidRegistrationMode(mode) { | |
| 225 | + return fmt.Errorf("invalid registration mode %q", mode) | |
| 226 | + } | |
| 227 | + if err := d.SetSetting(ctx, "registration_mode", mode); err != nil { | |
| 228 | + return err | |
| 229 | + } | |
| 230 | + return d.SetBoolSetting(ctx, "registration_enabled", mode != RegistrationModeClosed) | |
| 231 | +} | |
| 232 | + | |
| 184 | 233 | func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool { |
| 185 | 234 | return d.GetBoolSetting(ctx, "registration_enabled", fallback) |
| 186 | 235 | } |
| 187 | 236 | |
| 188 | 237 | func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error { |
| 189 | - return d.SetBoolSetting(ctx, "registration_enabled", enabled) | |
| 238 | + mode := RegistrationModeClosed | |
| 239 | + if enabled { | |
| 240 | + mode = RegistrationModePublic | |
| 241 | + } | |
| 242 | + return d.SetRegistrationMode(ctx, mode) | |
| 190 | 243 | } |
| 191 | 244 | |
| 192 | 245 | func (d *DB) IsExploreEnabled(ctx context.Context) bool { |
internal/db/migrations/006_media_and_registration.sql
+9
-0
| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | +ALTER TABLE users ADD COLUMN background_url TEXT NOT NULL DEFAULT ''; | |
| 2 | +ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1; | |
| 3 | +ALTER TABLE repositories ADD COLUMN avatar_url TEXT NOT NULL DEFAULT ''; | |
| 4 | + | |
| 5 | +INSERT INTO settings (key, value) | |
| 6 | +SELECT 'registration_mode', | |
| 7 | + CASE WHEN LOWER(value) IN ('1', 'true', 'on') THEN 'public' ELSE 'closed' END | |
| 8 | +FROM settings | |
| 9 | +WHERE key = 'registration_enabled'; |
internal/i18n/i18n.go
+124
-2
| ... | ... | @@ -533,7 +533,68 @@ var translations = map[Lang]map[string]string{ |
| 533 | 533 | "repo.updated": "Обновлён", |
| 534 | 534 | "repo.view_file": "Открыть", |
| 535 | 535 | "err.collab_forbidden": "Управлять коллабораторами может только владелец или администратор.", |
| 536 | - "err.pr_head_repo_invalid": "Выбран недопустимый репозиторий-источник."}, | |
| 536 | + "err.pr_head_repo_invalid": "Выбран недопустимый репозиторий-источник.", | |
| 537 | + | |
| 538 | + // Media, account deletion & administration | |
| 539 | + "auth.pending_approval": "Аккаунт ожидает подтверждения администратора.", | |
| 540 | + "auth.register.pending_notice": "После регистрации аккаунт должен подтвердить администратор.", | |
| 541 | + "settings.background": "Фон профиля", | |
| 542 | + "settings.background_upload": "Выбрать фон", | |
| 543 | + "settings.background_remove": "Убрать фон", | |
| 544 | + "settings.background_hint": "PNG, JPG, WebP или GIF. Изображение мягко растворяется по краям.", | |
| 545 | + "settings.delete_account": "Удаление аккаунта", | |
| 546 | + "settings.delete_account_desc": "Аккаунт и все его репозитории будут удалены без возможности восстановления.", | |
| 547 | + "settings.delete_account_confirm": "Введите имя пользователя", | |
| 548 | + "settings.delete_password": "Текущий пароль", | |
| 549 | + "settings.delete_account_button": "Удалить аккаунт навсегда", | |
| 550 | + "settings.delete_mismatch": "Имя пользователя не совпадает.", | |
| 551 | + "settings.delete_last_admin": "Нельзя удалить последнего администратора.", | |
| 552 | + "repo.avatar": "Аватар репозитория", | |
| 553 | + "repo.avatar_upload": "Выбрать изображение", | |
| 554 | + "repo.avatar_remove": "Удалить аватар", | |
| 555 | + "repo.avatar_hint": "Квадратное изображение до 2 МБ. Без аватара используется изображение владельца.", | |
| 556 | + "admin.registration.public": "Публичная регистрация", | |
| 557 | + "admin.registration.confirm": "Регистрация с подтверждением", | |
| 558 | + "admin.registration.closed": "Без регистрации", | |
| 559 | + "admin.registration.public_desc": "Аккаунт активируется сразу.", | |
| 560 | + "admin.registration.confirm_desc": "Аккаунт активируется после одобрения администратором.", | |
| 561 | + "admin.registration.closed_desc": "Самостоятельная регистрация новых аккаунтов закрыта.", | |
| 562 | + "admin.registration.mode": "Новые аккаунты", | |
| 563 | + "admin.repos": "Репозитории", | |
| 564 | + "admin.col.status": "Статус", | |
| 565 | + "admin.col.actions": "Действия", | |
| 566 | + "admin.pending": "Ожидает", | |
| 567 | + "admin.approved": "Подтверждён", | |
| 568 | + "admin.approve": "Подтвердить", | |
| 569 | + "admin.make_admin": "Сделать админом", | |
| 570 | + "admin.remove_admin": "Снять админа", | |
| 571 | + "admin.delete_user": "Удалить", | |
| 572 | + "admin.delete_repo": "Удалить", | |
| 573 | + "admin.repo_visibility": "Доступ", | |
| 574 | + "admin.delete_user_title": "Удалить аккаунт", | |
| 575 | + "admin.delete_user_desc": "Все репозитории и данные пользователя будут удалены без возможности восстановления.", | |
| 576 | + "admin.delete_repo_title": "Удалить репозиторий", | |
| 577 | + "admin.delete_repo_desc": "Git-данные, релизы и файлы будут удалены без возможности восстановления.", | |
| 578 | + "admin.confirm_user": "Введите имя пользователя", | |
| 579 | + "admin.confirm_repo": "Введите название репозитория", | |
| 580 | + "flash.image_invalid": "Изображение слишком большое или имеет неподдерживаемый формат.", | |
| 581 | + "flash.image_save_failed": "Не удалось сохранить изображение. Попробуйте ещё раз.", | |
| 582 | + "flash.account_deleted": "Аккаунт и все его репозитории удалены.", | |
| 583 | + "flash.account_delete_failed": "Не удалось удалить аккаунт.", | |
| 584 | + "flash.admin_user_approved": "Аккаунт @%s подтверждён.", | |
| 585 | + "flash.admin_user_missing": "Пользователь не найден.", | |
| 586 | + "flash.admin_user_update_failed": "Не удалось обновить пользователя.", | |
| 587 | + "flash.admin_self_role": "Нельзя менять собственную роль через админку.", | |
| 588 | + "flash.admin_last_admin": "Нельзя удалить или понизить последнего администратора.", | |
| 589 | + "flash.admin_role_updated": "Роль @%s обновлена.", | |
| 590 | + "flash.admin_delete_self": "Удалить собственный аккаунт можно только в настройках профиля.", | |
| 591 | + "flash.admin_delete_mismatch": "Подтверждение не совпадает.", | |
| 592 | + "flash.admin_user_delete_failed": "Не удалось удалить пользователя.", | |
| 593 | + "flash.admin_user_deleted": "Пользователь @%s и его репозитории удалены.", | |
| 594 | + "flash.admin_repo_missing": "Репозиторий не найден.", | |
| 595 | + "flash.admin_repo_delete_failed": "Не удалось удалить репозиторий.", | |
| 596 | + "flash.admin_repo_deleted": "Репозиторий %s/%s удалён.", | |
| 597 | + "flash.admin_registration_invalid": "Некорректный режим регистрации."}, | |
| 537 | 598 | EN: { |
| 538 | 599 | // Navigation & Common |
| 539 | 600 | "app.tagline": "Lightweight self-hosted Git hosting", |
| ... | ... | @@ -1051,7 +1112,68 @@ var translations = map[Lang]map[string]string{ |
| 1051 | 1112 | "repo.updated": "Updated", |
| 1052 | 1113 | "repo.view_file": "Open", |
| 1053 | 1114 | "err.collab_forbidden": "Only the owner or an administrator can manage collaborators.", |
| 1054 | - "err.pr_head_repo_invalid": "The selected source repository is not allowed."}, | |
| 1115 | + "err.pr_head_repo_invalid": "The selected source repository is not allowed.", | |
| 1116 | + | |
| 1117 | + // Media, account deletion & administration | |
| 1118 | + "auth.pending_approval": "This account is waiting for administrator approval.", | |
| 1119 | + "auth.register.pending_notice": "An administrator must approve the account after sign-up.", | |
| 1120 | + "settings.background": "Profile background", | |
| 1121 | + "settings.background_upload": "Choose background", | |
| 1122 | + "settings.background_remove": "Remove background", | |
| 1123 | + "settings.background_hint": "PNG, JPG, WebP, or GIF. The image fades gently at the edges.", | |
| 1124 | + "settings.delete_account": "Delete account", | |
| 1125 | + "settings.delete_account_desc": "The account and all of its repositories will be permanently deleted.", | |
| 1126 | + "settings.delete_account_confirm": "Type your username to confirm", | |
| 1127 | + "settings.delete_password": "Current password", | |
| 1128 | + "settings.delete_account_button": "Delete account permanently", | |
| 1129 | + "settings.delete_mismatch": "The username does not match.", | |
| 1130 | + "settings.delete_last_admin": "The last administrator account cannot be deleted.", | |
| 1131 | + "repo.avatar": "Repository avatar", | |
| 1132 | + "repo.avatar_upload": "Choose image", | |
| 1133 | + "repo.avatar_remove": "Remove avatar", | |
| 1134 | + "repo.avatar_hint": "A square image up to 2 MB. The owner avatar is used when empty.", | |
| 1135 | + "admin.registration.public": "Public Registration", | |
| 1136 | + "admin.registration.confirm": "Confirm-only Registration", | |
| 1137 | + "admin.registration.closed": "No Registration", | |
| 1138 | + "admin.registration.public_desc": "Accounts become active immediately.", | |
| 1139 | + "admin.registration.confirm_desc": "Accounts become active after administrator approval.", | |
| 1140 | + "admin.registration.closed_desc": "New accounts cannot be created by visitors.", | |
| 1141 | + "admin.registration.mode": "New accounts", | |
| 1142 | + "admin.repos": "Repositories", | |
| 1143 | + "admin.col.status": "Status", | |
| 1144 | + "admin.col.actions": "Actions", | |
| 1145 | + "admin.pending": "Pending", | |
| 1146 | + "admin.approved": "Approved", | |
| 1147 | + "admin.approve": "Approve", | |
| 1148 | + "admin.make_admin": "Make admin", | |
| 1149 | + "admin.remove_admin": "Remove admin", | |
| 1150 | + "admin.delete_user": "Delete", | |
| 1151 | + "admin.delete_repo": "Delete", | |
| 1152 | + "admin.repo_visibility": "Access", | |
| 1153 | + "admin.delete_user_title": "Delete account", | |
| 1154 | + "admin.delete_user_desc": "All repositories and user data will be permanently deleted.", | |
| 1155 | + "admin.delete_repo_title": "Delete repository", | |
| 1156 | + "admin.delete_repo_desc": "Git data, releases, and uploaded files will be permanently deleted.", | |
| 1157 | + "admin.confirm_user": "Type the username", | |
| 1158 | + "admin.confirm_repo": "Type the repository name", | |
| 1159 | + "flash.image_invalid": "The image is too large or uses an unsupported format.", | |
| 1160 | + "flash.image_save_failed": "The image could not be saved. Please try again.", | |
| 1161 | + "flash.account_deleted": "The account and all of its repositories were deleted.", | |
| 1162 | + "flash.account_delete_failed": "Could not delete the account.", | |
| 1163 | + "flash.admin_user_approved": "Account @%s approved.", | |
| 1164 | + "flash.admin_user_missing": "User not found.", | |
| 1165 | + "flash.admin_user_update_failed": "Could not update the user.", | |
| 1166 | + "flash.admin_self_role": "You cannot change your own role from the admin panel.", | |
| 1167 | + "flash.admin_last_admin": "The last administrator cannot be removed or demoted.", | |
| 1168 | + "flash.admin_role_updated": "The role for @%s was updated.", | |
| 1169 | + "flash.admin_delete_self": "Delete your own account from profile settings.", | |
| 1170 | + "flash.admin_delete_mismatch": "The confirmation does not match.", | |
| 1171 | + "flash.admin_user_delete_failed": "Could not delete the user.", | |
| 1172 | + "flash.admin_user_deleted": "User @%s and their repositories were deleted.", | |
| 1173 | + "flash.admin_repo_missing": "Repository not found.", | |
| 1174 | + "flash.admin_repo_delete_failed": "Could not delete the repository.", | |
| 1175 | + "flash.admin_repo_deleted": "Repository %s/%s deleted.", | |
| 1176 | + "flash.admin_registration_invalid": "Invalid registration mode."}, | |
| 1055 | 1177 | } |
| 1056 | 1178 | |
| 1057 | 1179 | func T(lang Lang, key string) string { |
internal/repo/repo.go
+48
-3
| ... | ... | @@ -39,6 +39,7 @@ type Repository struct { |
| 39 | 39 | OwnerAvatarURL string |
| 40 | 40 | Name string |
| 41 | 41 | Description string |
| 42 | + AvatarURL string | |
| 42 | 43 | IsPrivate bool |
| 43 | 44 | DefaultBranch string |
| 44 | 45 | EnableIssues bool |
| ... | ... | @@ -180,7 +181,7 @@ func (s *Service) Create(ctx context.Context, owner *user.User, name, descriptio |
| 180 | 181 | } |
| 181 | 182 | |
| 182 | 183 | 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 | + SELECT r.id, r.owner_id, u.username, u.avatar_url, r.avatar_url, r.name, r.description, r.is_private, r.default_branch, | |
| 184 | 185 | r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity, |
| 185 | 186 | r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name, |
| 186 | 187 | (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count, |
| ... | ... | @@ -334,6 +335,18 @@ func (s *Service) Update(ctx context.Context, r *Repository, description string, |
| 334 | 335 | return nil |
| 335 | 336 | } |
| 336 | 337 | |
| 338 | +func (s *Service) UpdateAvatar(ctx context.Context, repoID int64, avatarURL string) error { | |
| 339 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 340 | + defer cancel() | |
| 341 | + _, err := s.db.ExecContext(cctx, ` | |
| 342 | + UPDATE repositories SET avatar_url = ?, updated_at = ? WHERE id = ? | |
| 343 | + `, strings.TrimSpace(avatarURL), time.Now().UTC(), repoID) | |
| 344 | + if err != nil { | |
| 345 | + return fmt.Errorf("update repository avatar: %w", err) | |
| 346 | + } | |
| 347 | + return nil | |
| 348 | +} | |
| 349 | + | |
| 337 | 350 | func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) { |
| 338 | 351 | cctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 339 | 352 | defer cancel() |
| ... | ... | @@ -411,6 +424,38 @@ func (s *Service) Delete(ctx context.Context, r *Repository) error { |
| 411 | 424 | return nil |
| 412 | 425 | } |
| 413 | 426 | |
| 427 | +// DeleteAllByOwner removes every repository owned by the user, including bare | |
| 428 | +// Git data and uploaded release assets, then removes the owner's empty storage | |
| 429 | +// directory. It is used when an account is deleted. | |
| 430 | +func (s *Service) DeleteAllByOwner(ctx context.Context, owner *user.User) error { | |
| 431 | + if owner == nil { | |
| 432 | + return ErrRepoNotFound | |
| 433 | + } | |
| 434 | + repos, err := s.ListByOwner(ctx, owner.ID, true) | |
| 435 | + if err != nil { | |
| 436 | + return err | |
| 437 | + } | |
| 438 | + | |
| 439 | + var errs []error | |
| 440 | + for i := range repos { | |
| 441 | + if err := s.Delete(ctx, &repos[i]); err != nil { | |
| 442 | + errs = append(errs, fmt.Errorf("delete %s/%s: %w", repos[i].OwnerUsername, repos[i].Name, err)) | |
| 443 | + } | |
| 444 | + } | |
| 445 | + | |
| 446 | + baseAbs, err := filepath.Abs(s.reposDir) | |
| 447 | + if err != nil { | |
| 448 | + return fmt.Errorf("resolve repos dir: %w", err) | |
| 449 | + } | |
| 450 | + ownerDir := filepath.Join(baseAbs, strings.ToLower(owner.Username)) | |
| 451 | + if len(errs) == 0 { | |
| 452 | + if err := os.RemoveAll(ownerDir); err != nil { | |
| 453 | + errs = append(errs, fmt.Errorf("remove owner repository directory: %w", err)) | |
| 454 | + } | |
| 455 | + } | |
| 456 | + return errors.Join(errs...) | |
| 457 | +} | |
| 458 | + | |
| 414 | 459 | func boolInt(b bool) int { |
| 415 | 460 | if b { |
| 416 | 461 | return 1 |
| ... | ... | @@ -535,7 +580,7 @@ func scanRepo(row *sql.Row) (*Repository, error) { |
| 535 | 580 | var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int |
| 536 | 581 | var forkedFromID sql.NullInt64 |
| 537 | 582 | var forkedFromOwner, forkedFromName sql.NullString |
| 538 | - err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 583 | + err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 539 | 584 | &enableIssues, &enablePulls, &enableReleases, &enableActivity, |
| 540 | 585 | &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, |
| 541 | 586 | &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, |
| ... | ... | @@ -567,7 +612,7 @@ func scanRepos(rows *sql.Rows) ([]Repository, error) { |
| 567 | 612 | var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int |
| 568 | 613 | var forkedFromID sql.NullInt64 |
| 569 | 614 | 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, | |
| 615 | + if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, | |
| 571 | 616 | &enableIssues, &enablePulls, &enableReleases, &enableActivity, |
| 572 | 617 | &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, |
| 573 | 618 | &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, |
internal/user/user.go
+133
-25
| ... | ... | @@ -17,7 +17,9 @@ import ( |
| 17 | 17 | var ( |
| 18 | 18 | ErrUserNotFound = errors.New("user not found") |
| 19 | 19 | ErrUserAlreadyExists = errors.New("username or email already taken") |
| 20 | - ErrRegistrationDisabled = errors.New("public registration is disabled") | |
| 20 | + ErrRegistrationDisabled = errors.New("registration is disabled") | |
| 21 | + ErrPendingApproval = errors.New("account is awaiting administrator approval") | |
| 22 | + ErrLastAdmin = errors.New("the last administrator account cannot be removed") | |
| 21 | 23 | ErrInvalidUsername = errors.New("username must be 2–32 alphanumeric characters or single hyphens/underscores") |
| 22 | 24 | ErrReservedUsername = errors.New("this username is reserved by the system") |
| 23 | 25 | ErrInvalidEmail = errors.New("invalid email address") |
| ... | ... | @@ -38,18 +40,20 @@ var reservedUsernames = map[string]struct{}{ |
| 38 | 40 | } |
| 39 | 41 | |
| 40 | 42 | 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 | |
| 43 | + ID int64 | |
| 44 | + Username string | |
| 45 | + Email string | |
| 46 | + PasswordHash string | |
| 47 | + FullName string | |
| 48 | + Bio string | |
| 49 | + AvatarURL string | |
| 50 | + BackgroundURL string | |
| 51 | + Location string | |
| 52 | + Website string | |
| 53 | + IsAdmin bool | |
| 54 | + IsApproved bool | |
| 55 | + RepoCount int | |
| 56 | + CreatedAt time.Time | |
| 53 | 57 | } |
| 54 | 58 | |
| 55 | 59 | type Service struct { |
| ... | ... | @@ -98,9 +102,24 @@ func (s *Service) Count(ctx context.Context) (int, error) { |
| 98 | 102 | } |
| 99 | 103 | |
| 100 | 104 | func (s *Service) Register(ctx context.Context, username, email, password string, regEnabled bool) (*User, error) { |
| 105 | + mode := db.RegistrationModeClosed | |
| 106 | + if regEnabled { | |
| 107 | + mode = db.RegistrationModePublic | |
| 108 | + } | |
| 109 | + return s.RegisterWithMode(ctx, username, email, password, mode) | |
| 110 | +} | |
| 111 | + | |
| 112 | +// RegisterWithMode creates an account according to the server registration | |
| 113 | +// policy. "public" accounts are usable immediately, while "confirm" accounts | |
| 114 | +// stay pending until an administrator approves them. The first account is | |
| 115 | +// always an approved administrator, regardless of the policy. | |
| 116 | +func (s *Service) RegisterWithMode(ctx context.Context, username, email, password, mode string) (*User, error) { | |
| 101 | 117 | username = strings.TrimSpace(username) |
| 102 | 118 | email = strings.ToLower(strings.TrimSpace(email)) |
| 103 | 119 | |
| 120 | + if !db.ValidRegistrationMode(mode) { | |
| 121 | + return nil, fmt.Errorf("invalid registration mode %q", mode) | |
| 122 | + } | |
| 104 | 123 | if err := ValidateUsername(username); err != nil { |
| 105 | 124 | return nil, err |
| 106 | 125 | } |
| ... | ... | @@ -130,7 +149,7 @@ func (s *Service) Register(ctx context.Context, username, email, password string |
| 130 | 149 | return nil, fmt.Errorf("count existing users: %w", err) |
| 131 | 150 | } |
| 132 | 151 | |
| 133 | - if userCount > 0 && !regEnabled { | |
| 152 | + if userCount > 0 && mode == db.RegistrationModeClosed { | |
| 134 | 153 | return nil, ErrRegistrationDisabled |
| 135 | 154 | } |
| 136 | 155 | |
| ... | ... | @@ -149,12 +168,17 @@ func (s *Service) Register(ctx context.Context, username, email, password string |
| 149 | 168 | if isAdmin { |
| 150 | 169 | isAdminInt = 1 |
| 151 | 170 | } |
| 171 | + isApproved := isAdmin || mode == db.RegistrationModePublic | |
| 172 | + isApprovedInt := 0 | |
| 173 | + if isApproved { | |
| 174 | + isApprovedInt = 1 | |
| 175 | + } | |
| 152 | 176 | now := time.Now().UTC() |
| 153 | 177 | |
| 154 | 178 | 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) | |
| 179 | + INSERT INTO users (username, email, password_hash, bio, is_admin, is_approved, created_at) | |
| 180 | + VALUES (?, ?, ?, '', ?, ?, ?) | |
| 181 | + `, username, email, hash, isAdminInt, isApprovedInt, now) | |
| 158 | 182 | if err != nil { |
| 159 | 183 | if strings.Contains(strings.ToLower(err.Error()), "unique") { |
| 160 | 184 | return nil, ErrUserAlreadyExists |
| ... | ... | @@ -173,6 +197,7 @@ func (s *Service) Register(ctx context.Context, username, email, password string |
| 173 | 197 | Email: email, |
| 174 | 198 | PasswordHash: hash, |
| 175 | 199 | IsAdmin: isAdmin, |
| 200 | + IsApproved: isApproved, | |
| 176 | 201 | CreatedAt: now, |
| 177 | 202 | }, nil |
| 178 | 203 | } |
| ... | ... | @@ -187,7 +212,7 @@ func (s *Service) Authenticate(ctx context.Context, login, password string) (*Us |
| 187 | 212 | defer cancel() |
| 188 | 213 | |
| 189 | 214 | 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 | |
| 215 | + SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at | |
| 191 | 216 | FROM users |
| 192 | 217 | WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?) |
| 193 | 218 | `, login, login)) |
| ... | ... | @@ -202,6 +227,9 @@ func (s *Service) Authenticate(ctx context.Context, login, password string) (*Us |
| 202 | 227 | if err != nil || !ok { |
| 203 | 228 | return nil, auth.ErrInvalidCredentials |
| 204 | 229 | } |
| 230 | + if !u.IsApproved { | |
| 231 | + return nil, ErrPendingApproval | |
| 232 | + } | |
| 205 | 233 | return u, nil |
| 206 | 234 | } |
| 207 | 235 | |
| ... | ... | @@ -210,7 +238,7 @@ func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) { |
| 210 | 238 | defer cancel() |
| 211 | 239 | |
| 212 | 240 | 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 | |
| 241 | + SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at | |
| 214 | 242 | FROM users WHERE id = ? |
| 215 | 243 | `, id)) |
| 216 | 244 | } |
| ... | ... | @@ -220,7 +248,7 @@ func (s *Service) GetByUsername(ctx context.Context, username string) (*User, er |
| 220 | 248 | defer cancel() |
| 221 | 249 | |
| 222 | 250 | 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 | |
| 251 | + SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at | |
| 224 | 252 | FROM users WHERE LOWER(username) = LOWER(?) |
| 225 | 253 | `, strings.TrimSpace(username))) |
| 226 | 254 | } |
| ... | ... | @@ -287,6 +315,19 @@ func (s *Service) UpdateAvatar(ctx context.Context, userID int64, avatarURL stri |
| 287 | 315 | return nil |
| 288 | 316 | } |
| 289 | 317 | |
| 318 | +func (s *Service) UpdateBackground(ctx context.Context, userID int64, backgroundURL string) error { | |
| 319 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 320 | + defer cancel() | |
| 321 | + | |
| 322 | + _, err := s.db.ExecContext(cctx, ` | |
| 323 | + UPDATE users SET background_url = ? WHERE id = ? | |
| 324 | + `, strings.TrimSpace(backgroundURL), userID) | |
| 325 | + if err != nil { | |
| 326 | + return fmt.Errorf("update profile background: %w", err) | |
| 327 | + } | |
| 328 | + return nil | |
| 329 | +} | |
| 330 | + | |
| 290 | 331 | func (s *Service) UpdatePassword(ctx context.Context, userID int64, currentPassword, newPassword string) error { |
| 291 | 332 | if len(newPassword) < 8 { |
| 292 | 333 | return ErrWeakPassword |
| ... | ... | @@ -319,7 +360,7 @@ func (s *Service) ListAll(ctx context.Context) ([]User, error) { |
| 319 | 360 | defer cancel() |
| 320 | 361 | |
| 321 | 362 | 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, | |
| 363 | + SELECT u.id, u.username, u.email, u.password_hash, u.full_name, u.bio, u.avatar_url, u.background_url, u.location, u.website, u.is_admin, u.is_approved, u.created_at, | |
| 323 | 364 | COUNT(r.id) AS repo_count |
| 324 | 365 | FROM users u |
| 325 | 366 | LEFT JOIN repositories r ON r.owner_id = u.id |
| ... | ... | @@ -334,17 +375,44 @@ func (s *Service) ListAll(ctx context.Context) ([]User, error) { |
| 334 | 375 | var users []User |
| 335 | 376 | for rows.Next() { |
| 336 | 377 | 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 { | |
| 378 | + var isAdmin, isApproved int | |
| 379 | + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.BackgroundURL, &u.Location, &u.Website, &isAdmin, &isApproved, &u.CreatedAt, &u.RepoCount); err != nil { | |
| 339 | 380 | return nil, fmt.Errorf("scan user: %w", err) |
| 340 | 381 | } |
| 341 | 382 | u.IsAdmin = isAdmin == 1 |
| 383 | + u.IsApproved = isApproved == 1 | |
| 342 | 384 | users = append(users, u) |
| 343 | 385 | } |
| 344 | 386 | return users, rows.Err() |
| 345 | 387 | } |
| 346 | 388 | |
| 389 | +func (s *Service) CountAdmins(ctx context.Context) (int, error) { | |
| 390 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 391 | + defer cancel() | |
| 392 | + var count int | |
| 393 | + if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&count); err != nil { | |
| 394 | + return 0, fmt.Errorf("count administrators: %w", err) | |
| 395 | + } | |
| 396 | + return count, nil | |
| 397 | +} | |
| 398 | + | |
| 347 | 399 | func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) error { |
| 400 | + if !isAdmin { | |
| 401 | + u, err := s.GetByID(ctx, userID) | |
| 402 | + if err != nil { | |
| 403 | + return err | |
| 404 | + } | |
| 405 | + if u.IsAdmin { | |
| 406 | + count, err := s.CountAdmins(ctx) | |
| 407 | + if err != nil { | |
| 408 | + return err | |
| 409 | + } | |
| 410 | + if count <= 1 { | |
| 411 | + return ErrLastAdmin | |
| 412 | + } | |
| 413 | + } | |
| 414 | + } | |
| 415 | + | |
| 348 | 416 | cctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 349 | 417 | defer cancel() |
| 350 | 418 | val := 0 |
| ... | ... | @@ -355,10 +423,49 @@ func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) erro |
| 355 | 423 | return err |
| 356 | 424 | } |
| 357 | 425 | |
| 426 | +func (s *Service) SetApproved(ctx context.Context, userID int64, approved bool) error { | |
| 427 | + cctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 428 | + defer cancel() | |
| 429 | + val := 0 | |
| 430 | + if approved { | |
| 431 | + val = 1 | |
| 432 | + } | |
| 433 | + _, err := s.db.ExecContext(cctx, `UPDATE users SET is_approved = ? WHERE id = ?`, val, userID) | |
| 434 | + if err != nil { | |
| 435 | + return fmt.Errorf("update account approval: %w", err) | |
| 436 | + } | |
| 437 | + return nil | |
| 438 | +} | |
| 439 | + | |
| 440 | +// Delete removes the user record. Callers must remove the user's repositories | |
| 441 | +// and their on-disk data first. The last administrator is protected. | |
| 442 | +func (s *Service) Delete(ctx context.Context, userID int64) error { | |
| 443 | + u, err := s.GetByID(ctx, userID) | |
| 444 | + if err != nil { | |
| 445 | + return err | |
| 446 | + } | |
| 447 | + if u.IsAdmin { | |
| 448 | + count, err := s.CountAdmins(ctx) | |
| 449 | + if err != nil { | |
| 450 | + return err | |
| 451 | + } | |
| 452 | + if count <= 1 { | |
| 453 | + return ErrLastAdmin | |
| 454 | + } | |
| 455 | + } | |
| 456 | + | |
| 457 | + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 458 | + defer cancel() | |
| 459 | + if _, err := s.db.ExecContext(cctx, `DELETE FROM users WHERE id = ?`, userID); err != nil { | |
| 460 | + return fmt.Errorf("delete user: %w", err) | |
| 461 | + } | |
| 462 | + return nil | |
| 463 | +} | |
| 464 | + | |
| 358 | 465 | func (s *Service) scanUser(row *sql.Row) (*User, error) { |
| 359 | 466 | 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) | |
| 467 | + var isAdmin, isApproved int | |
| 468 | + err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.BackgroundURL, &u.Location, &u.Website, &isAdmin, &isApproved, &u.CreatedAt) | |
| 362 | 469 | if err != nil { |
| 363 | 470 | if errors.Is(err, sql.ErrNoRows) { |
| 364 | 471 | return nil, ErrUserNotFound |
| ... | ... | @@ -366,5 +473,6 @@ func (s *Service) scanUser(row *sql.Row) (*User, error) { |
| 366 | 473 | return nil, fmt.Errorf("scan user: %w", err) |
| 367 | 474 | } |
| 368 | 475 | u.IsAdmin = isAdmin == 1 |
| 476 | + u.IsApproved = isApproved == 1 | |
| 369 | 477 | return &u, nil |
| 370 | 478 | } |
internal/web/handlers/account.go
+54
-0
| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "polar/internal/user" | |
| 9 | + "polar/internal/web/middleware" | |
| 10 | + | |
| 11 | + "github.com/gofiber/fiber/v2" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func (h *Handler) deleteAccount(ctx context.Context, account *user.User) error { | |
| 15 | + if account == nil { | |
| 16 | + return user.ErrUserNotFound | |
| 17 | + } | |
| 18 | + if err := h.repoSvc.DeleteAllByOwner(ctx, account); err != nil { | |
| 19 | + return err | |
| 20 | + } | |
| 21 | + return h.userSvc.Delete(ctx, account.ID) | |
| 22 | +} | |
| 23 | + | |
| 24 | +func (h *Handler) DeleteAccountSubmit(c *fiber.Ctx) error { | |
| 25 | + current := middleware.CurrentUser(c) | |
| 26 | + if current == nil { | |
| 27 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 28 | + } | |
| 29 | + | |
| 30 | + confirm := strings.TrimSpace(c.FormValue("confirm_username")) | |
| 31 | + password := c.FormValue("password") | |
| 32 | + if !strings.EqualFold(confirm, current.Username) { | |
| 33 | + h.setFlashT(c, "settings.delete_mismatch") | |
| 34 | + return c.Redirect("/settings#danger", fiber.StatusSeeOther) | |
| 35 | + } | |
| 36 | + if _, err := h.userSvc.Authenticate(c.UserContext(), current.Username, password); err != nil { | |
| 37 | + h.setFlashT(c, "auth.invalid_creds") | |
| 38 | + return c.Redirect("/settings#danger", fiber.StatusSeeOther) | |
| 39 | + } | |
| 40 | + | |
| 41 | + if err := h.deleteAccount(c.UserContext(), current); err != nil { | |
| 42 | + h.logger.Error("delete account failed", "user_id", current.ID, "err", err) | |
| 43 | + if errors.Is(err, user.ErrLastAdmin) { | |
| 44 | + h.setFlashT(c, "settings.delete_last_admin") | |
| 45 | + } else { | |
| 46 | + h.setFlashT(c, "flash.account_delete_failed") | |
| 47 | + } | |
| 48 | + return c.Redirect("/settings#danger", fiber.StatusSeeOther) | |
| 49 | + } | |
| 50 | + | |
| 51 | + c.ClearCookie(middleware.SessionCookieName) | |
| 52 | + h.setFlashT(c, "flash.account_deleted") | |
| 53 | + return c.Redirect("/", fiber.StatusSeeOther) | |
| 54 | +} |
internal/web/handlers/account_admin_test.go
+248
-0
| ... | ... | @@ -0,0 +1,248 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "io" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "net/url" | |
| 10 | + "os" | |
| 11 | + "path/filepath" | |
| 12 | + "strconv" | |
| 13 | + "strings" | |
| 14 | + "testing" | |
| 15 | + | |
| 16 | + "polar/internal/auth" | |
| 17 | + "polar/internal/db" | |
| 18 | + "polar/internal/repo" | |
| 19 | + "polar/internal/user" | |
| 20 | + "polar/internal/web/middleware" | |
| 21 | +) | |
| 22 | + | |
| 23 | +func authenticatedFormRequest(t *testing.T, method, path string, form url.Values, sess *auth.Session) *http.Request { | |
| 24 | + t.Helper() | |
| 25 | + req := httptest.NewRequest(method, path, strings.NewReader(form.Encode())) | |
| 26 | + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 27 | + req.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sess.ID}) | |
| 28 | + return req | |
| 29 | +} | |
| 30 | + | |
| 31 | +func TestRegistrationModesMediaPersistence(t *testing.T) { | |
| 32 | + app, _, userSvc, repoSvc := setupTestApp(t) | |
| 33 | + ctx := context.Background() | |
| 34 | + | |
| 35 | + admin, err := userSvc.RegisterWithMode(ctx, "admin1", "admin1@example.com", "password123", db.RegistrationModePublic) | |
| 36 | + if err != nil { | |
| 37 | + t.Fatalf("register first administrator: %v", err) | |
| 38 | + } | |
| 39 | + if !admin.IsAdmin || !admin.IsApproved { | |
| 40 | + t.Fatalf("first account must be an approved administrator: %+v", admin) | |
| 41 | + } | |
| 42 | + | |
| 43 | + pending, err := userSvc.RegisterWithMode(ctx, "pending1", "pending1@example.com", "password123", db.RegistrationModeConfirm) | |
| 44 | + if err != nil { | |
| 45 | + t.Fatalf("register pending account: %v", err) | |
| 46 | + } | |
| 47 | + if pending.IsApproved { | |
| 48 | + t.Fatal("confirm-only registration must create a pending account") | |
| 49 | + } | |
| 50 | + if _, err := userSvc.Authenticate(ctx, pending.Username, "password123"); !errors.Is(err, user.ErrPendingApproval) { | |
| 51 | + t.Fatalf("pending account login error = %v, want ErrPendingApproval", err) | |
| 52 | + } | |
| 53 | + if err := userSvc.SetApproved(ctx, pending.ID, true); err != nil { | |
| 54 | + t.Fatalf("approve pending account: %v", err) | |
| 55 | + } | |
| 56 | + if _, err := userSvc.Authenticate(ctx, pending.Username, "password123"); err != nil { | |
| 57 | + t.Fatalf("approved account login failed: %v", err) | |
| 58 | + } | |
| 59 | + | |
| 60 | + member, err := userSvc.RegisterWithMode(ctx, "member1", "member1@example.com", "password123", db.RegistrationModePublic) | |
| 61 | + if err != nil { | |
| 62 | + t.Fatalf("register public account: %v", err) | |
| 63 | + } | |
| 64 | + if !member.IsApproved { | |
| 65 | + t.Fatal("public registration must create an approved account") | |
| 66 | + } | |
| 67 | + if _, err := userSvc.RegisterWithMode(ctx, "closed1", "closed1@example.com", "password123", db.RegistrationModeClosed); !errors.Is(err, user.ErrRegistrationDisabled) { | |
| 68 | + t.Fatalf("closed registration error = %v, want ErrRegistrationDisabled", err) | |
| 69 | + } | |
| 70 | + | |
| 71 | + background := "data:image/webp;base64,AAAA" | |
| 72 | + avatar := "data:image/webp;base64,BBBB" | |
| 73 | + if err := userSvc.UpdateBackground(ctx, member.ID, background); err != nil { | |
| 74 | + t.Fatalf("update profile background: %v", err) | |
| 75 | + } | |
| 76 | + if err := userSvc.UpdateAvatar(ctx, member.ID, avatar); err != nil { | |
| 77 | + t.Fatalf("update profile avatar: %v", err) | |
| 78 | + } | |
| 79 | + storedMember, err := userSvc.GetByUsername(ctx, member.Username) | |
| 80 | + if err != nil { | |
| 81 | + t.Fatalf("load member: %v", err) | |
| 82 | + } | |
| 83 | + if storedMember.BackgroundURL != background || storedMember.AvatarURL != avatar { | |
| 84 | + t.Fatalf("profile media not persisted: background=%q avatar=%q", storedMember.BackgroundURL, storedMember.AvatarURL) | |
| 85 | + } | |
| 86 | + | |
| 87 | + repository, err := repoSvc.Create(ctx, member, "media-repo", "Media persistence", false) | |
| 88 | + if err != nil { | |
| 89 | + t.Fatalf("create repository: %v", err) | |
| 90 | + } | |
| 91 | + repoAvatar := "data:image/webp;base64,CCCC" | |
| 92 | + if err := repoSvc.UpdateAvatar(ctx, repository.ID, repoAvatar); err != nil { | |
| 93 | + t.Fatalf("update repository avatar: %v", err) | |
| 94 | + } | |
| 95 | + storedRepo, err := repoSvc.GetByID(ctx, repository.ID) | |
| 96 | + if err != nil { | |
| 97 | + t.Fatalf("load repository: %v", err) | |
| 98 | + } | |
| 99 | + if storedRepo.AvatarURL != repoAvatar { | |
| 100 | + t.Fatalf("repository avatar = %q, want %q", storedRepo.AvatarURL, repoAvatar) | |
| 101 | + } | |
| 102 | + | |
| 103 | + profileResp, err := app.Test(httptest.NewRequest(http.MethodGet, "/member1", nil), 5000) | |
| 104 | + if err != nil || profileResp.StatusCode != http.StatusOK { | |
| 105 | + t.Fatalf("profile response: status=%v err=%v", profileResp.StatusCode, err) | |
| 106 | + } | |
| 107 | + profileBody, _ := io.ReadAll(profileResp.Body) | |
| 108 | + if !strings.Contains(string(profileBody), background) || !strings.Contains(string(profileBody), "profile-hero-bg") { | |
| 109 | + t.Fatal("profile page must render the saved vignette background") | |
| 110 | + } | |
| 111 | + | |
| 112 | + repoResp, err := app.Test(httptest.NewRequest(http.MethodGet, "/member1/media-repo", nil), 5000) | |
| 113 | + if err != nil || repoResp.StatusCode != http.StatusOK { | |
| 114 | + t.Fatalf("repository response: status=%v err=%v", repoResp.StatusCode, err) | |
| 115 | + } | |
| 116 | + repoBody, _ := io.ReadAll(repoResp.Body) | |
| 117 | + if !strings.Contains(string(repoBody), repoAvatar) { | |
| 118 | + t.Fatal("repository page must render the custom repository avatar") | |
| 119 | + } | |
| 120 | +} | |
| 121 | + | |
| 122 | +func TestAdminCanDeleteRepositoriesAndAccounts(t *testing.T) { | |
| 123 | + app, authSvc, userSvc, repoSvc := setupTestApp(t) | |
| 124 | + ctx := context.Background() | |
| 125 | + | |
| 126 | + admin, err := userSvc.RegisterWithMode(ctx, "admin2", "admin2@example.com", "password123", db.RegistrationModePublic) | |
| 127 | + if err != nil { | |
| 128 | + t.Fatalf("register administrator: %v", err) | |
| 129 | + } | |
| 130 | + member, err := userSvc.RegisterWithMode(ctx, "member2", "member2@example.com", "password123", db.RegistrationModePublic) | |
| 131 | + if err != nil { | |
| 132 | + t.Fatalf("register member: %v", err) | |
| 133 | + } | |
| 134 | + repository, err := repoSvc.Create(ctx, member, "delete-me", "Temporary repository", false) | |
| 135 | + if err != nil { | |
| 136 | + t.Fatalf("create repository: %v", err) | |
| 137 | + } | |
| 138 | + | |
| 139 | + sess, err := authSvc.CreateSession(ctx, admin.ID) | |
| 140 | + if err != nil { | |
| 141 | + t.Fatalf("create administrator session: %v", err) | |
| 142 | + } | |
| 143 | + | |
| 144 | + repoDeletePath := "/admin/repos/" + strconv.FormatInt(repository.ID, 10) + "/delete" | |
| 145 | + req := authenticatedFormRequest(t, http.MethodPost, repoDeletePath, url.Values{ | |
| 146 | + "_csrf": {sess.CSRFToken}, | |
| 147 | + "confirm_name": {repository.Name}, | |
| 148 | + }, sess) | |
| 149 | + resp, err := app.Test(req, 5000) | |
| 150 | + if err != nil { | |
| 151 | + t.Fatalf("admin repository delete request: %v", err) | |
| 152 | + } | |
| 153 | + if resp.StatusCode != http.StatusSeeOther { | |
| 154 | + t.Fatalf("admin repository delete status = %d, want 303", resp.StatusCode) | |
| 155 | + } | |
| 156 | + if _, err := repoSvc.GetByID(ctx, repository.ID); !errors.Is(err, repo.ErrRepoNotFound) { | |
| 157 | + t.Fatalf("deleted repository lookup error = %v, want ErrRepoNotFound", err) | |
| 158 | + } | |
| 159 | + | |
| 160 | + remaining, err := repoSvc.Create(ctx, member, "delete-with-account", "Temporary repository", false) | |
| 161 | + if err != nil { | |
| 162 | + t.Fatalf("create remaining repository: %v", err) | |
| 163 | + } | |
| 164 | + diskPath, err := repoSvc.DiskPath(member.Username, remaining.Name) | |
| 165 | + if err != nil { | |
| 166 | + t.Fatalf("resolve repository path: %v", err) | |
| 167 | + } | |
| 168 | + ownerDir := filepath.Dir(diskPath) | |
| 169 | + | |
| 170 | + userDeletePath := "/admin/users/" + strconv.FormatInt(member.ID, 10) + "/delete" | |
| 171 | + req = authenticatedFormRequest(t, http.MethodPost, userDeletePath, url.Values{ | |
| 172 | + "_csrf": {sess.CSRFToken}, | |
| 173 | + "confirm_username": {member.Username}, | |
| 174 | + }, sess) | |
| 175 | + resp, err = app.Test(req, 5000) | |
| 176 | + if err != nil { | |
| 177 | + t.Fatalf("admin account delete request: %v", err) | |
| 178 | + } | |
| 179 | + if resp.StatusCode != http.StatusSeeOther { | |
| 180 | + t.Fatalf("admin account delete status = %d, want 303", resp.StatusCode) | |
| 181 | + } | |
| 182 | + if _, err := userSvc.GetByUsername(ctx, member.Username); !errors.Is(err, user.ErrUserNotFound) { | |
| 183 | + t.Fatalf("deleted account lookup error = %v, want ErrUserNotFound", err) | |
| 184 | + } | |
| 185 | + if _, err := repoSvc.GetByID(ctx, remaining.ID); !errors.Is(err, repo.ErrRepoNotFound) { | |
| 186 | + t.Fatalf("cascaded repository lookup error = %v, want ErrRepoNotFound", err) | |
| 187 | + } | |
| 188 | + if _, err := os.Stat(ownerDir); !os.IsNotExist(err) { | |
| 189 | + t.Fatalf("owner repository directory still exists: %v", err) | |
| 190 | + } | |
| 191 | +} | |
| 192 | + | |
| 193 | +func TestAccountSelfDeleteAndLastAdminProtection(t *testing.T) { | |
| 194 | + app, authSvc, userSvc, repoSvc := setupTestApp(t) | |
| 195 | + ctx := context.Background() | |
| 196 | + | |
| 197 | + admin, err := userSvc.RegisterWithMode(ctx, "admin3", "admin3@example.com", "password123", db.RegistrationModePublic) | |
| 198 | + if err != nil { | |
| 199 | + t.Fatalf("register administrator: %v", err) | |
| 200 | + } | |
| 201 | + member, err := userSvc.RegisterWithMode(ctx, "member3", "member3@example.com", "password123", db.RegistrationModePublic) | |
| 202 | + if err != nil { | |
| 203 | + t.Fatalf("register member: %v", err) | |
| 204 | + } | |
| 205 | + repository, err := repoSvc.Create(ctx, member, "self-delete", "Temporary repository", false) | |
| 206 | + if err != nil { | |
| 207 | + t.Fatalf("create repository: %v", err) | |
| 208 | + } | |
| 209 | + | |
| 210 | + sess, err := authSvc.CreateSession(ctx, member.ID) | |
| 211 | + if err != nil { | |
| 212 | + t.Fatalf("create member session: %v", err) | |
| 213 | + } | |
| 214 | + wrongPassword := authenticatedFormRequest(t, http.MethodPost, "/settings/delete", url.Values{ | |
| 215 | + "_csrf": {sess.CSRFToken}, | |
| 216 | + "confirm_username": {member.Username}, | |
| 217 | + "password": {"wrong-password"}, | |
| 218 | + }, sess) | |
| 219 | + resp, err := app.Test(wrongPassword, 5000) | |
| 220 | + if err != nil || resp.StatusCode != http.StatusSeeOther { | |
| 221 | + t.Fatalf("wrong-password delete response: status=%v err=%v", resp.StatusCode, err) | |
| 222 | + } | |
| 223 | + if _, err := userSvc.GetByUsername(ctx, member.Username); err != nil { | |
| 224 | + t.Fatalf("account was deleted with a wrong password: %v", err) | |
| 225 | + } | |
| 226 | + | |
| 227 | + deleteReq := authenticatedFormRequest(t, http.MethodPost, "/settings/delete", url.Values{ | |
| 228 | + "_csrf": {sess.CSRFToken}, | |
| 229 | + "confirm_username": {member.Username}, | |
| 230 | + "password": {"password123"}, | |
| 231 | + }, sess) | |
| 232 | + resp, err = app.Test(deleteReq, 5000) | |
| 233 | + if err != nil { | |
| 234 | + t.Fatalf("self delete request: %v", err) | |
| 235 | + } | |
| 236 | + if resp.StatusCode != http.StatusSeeOther { | |
| 237 | + t.Fatalf("self delete status = %d, want 303", resp.StatusCode) | |
| 238 | + } | |
| 239 | + if _, err := userSvc.GetByUsername(ctx, member.Username); !errors.Is(err, user.ErrUserNotFound) { | |
| 240 | + t.Fatalf("self-deleted account lookup error = %v, want ErrUserNotFound", err) | |
| 241 | + } | |
| 242 | + if _, err := repoSvc.GetByID(ctx, repository.ID); !errors.Is(err, repo.ErrRepoNotFound) { | |
| 243 | + t.Fatalf("self-deleted repository lookup error = %v, want ErrRepoNotFound", err) | |
| 244 | + } | |
| 245 | + if err := userSvc.Delete(ctx, admin.ID); !errors.Is(err, user.ErrLastAdmin) { | |
| 246 | + t.Fatalf("last administrator deletion error = %v, want ErrLastAdmin", err) | |
| 247 | + } | |
| 248 | +} |
internal/web/handlers/admin.go
+115
-0
| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "strconv" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "polar/internal/user" | |
| 9 | + "polar/internal/web/middleware" | |
| 10 | + | |
| 11 | + "github.com/gofiber/fiber/v2" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func (h *Handler) AdminApproveUserSubmit(c *fiber.Ctx) error { | |
| 15 | + id, err := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 16 | + if err != nil { | |
| 17 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 18 | + } | |
| 19 | + target, err := h.userSvc.GetByID(c.UserContext(), id) | |
| 20 | + if err != nil { | |
| 21 | + h.setFlashT(c, "flash.admin_user_missing") | |
| 22 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 23 | + } | |
| 24 | + if err := h.userSvc.SetApproved(c.UserContext(), target.ID, true); err != nil { | |
| 25 | + h.logger.Error("approve account failed", "user_id", target.ID, "err", err) | |
| 26 | + h.setFlashT(c, "flash.admin_user_update_failed") | |
| 27 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 28 | + } | |
| 29 | + h.setFlashT(c, "flash.admin_user_approved", target.Username) | |
| 30 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 31 | +} | |
| 32 | + | |
| 33 | +func (h *Handler) AdminToggleUserAdminSubmit(c *fiber.Ctx) error { | |
| 34 | + id, err := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 35 | + if err != nil { | |
| 36 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 37 | + } | |
| 38 | + current := middleware.CurrentUser(c) | |
| 39 | + target, err := h.userSvc.GetByID(c.UserContext(), id) | |
| 40 | + if err != nil { | |
| 41 | + h.setFlashT(c, "flash.admin_user_missing") | |
| 42 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 43 | + } | |
| 44 | + if current != nil && target.ID == current.ID { | |
| 45 | + h.setFlashT(c, "flash.admin_self_role") | |
| 46 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 47 | + } | |
| 48 | + if err := h.userSvc.SetAdmin(c.UserContext(), target.ID, !target.IsAdmin); err != nil { | |
| 49 | + if errors.Is(err, user.ErrLastAdmin) { | |
| 50 | + h.setFlashT(c, "flash.admin_last_admin") | |
| 51 | + } else { | |
| 52 | + h.logger.Error("update administrator role failed", "user_id", target.ID, "err", err) | |
| 53 | + h.setFlashT(c, "flash.admin_user_update_failed") | |
| 54 | + } | |
| 55 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 56 | + } | |
| 57 | + h.setFlashT(c, "flash.admin_role_updated", target.Username) | |
| 58 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 59 | +} | |
| 60 | + | |
| 61 | +func (h *Handler) AdminDeleteUserSubmit(c *fiber.Ctx) error { | |
| 62 | + id, err := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 63 | + if err != nil { | |
| 64 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 65 | + } | |
| 66 | + current := middleware.CurrentUser(c) | |
| 67 | + target, err := h.userSvc.GetByID(c.UserContext(), id) | |
| 68 | + if err != nil { | |
| 69 | + h.setFlashT(c, "flash.admin_user_missing") | |
| 70 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 71 | + } | |
| 72 | + if current != nil && target.ID == current.ID { | |
| 73 | + h.setFlashT(c, "flash.admin_delete_self") | |
| 74 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 75 | + } | |
| 76 | + if !strings.EqualFold(strings.TrimSpace(c.FormValue("confirm_username")), target.Username) { | |
| 77 | + h.setFlashT(c, "flash.admin_delete_mismatch") | |
| 78 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 79 | + } | |
| 80 | + | |
| 81 | + if err := h.deleteAccount(c.UserContext(), target); err != nil { | |
| 82 | + h.logger.Error("admin delete account failed", "user_id", target.ID, "err", err) | |
| 83 | + if errors.Is(err, user.ErrLastAdmin) { | |
| 84 | + h.setFlashT(c, "flash.admin_last_admin") | |
| 85 | + } else { | |
| 86 | + h.setFlashT(c, "flash.admin_user_delete_failed") | |
| 87 | + } | |
| 88 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 89 | + } | |
| 90 | + h.setFlashT(c, "flash.admin_user_deleted", target.Username) | |
| 91 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 92 | +} | |
| 93 | + | |
| 94 | +func (h *Handler) AdminDeleteRepoSubmit(c *fiber.Ctx) error { | |
| 95 | + id, err := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 96 | + if err != nil { | |
| 97 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 98 | + } | |
| 99 | + r, err := h.repoSvc.GetByID(c.UserContext(), id) | |
| 100 | + if err != nil { | |
| 101 | + h.setFlashT(c, "flash.admin_repo_missing") | |
| 102 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 103 | + } | |
| 104 | + if !strings.EqualFold(strings.TrimSpace(c.FormValue("confirm_name")), r.Name) { | |
| 105 | + h.setFlashT(c, "flash.admin_delete_mismatch") | |
| 106 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 107 | + } | |
| 108 | + if err := h.repoSvc.Delete(c.UserContext(), r); err != nil { | |
| 109 | + h.logger.Error("admin delete repository failed", "repo_id", r.ID, "err", err) | |
| 110 | + h.setFlashT(c, "flash.admin_repo_delete_failed") | |
| 111 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 112 | + } | |
| 113 | + h.setFlashT(c, "flash.admin_repo_deleted", r.OwnerUsername, r.Name) | |
| 114 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 115 | +} |
internal/web/handlers/auth.go
+75
-21
| ... | ... | @@ -1,12 +1,14 @@ |
| 1 | 1 | package handlers |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "encoding/base64" | |
| 4 | + "errors" | |
| 5 | 5 | "strconv" |
| 6 | 6 | "strings" |
| 7 | 7 | |
| 8 | 8 | "polar/internal/auth" |
| 9 | + "polar/internal/db" | |
| 9 | 10 | "polar/internal/i18n" |
| 11 | + "polar/internal/user" | |
| 10 | 12 | "polar/internal/web/middleware" |
| 11 | 13 | |
| 12 | 14 | "github.com/gofiber/fiber/v2" |
| ... | ... | @@ -32,7 +34,11 @@ func (h *Handler) LoginSubmit(c *fiber.Ctx) error { |
| 32 | 34 | data := h.baseData(c, i18n.T(lang, "nav.login")) |
| 33 | 35 | data["FormLogin"] = login |
| 34 | 36 | data["Next"] = safeNextPath(c.FormValue("next")) |
| 35 | - data["Error"] = i18n.T(lang, "auth.invalid_creds") | |
| 37 | + if errors.Is(err, user.ErrPendingApproval) { | |
| 38 | + data["Error"] = i18n.T(lang, "auth.pending_approval") | |
| 39 | + } else { | |
| 40 | + data["Error"] = i18n.T(lang, "auth.invalid_creds") | |
| 41 | + } | |
| 36 | 42 | return h.render(c, fiber.StatusUnauthorized, "login", data) |
| 37 | 43 | } |
| 38 | 44 | |
| ... | ... | @@ -81,16 +87,25 @@ func (h *Handler) RegisterSubmit(c *fiber.Ctx) error { |
| 81 | 87 | email := strings.TrimSpace(c.FormValue("email")) |
| 82 | 88 | password := c.FormValue("password") |
| 83 | 89 | |
| 84 | - regAllowed := h.isRegistrationAllowed(c) | |
| 85 | - u, err := h.userSvc.Register(c.UserContext(), username, email, password, regAllowed) | |
| 90 | + mode := h.db.GetRegistrationMode(c.UserContext(), h.cfg.Auth.RegistrationEnabled) | |
| 91 | + u, err := h.userSvc.RegisterWithMode(c.UserContext(), username, email, password, mode) | |
| 86 | 92 | if err != nil { |
| 87 | 93 | data := h.baseData(c, i18n.T(lang, "nav.register")) |
| 88 | 94 | data["FormUsername"] = username |
| 89 | 95 | data["FormEmail"] = email |
| 90 | - data["Error"] = err.Error() | |
| 96 | + if errors.Is(err, user.ErrRegistrationDisabled) { | |
| 97 | + data["Error"] = i18n.T(lang, "auth.register.disabled") | |
| 98 | + } else { | |
| 99 | + data["Error"] = err.Error() | |
| 100 | + } | |
| 91 | 101 | return h.render(c, fiber.StatusBadRequest, "register", data) |
| 92 | 102 | } |
| 93 | 103 | |
| 104 | + if !u.IsApproved { | |
| 105 | + h.setFlashT(c, "auth.pending_approval") | |
| 106 | + return c.Redirect("/login", fiber.StatusSeeOther) | |
| 107 | + } | |
| 108 | + | |
| 94 | 109 | sess, err := h.authSvc.CreateSession(c.UserContext(), u.ID) |
| 95 | 110 | if err != nil { |
| 96 | 111 | h.logger.Error("create session after register failed", "err", err) |
| ... | ... | @@ -157,21 +172,36 @@ func (h *Handler) UpdateProfileSubmit(c *fiber.Ctx) error { |
| 157 | 172 | } |
| 158 | 173 | |
| 159 | 174 | 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 | + if err := h.userSvc.UpdateAvatar(c.UserContext(), u.ID, ""); err != nil { | |
| 176 | + h.logger.Error("remove profile avatar failed", "user_id", u.ID, "err", err) | |
| 177 | + h.setFlashT(c, "flash.image_save_failed") | |
| 178 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 179 | + } | |
| 180 | + } else if avatarData, err := readImageField(c, "avatar_data", "avatar_file", maxAvatarImageBytes); err != nil { | |
| 181 | + h.setFlashT(c, "flash.image_invalid") | |
| 182 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 183 | + } else if avatarData != "" { | |
| 184 | + if err := h.userSvc.UpdateAvatar(c.UserContext(), u.ID, avatarData); err != nil { | |
| 185 | + h.logger.Error("update profile avatar failed", "user_id", u.ID, "err", err) | |
| 186 | + h.setFlashT(c, "flash.image_save_failed") | |
| 187 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + | |
| 191 | + if c.FormValue("remove_background") == "true" { | |
| 192 | + if err := h.userSvc.UpdateBackground(c.UserContext(), u.ID, ""); err != nil { | |
| 193 | + h.logger.Error("remove profile background failed", "user_id", u.ID, "err", err) | |
| 194 | + h.setFlashT(c, "flash.image_save_failed") | |
| 195 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 196 | + } | |
| 197 | + } else if backgroundData, err := readImageField(c, "background_data", "background_file", maxBackgroundImageBytes); err != nil { | |
| 198 | + h.setFlashT(c, "flash.image_invalid") | |
| 199 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 200 | + } else if backgroundData != "" { | |
| 201 | + if err := h.userSvc.UpdateBackground(c.UserContext(), u.ID, backgroundData); err != nil { | |
| 202 | + h.logger.Error("update profile background failed", "user_id", u.ID, "err", err) | |
| 203 | + h.setFlashT(c, "flash.image_save_failed") | |
| 204 | + return c.Redirect("/settings", fiber.StatusSeeOther) | |
| 175 | 205 | } |
| 176 | 206 | } |
| 177 | 207 | |
| ... | ... | @@ -240,11 +270,29 @@ func (h *Handler) AdminPage(c *fiber.Ctx) error { |
| 240 | 270 | if err != nil { |
| 241 | 271 | h.logger.Error("list users failed", "err", err) |
| 242 | 272 | } |
| 273 | + repos, err := h.repoSvc.ListAccessible(c.UserContext(), middleware.CurrentUser(c), 200) | |
| 274 | + if err != nil { | |
| 275 | + h.logger.Error("list repositories failed", "err", err) | |
| 276 | + } | |
| 243 | 277 | data["Users"] = users |
| 278 | + data["Repos"] = repos | |
| 244 | 279 | return h.render(c, fiber.StatusOK, "admin", data) |
| 245 | 280 | } |
| 246 | 281 | |
| 247 | 282 | func (h *Handler) AdminSettingsSubmit(c *fiber.Ctx) error { |
| 283 | + if mode := strings.TrimSpace(c.FormValue("registration_mode")); mode != "" { | |
| 284 | + if !db.ValidRegistrationMode(mode) { | |
| 285 | + h.setFlashT(c, "flash.admin_registration_invalid") | |
| 286 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 287 | + } | |
| 288 | + if err := h.db.SetRegistrationMode(c.UserContext(), mode); err != nil { | |
| 289 | + h.logger.Error("update registration mode failed", "mode", mode, "err", err) | |
| 290 | + return h.renderErrorT(c, fiber.StatusInternalServerError, "err.settings_error", "err.settings_save") | |
| 291 | + } | |
| 292 | + h.setFlashT(c, "flash.settings_updated") | |
| 293 | + return c.Redirect("/admin", fiber.StatusSeeOther) | |
| 294 | + } | |
| 295 | + | |
| 248 | 296 | key := c.FormValue("setting_key") |
| 249 | 297 | val := c.FormValue("setting_val") |
| 250 | 298 | |
| ... | ... | @@ -261,7 +309,13 @@ func (h *Handler) AdminSettingsSubmit(c *fiber.Ctx) error { |
| 261 | 309 | } |
| 262 | 310 | |
| 263 | 311 | enabled := val == "true" || val == "1" || val == "on" |
| 264 | - if err := h.db.SetBoolSetting(c.UserContext(), key, enabled); err != nil { | |
| 312 | + var err error | |
| 313 | + if key == "registration_enabled" { | |
| 314 | + err = h.db.SetRegistrationEnabled(c.UserContext(), enabled) | |
| 315 | + } else { | |
| 316 | + err = h.db.SetBoolSetting(c.UserContext(), key, enabled) | |
| 317 | + } | |
| 318 | + if err != nil { | |
| 265 | 319 | h.logger.Error("update admin setting failed", "key", key, "err", err) |
| 266 | 320 | return h.renderErrorT(c, fiber.StatusInternalServerError, "err.settings_error", "err.settings_save") |
| 267 | 321 | } |
internal/web/handlers/handlers.go
+11
-2
| ... | ... | @@ -93,6 +93,7 @@ func (h *Handler) RegisterRoutes(app *fiber.App) { |
| 93 | 93 | app.Post("/new", middleware.RequireAuth(), h.NewRepoSubmit) |
| 94 | 94 | app.Get("/settings", middleware.RequireAuth(), h.SettingsPage) |
| 95 | 95 | app.Post("/settings/profile", middleware.RequireAuth(), h.UpdateProfileSubmit) |
| 96 | + app.Post("/settings/delete", middleware.RequireAuth(), h.DeleteAccountSubmit) | |
| 96 | 97 | app.Post("/settings/password", middleware.RequireAuth(), h.UpdatePasswordSubmit) |
| 97 | 98 | app.Post("/settings/tokens", middleware.RequireAuth(), h.CreateTokenSubmit) |
| 98 | 99 | app.Post("/settings/tokens/:id/revoke", middleware.RequireAuth(), h.RevokeTokenSubmit) |
| ... | ... | @@ -100,6 +101,10 @@ func (h *Handler) RegisterRoutes(app *fiber.App) { |
| 100 | 101 | // Admin routes |
| 101 | 102 | app.Get("/admin", middleware.RequireAdmin(), h.AdminPage) |
| 102 | 103 | app.Post("/admin/settings", middleware.RequireAdmin(), h.AdminSettingsSubmit) |
| 104 | + app.Post("/admin/users/:id/approve", middleware.RequireAdmin(), h.AdminApproveUserSubmit) | |
| 105 | + app.Post("/admin/users/:id/admin", middleware.RequireAdmin(), h.AdminToggleUserAdminSubmit) | |
| 106 | + app.Post("/admin/users/:id/delete", middleware.RequireAdmin(), h.AdminDeleteUserSubmit) | |
| 107 | + app.Post("/admin/repos/:id/delete", middleware.RequireAdmin(), h.AdminDeleteRepoSubmit) | |
| 103 | 108 | |
| 104 | 109 | // Git Smart HTTP endpoints (supports both /:owner/:repo.git/* and /:owner/:repo/*) |
| 105 | 110 | app.Get("/:owner/:repo/info/refs", h.GitInfoRefs) |
| ... | ... | @@ -209,6 +214,8 @@ func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any { |
| 209 | 214 | paletteRepos, _ = h.repoSvc.ListAccessible(ctx, u, 25) |
| 210 | 215 | } |
| 211 | 216 | |
| 217 | + registrationMode := h.db.GetRegistrationMode(ctx, h.cfg.Auth.RegistrationEnabled) | |
| 218 | + registrationOpen := h.isRegistrationAllowed(c) | |
| 212 | 219 | data := map[string]any{ |
| 213 | 220 | "Title": title, |
| 214 | 221 | "Lang": lang, |
| ... | ... | @@ -216,7 +223,9 @@ func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any { |
| 216 | 223 | "CSRFToken": csrf, |
| 217 | 224 | "CurrentUser": u, |
| 218 | 225 | "Flash": flash, |
| 219 | - "RegistrationEnabled": h.isRegistrationAllowed(c), | |
| 226 | + "RegistrationMode": registrationMode, | |
| 227 | + "RegistrationOpen": registrationOpen, | |
| 228 | + "RegistrationEnabled": registrationOpen, | |
| 220 | 229 | "ExploreEnabled": h.db.IsExploreEnabled(ctx), |
| 221 | 230 | "LandingEnabled": h.db.IsLandingEnabled(ctx, h.cfg.Landing.Enabled), |
| 222 | 231 | "AnonymousReadEnabled": anonRead, |
| ... | ... | @@ -293,7 +302,7 @@ func (h *Handler) isRegistrationAllowed(c *fiber.Ctx) bool { |
| 293 | 302 | if cnt, err := h.userSvc.Count(c.UserContext()); err == nil && cnt == 0 { |
| 294 | 303 | return true |
| 295 | 304 | } |
| 296 | - return h.db.IsRegistrationEnabled(c.UserContext(), h.cfg.Auth.RegistrationEnabled) | |
| 305 | + return h.db.GetRegistrationMode(c.UserContext(), h.cfg.Auth.RegistrationEnabled) != db.RegistrationModeClosed | |
| 297 | 306 | } |
| 298 | 307 | |
| 299 | 308 | func (h *Handler) render(c *fiber.Ctx, status int, page string, data map[string]any) error { |
internal/web/handlers/media.go
+81
-0
| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/base64" | |
| 5 | + "errors" | |
| 6 | + "io" | |
| 7 | + "net/http" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "github.com/gofiber/fiber/v2" | |
| 11 | +) | |
| 12 | + | |
| 13 | +const ( | |
| 14 | + maxAvatarImageBytes = 2 << 20 | |
| 15 | + maxBackgroundImageBytes = 6 << 20 | |
| 16 | +) | |
| 17 | + | |
| 18 | +var ( | |
| 19 | + errImageTooLarge = errors.New("image is too large") | |
| 20 | + errImageType = errors.New("unsupported image type") | |
| 21 | +) | |
| 22 | + | |
| 23 | +func validImageDataURI(value string) bool { | |
| 24 | + for _, prefix := range []string{ | |
| 25 | + "data:image/png;base64,", | |
| 26 | + "data:image/jpeg;base64,", | |
| 27 | + "data:image/webp;base64,", | |
| 28 | + "data:image/gif;base64,", | |
| 29 | + } { | |
| 30 | + if strings.HasPrefix(value, prefix) { | |
| 31 | + return true | |
| 32 | + } | |
| 33 | + } | |
| 34 | + return false | |
| 35 | +} | |
| 36 | + | |
| 37 | +// readImageField accepts either a browser-resized data URI or a multipart | |
| 38 | +// upload for one image field. An empty result means the field was not changed. | |
| 39 | +func readImageField(c *fiber.Ctx, dataField, fileField string, maxBytes int64) (string, error) { | |
| 40 | + if data := strings.TrimSpace(c.FormValue(dataField)); data != "" { | |
| 41 | + if !validImageDataURI(data) { | |
| 42 | + return "", errImageType | |
| 43 | + } | |
| 44 | + if int64(len(data)) > maxBytes*4/3+4096 { | |
| 45 | + return "", errImageTooLarge | |
| 46 | + } | |
| 47 | + return data, nil | |
| 48 | + } | |
| 49 | + | |
| 50 | + file, err := c.FormFile(fileField) | |
| 51 | + if err != nil || file == nil || file.Size <= 0 { | |
| 52 | + return "", nil | |
| 53 | + } | |
| 54 | + if file.Size > maxBytes { | |
| 55 | + return "", errImageTooLarge | |
| 56 | + } | |
| 57 | + | |
| 58 | + f, err := file.Open() | |
| 59 | + if err != nil { | |
| 60 | + return "", err | |
| 61 | + } | |
| 62 | + defer f.Close() | |
| 63 | + | |
| 64 | + buf, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) | |
| 65 | + if err != nil { | |
| 66 | + return "", err | |
| 67 | + } | |
| 68 | + if int64(len(buf)) > maxBytes { | |
| 69 | + return "", errImageTooLarge | |
| 70 | + } | |
| 71 | + mime := http.DetectContentType(buf) | |
| 72 | + if semi := strings.IndexByte(mime, ';'); semi >= 0 { | |
| 73 | + mime = mime[:semi] | |
| 74 | + } | |
| 75 | + switch mime { | |
| 76 | + case "image/png", "image/jpeg", "image/gif", "image/webp": | |
| 77 | + default: | |
| 78 | + return "", errImageType | |
| 79 | + } | |
| 80 | + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(buf), nil | |
| 81 | +} |
internal/web/handlers/repo.go
+17
-0
| ... | ... | @@ -347,6 +347,23 @@ func (h *Handler) RepoSettingsSubmit(c *fiber.Ctx) error { |
| 347 | 347 | return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) |
| 348 | 348 | } |
| 349 | 349 | |
| 350 | + if c.FormValue("remove_avatar") == "true" { | |
| 351 | + if err := h.repoSvc.UpdateAvatar(c.UserContext(), r.ID, ""); err != nil { | |
| 352 | + h.logger.Error("remove repository avatar failed", "repo_id", r.ID, "err", err) | |
| 353 | + h.setFlashT(c, "flash.image_save_failed") | |
| 354 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 355 | + } | |
| 356 | + } else if avatarData, err := readImageField(c, "avatar_data", "avatar_file", maxAvatarImageBytes); err != nil { | |
| 357 | + h.setFlashT(c, "flash.image_invalid") | |
| 358 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 359 | + } else if avatarData != "" { | |
| 360 | + if err := h.repoSvc.UpdateAvatar(c.UserContext(), r.ID, avatarData); err != nil { | |
| 361 | + h.logger.Error("update repository avatar failed", "repo_id", r.ID, "err", err) | |
| 362 | + h.setFlashT(c, "flash.image_save_failed") | |
| 363 | + return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 350 | 367 | h.setFlashT(c, "flash.repo_settings_saved") |
| 351 | 368 | return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther) |
| 352 | 369 | } |
internal/web/middleware/middleware.go
+1
-1
| ... | ... | @@ -90,7 +90,7 @@ func SessionAuth(authSvc *auth.Service, userSvc *user.Service) fiber.Handler { |
| 90 | 90 | sessID := c.Cookies(SessionCookieName) |
| 91 | 91 | if sessID != "" { |
| 92 | 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 { | |
| 93 | + if u, err := userSvc.GetByID(c.UserContext(), sess.UserID); err == nil && u != nil && u.IsApproved { | |
| 94 | 94 | c.Locals("user", u) |
| 95 | 95 | c.Locals("session", sess) |
| 96 | 96 | c.Locals("csrf_token", sess.CSRFToken) |
internal/web/static/css/app.css
+178
-1
| ... | ... | @@ -1477,4 +1477,181 @@ kbd, .kbd { |
| 1477 | 1477 | .gap-1\.5 { gap: 0.375rem; } |
| 1478 | 1478 | .gap-5 { gap: 1.25rem; } |
| 1479 | 1479 | .mb-1\.5 { margin-bottom: 0.375rem; } |
| 1480 | -.pt-1 { padding-top: 0.25rem; } | |
| 1480 | +.pt-1 { padding-top: 0.25rem; } | |
| 1481 | + | |
| 1482 | +/* Profile vignette cover */ | |
| 1483 | +.profile-hero { | |
| 1484 | + position: relative; | |
| 1485 | + overflow: hidden; | |
| 1486 | + isolation: isolate; | |
| 1487 | + padding: 1.4rem 1.45rem; | |
| 1488 | + border: 1px solid hsl(var(--border)); | |
| 1489 | + border-radius: calc(var(--radius) * 1.7); | |
| 1490 | + background: hsl(var(--card)); | |
| 1491 | +} | |
| 1492 | +.profile-hero.has-bg { | |
| 1493 | + border-color: hsl(var(--border) / 0.7); | |
| 1494 | + min-height: 12.5rem; | |
| 1495 | +} | |
| 1496 | +.profile-hero-bg { | |
| 1497 | + position: absolute; | |
| 1498 | + inset: 0; | |
| 1499 | + z-index: -2; | |
| 1500 | + pointer-events: none; | |
| 1501 | +} | |
| 1502 | +.profile-hero-bg img { | |
| 1503 | + width: 100%; | |
| 1504 | + height: 100%; | |
| 1505 | + object-fit: cover; | |
| 1506 | + object-position: center 32%; | |
| 1507 | + opacity: 0.92; | |
| 1508 | + -webkit-mask-image: radial-gradient(120% 150% at 50% 22%, #000 34%, rgba(0,0,0,0.9) 55%, transparent 100%); | |
| 1509 | + mask-image: radial-gradient(120% 150% at 50% 22%, #000 34%, rgba(0,0,0,0.9) 55%, transparent 100%); | |
| 1510 | +} | |
| 1511 | +.profile-hero.has-bg::after { | |
| 1512 | + content: ""; | |
| 1513 | + position: absolute; | |
| 1514 | + inset: 0; | |
| 1515 | + z-index: -1; | |
| 1516 | + pointer-events: none; | |
| 1517 | + background: | |
| 1518 | + linear-gradient(to top, hsl(var(--background)) 2%, hsl(var(--background) / 0.84) 44%, hsl(var(--background) / 0.18) 100%), | |
| 1519 | + radial-gradient(90% 90% at 12% 30%, hsl(var(--background) / 0.38), transparent 62%); | |
| 1520 | +} | |
| 1521 | +.profile-hero-content { | |
| 1522 | + position: relative; | |
| 1523 | + z-index: 1; | |
| 1524 | +} | |
| 1525 | +.profile-hero.has-bg .avatar-xxl { | |
| 1526 | + box-shadow: 0 0 0 4px hsl(var(--background) / 0.88), 0 0 0 5px hsl(var(--border)); | |
| 1527 | + backdrop-filter: blur(2px); | |
| 1528 | +} | |
| 1529 | + | |
| 1530 | +/* Media settings */ | |
| 1531 | +.background-preview { | |
| 1532 | + position: relative; | |
| 1533 | + width: 100%; | |
| 1534 | + aspect-ratio: 16 / 6; | |
| 1535 | + min-height: 7rem; | |
| 1536 | + overflow: hidden; | |
| 1537 | + border: 1px solid hsl(var(--border)); | |
| 1538 | + border-radius: calc(var(--radius) * 1.3); | |
| 1539 | + background: | |
| 1540 | + radial-gradient(100% 130% at 50% 30%, hsl(var(--muted)) 0%, hsl(var(--card)) 72%); | |
| 1541 | +} | |
| 1542 | +.background-preview img { | |
| 1543 | + width: 100%; | |
| 1544 | + height: 100%; | |
| 1545 | + object-fit: cover; | |
| 1546 | + object-position: center; | |
| 1547 | + display: block; | |
| 1548 | + -webkit-mask-image: radial-gradient(120% 145% at 50% 25%, #000 38%, rgba(0,0,0,0.86) 58%, transparent 100%); | |
| 1549 | + mask-image: radial-gradient(120% 145% at 50% 25%, #000 38%, rgba(0,0,0,0.86) 58%, transparent 100%); | |
| 1550 | +} | |
| 1551 | +.background-preview::after { | |
| 1552 | + content: ""; | |
| 1553 | + position: absolute; | |
| 1554 | + inset: 0; | |
| 1555 | + background: linear-gradient(to top, hsl(var(--background) / 0.75), hsl(var(--background) / 0.05)); | |
| 1556 | + pointer-events: none; | |
| 1557 | +} | |
| 1558 | +.background-preview.is-empty { | |
| 1559 | + display: flex; | |
| 1560 | + align-items: center; | |
| 1561 | + justify-content: center; | |
| 1562 | +} | |
| 1563 | +.background-preview-placeholder { | |
| 1564 | + color: hsl(var(--muted-foreground) / 0.7); | |
| 1565 | +} | |
| 1566 | + | |
| 1567 | +/* Registration policy selector */ | |
| 1568 | +.registration-options { | |
| 1569 | + display: grid; | |
| 1570 | + grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| 1571 | + gap: 0.55rem; | |
| 1572 | +} | |
| 1573 | +.registration-option { | |
| 1574 | + display: flex; | |
| 1575 | + align-items: flex-start; | |
| 1576 | + gap: 0.6rem; | |
| 1577 | + padding: 0.75rem; | |
| 1578 | + border: 1px solid hsl(var(--border)); | |
| 1579 | + border-radius: calc(var(--radius) * 1.2); | |
| 1580 | + background: hsl(var(--muted) / 0.18); | |
| 1581 | + cursor: pointer; | |
| 1582 | + transition: border-color 0.15s ease, background-color 0.15s ease; | |
| 1583 | +} | |
| 1584 | +.registration-option:hover { | |
| 1585 | + border-color: hsl(var(--foreground) / 0.28); | |
| 1586 | + background: hsl(var(--muted) / 0.32); | |
| 1587 | +} | |
| 1588 | +.registration-option:has(input:checked) { | |
| 1589 | + border-color: hsl(var(--foreground) / 0.72); | |
| 1590 | + background: hsl(var(--secondary)); | |
| 1591 | +} | |
| 1592 | +.registration-option input { | |
| 1593 | + margin-top: 0.14rem; | |
| 1594 | + flex-shrink: 0; | |
| 1595 | +} | |
| 1596 | +.registration-option strong, | |
| 1597 | +.registration-option small { | |
| 1598 | + display: block; | |
| 1599 | +} | |
| 1600 | +.registration-option strong { | |
| 1601 | + font-size: 0.8rem; | |
| 1602 | + line-height: 1.3; | |
| 1603 | +} | |
| 1604 | +.registration-option small { | |
| 1605 | + margin-top: 0.18rem; | |
| 1606 | + color: hsl(var(--muted-foreground)); | |
| 1607 | + font-size: 0.7rem; | |
| 1608 | + line-height: 1.4; | |
| 1609 | +} | |
| 1610 | + | |
| 1611 | +/* Admin actions */ | |
| 1612 | +.admin-row-actions { | |
| 1613 | + display: flex; | |
| 1614 | + align-items: center; | |
| 1615 | + justify-content: flex-end; | |
| 1616 | + gap: 0.25rem; | |
| 1617 | + flex-wrap: wrap; | |
| 1618 | +} | |
| 1619 | +.admin-row-actions form { | |
| 1620 | + margin: 0; | |
| 1621 | + display: inline-flex; | |
| 1622 | +} | |
| 1623 | +.admin-repo-row { | |
| 1624 | + gap: 0.75rem; | |
| 1625 | +} | |
| 1626 | +.admin-repo-link { | |
| 1627 | + display: flex; | |
| 1628 | + align-items: center; | |
| 1629 | + gap: 0.6rem; | |
| 1630 | + min-width: 0; | |
| 1631 | + flex: 1; | |
| 1632 | + color: inherit; | |
| 1633 | + text-decoration: none; | |
| 1634 | +} | |
| 1635 | +.admin-repo-link:hover strong { | |
| 1636 | + text-decoration: underline; | |
| 1637 | + text-underline-offset: 2px; | |
| 1638 | +} | |
| 1639 | + | |
| 1640 | +.segmented-btn { | |
| 1641 | + text-decoration: none; | |
| 1642 | +} | |
| 1643 | + | |
| 1644 | +@media (max-width: 760px) { | |
| 1645 | + .registration-options { | |
| 1646 | + grid-template-columns: 1fr; | |
| 1647 | + } | |
| 1648 | + .profile-hero { | |
| 1649 | + padding: 1.05rem; | |
| 1650 | + } | |
| 1651 | + .profile-hero.has-bg { | |
| 1652 | + min-height: 10.8rem; | |
| 1653 | + } | |
| 1654 | + .background-preview { | |
| 1655 | + aspect-ratio: 16 / 7; | |
| 1656 | + } | |
| 1657 | +} |
internal/web/static/js/app.js
+156
-0
| ... | ... | @@ -464,6 +464,162 @@ |
| 464 | 464 | } |
| 465 | 465 | }); |
| 466 | 466 | |
| 467 | + function resizeCoverImage(file, width, height, callback) { | |
| 468 | + var reader = new FileReader(); | |
| 469 | + reader.onload = function (ev) { | |
| 470 | + var img = new Image(); | |
| 471 | + img.onload = function () { | |
| 472 | + var canvas = document.createElement('canvas'); | |
| 473 | + canvas.width = width; | |
| 474 | + canvas.height = height; | |
| 475 | + var ctx = canvas.getContext('2d'); | |
| 476 | + var scale = Math.max(width / img.width, height / img.height); | |
| 477 | + var sw = width / scale; | |
| 478 | + var sh = height / scale; | |
| 479 | + var sx = (img.width - sw) / 2; | |
| 480 | + var sy = (img.height - sh) / 2; | |
| 481 | + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height); | |
| 482 | + var dataUrl = canvas.toDataURL('image/webp', 0.84); | |
| 483 | + if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) { | |
| 484 | + dataUrl = canvas.toDataURL('image/jpeg', 0.84); | |
| 485 | + } | |
| 486 | + callback(dataUrl); | |
| 487 | + }; | |
| 488 | + img.src = ev.target.result; | |
| 489 | + }; | |
| 490 | + reader.readAsDataURL(file); | |
| 491 | + } | |
| 492 | + | |
| 493 | + function resizeSquareImage(file, size, callback) { | |
| 494 | + var reader = new FileReader(); | |
| 495 | + reader.onload = function (ev) { | |
| 496 | + var img = new Image(); | |
| 497 | + img.onload = function () { | |
| 498 | + var canvas = document.createElement('canvas'); | |
| 499 | + canvas.width = size; | |
| 500 | + canvas.height = size; | |
| 501 | + var ctx = canvas.getContext('2d'); | |
| 502 | + var minSide = Math.min(img.width, img.height); | |
| 503 | + var sx = (img.width - minSide) / 2; | |
| 504 | + var sy = (img.height - minSide) / 2; | |
| 505 | + ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size); | |
| 506 | + var dataUrl = canvas.toDataURL('image/webp', 0.88); | |
| 507 | + if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) { | |
| 508 | + dataUrl = canvas.toDataURL('image/jpeg', 0.88); | |
| 509 | + } | |
| 510 | + callback(dataUrl); | |
| 511 | + }; | |
| 512 | + img.src = ev.target.result; | |
| 513 | + }; | |
| 514 | + reader.readAsDataURL(file); | |
| 515 | + } | |
| 516 | + | |
| 517 | + document.addEventListener('change', function (e) { | |
| 518 | + if (!e.target) return; | |
| 519 | + if (e.target.id === 'background-file-input') { | |
| 520 | + var bgFile = e.target.files && e.target.files[0]; | |
| 521 | + if (!bgFile) return; | |
| 522 | + resizeCoverImage(bgFile, 1600, 700, function (dataUrl) { | |
| 523 | + var hidden = document.getElementById('background-data-input'); | |
| 524 | + var remove = document.getElementById('remove-background-input'); | |
| 525 | + var preview = document.getElementById('background-preview'); | |
| 526 | + if (hidden) hidden.value = dataUrl; | |
| 527 | + if (remove) remove.value = 'false'; | |
| 528 | + if (preview) { | |
| 529 | + preview.classList.remove('is-empty'); | |
| 530 | + preview.innerHTML = '<img src="' + dataUrl + '" alt="">'; | |
| 531 | + } | |
| 532 | + }); | |
| 533 | + return; | |
| 534 | + } | |
| 535 | + if (e.target.id === 'repo-avatar-file-input') { | |
| 536 | + var repoFile = e.target.files && e.target.files[0]; | |
| 537 | + if (!repoFile) return; | |
| 538 | + resizeSquareImage(repoFile, 256, function (dataUrl) { | |
| 539 | + var hidden = document.getElementById('repo-avatar-data-input'); | |
| 540 | + var remove = document.getElementById('remove-repo-avatar-input'); | |
| 541 | + var preview = document.getElementById('repo-avatar-preview-wrap'); | |
| 542 | + if (hidden) hidden.value = dataUrl; | |
| 543 | + if (remove) remove.value = 'false'; | |
| 544 | + if (preview) { | |
| 545 | + preview.innerHTML = '<span class="avatar avatar-xl"><img src="' + dataUrl + '" alt="Repository avatar" class="avatar-img"></span>'; | |
| 546 | + } | |
| 547 | + }); | |
| 548 | + } | |
| 549 | + }); | |
| 550 | + | |
| 551 | + document.addEventListener('click', function (e) { | |
| 552 | + var removeBg = e.target.closest('#remove-background-btn'); | |
| 553 | + if (removeBg) { | |
| 554 | + e.preventDefault(); | |
| 555 | + var hidden = document.getElementById('background-data-input'); | |
| 556 | + var remove = document.getElementById('remove-background-input'); | |
| 557 | + var fileInput = document.getElementById('background-file-input'); | |
| 558 | + var preview = document.getElementById('background-preview'); | |
| 559 | + if (hidden) hidden.value = ''; | |
| 560 | + if (remove) remove.value = 'true'; | |
| 561 | + if (fileInput) fileInput.value = ''; | |
| 562 | + if (preview) { | |
| 563 | + preview.classList.add('is-empty'); | |
| 564 | + preview.innerHTML = '<span class="background-preview-placeholder"><svg class="icon size-5" aria-hidden="true"><use href="#hi-image"></use></svg></span>'; | |
| 565 | + } | |
| 566 | + removeBg.style.display = 'none'; | |
| 567 | + return; | |
| 568 | + } | |
| 569 | + | |
| 570 | + var removeRepoAvatar = e.target.closest('#remove-repo-avatar-btn'); | |
| 571 | + if (removeRepoAvatar) { | |
| 572 | + e.preventDefault(); | |
| 573 | + var hidden = document.getElementById('repo-avatar-data-input'); | |
| 574 | + var remove = document.getElementById('remove-repo-avatar-input'); | |
| 575 | + var fileInput = document.getElementById('repo-avatar-file-input'); | |
| 576 | + var preview = document.getElementById('repo-avatar-preview-wrap'); | |
| 577 | + if (hidden) hidden.value = ''; | |
| 578 | + if (remove) remove.value = 'true'; | |
| 579 | + if (fileInput) fileInput.value = ''; | |
| 580 | + if (preview) preview.innerHTML = '<span class="avatar avatar-xl">?</span>'; | |
| 581 | + removeRepoAvatar.style.display = 'none'; | |
| 582 | + return; | |
| 583 | + } | |
| 584 | + | |
| 585 | + var deleteUser = e.target.closest('[data-admin-delete-user]'); | |
| 586 | + if (deleteUser) { | |
| 587 | + e.preventDefault(); | |
| 588 | + var name = deleteUser.getAttribute('data-admin-delete-name') || ''; | |
| 589 | + var userForm = document.getElementById('admin-delete-user-form'); | |
| 590 | + var userInput = document.getElementById('admin_delete_confirm_user'); | |
| 591 | + var userTarget = document.getElementById('admin-delete-user-target'); | |
| 592 | + var userSubmit = document.getElementById('admin-delete-user-submit'); | |
| 593 | + if (userForm) userForm.setAttribute('action', '/admin/users/' + deleteUser.getAttribute('data-admin-delete-user') + '/delete'); | |
| 594 | + if (userTarget) userTarget.textContent = '@' + name; | |
| 595 | + if (userInput) { | |
| 596 | + userInput.value = ''; | |
| 597 | + userInput.setAttribute('data-confirm-input', name); | |
| 598 | + } | |
| 599 | + if (userSubmit) userSubmit.disabled = true; | |
| 600 | + openDialog('admin-delete-user-dialog'); | |
| 601 | + return; | |
| 602 | + } | |
| 603 | + | |
| 604 | + var deleteRepo = e.target.closest('[data-admin-delete-repo]'); | |
| 605 | + if (deleteRepo) { | |
| 606 | + e.preventDefault(); | |
| 607 | + var repoName = deleteRepo.getAttribute('data-admin-delete-name') || ''; | |
| 608 | + var repoForm = document.getElementById('admin-delete-repo-form'); | |
| 609 | + var repoInput = document.getElementById('admin_delete_confirm_repo'); | |
| 610 | + var repoTarget = document.getElementById('admin-delete-repo-target'); | |
| 611 | + var repoSubmit = document.getElementById('admin-delete-repo-submit'); | |
| 612 | + if (repoForm) repoForm.setAttribute('action', '/admin/repos/' + deleteRepo.getAttribute('data-admin-delete-repo') + '/delete'); | |
| 613 | + if (repoTarget) repoTarget.textContent = repoName; | |
| 614 | + if (repoInput) { | |
| 615 | + repoInput.value = ''; | |
| 616 | + repoInput.setAttribute('data-confirm-input', repoName); | |
| 617 | + } | |
| 618 | + if (repoSubmit) repoSubmit.disabled = true; | |
| 619 | + openDialog('admin-delete-repo-dialog'); | |
| 620 | + } | |
| 621 | + }); | |
| 622 | + | |
| 467 | 623 | // Repo header: the description stays on the title line and expands onto its |
| 468 | 624 | // own line when clicked. The avatar is sized to the title line so it always |
| 469 | 625 | // sits on the same row as the repository name, at a modest size. |
internal/web/views/layouts/base.html
+15
-4
| ... | ... | @@ -148,6 +148,11 @@ |
| 148 | 148 | <button type="button" class="btn btn-ghost btn-icon" data-open-palette="" aria-label="{{t .Lang "nav.command_palette"}}"> |
| 149 | 149 | {{icon "search" "size-4"}} |
| 150 | 150 | </button> |
| 151 | + {{if eq .Lang "en"}} | |
| 152 | + <a href="?lang=ru" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}" aria-label="{{t .Lang "nav.lang"}}">RU</a> | |
| 153 | + {{else}} | |
| 154 | + <a href="?lang=en" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "nav.lang"}}" aria-label="{{t .Lang "nav.lang"}}">EN</a> | |
| 155 | + {{end}} | |
| 151 | 156 | {{if .CurrentUser}} |
| 152 | 157 | <a href="/new" class="btn btn-primary btn-sm">{{icon "plus" "size-3.5"}}</a> |
| 153 | 158 | {{else}} |
| ... | ... | @@ -201,10 +206,16 @@ |
| 201 | 206 | {{end}} |
| 202 | 207 | </nav> |
| 203 | 208 | </div> |
| 204 | - <div class="segmented"> | |
| 205 | - <button type="button" class="segmented-btn" data-set-theme="light">{{t .Lang "theme.light"}}</button> | |
| 206 | - <button type="button" class="segmented-btn" data-set-theme="dark">{{t .Lang "theme.dark"}}</button> | |
| 207 | - <button type="button" class="segmented-btn" data-set-theme="system">{{t .Lang "theme.system"}}</button> | |
| 209 | + <div class="flex flex-col gap-2"> | |
| 210 | + <div class="segmented" role="group" aria-label="{{t .Lang "nav.lang"}}"> | |
| 211 | + <a href="?lang=ru" class="segmented-btn {{if eq .Lang "ru"}}active{{end}}">RU</a> | |
| 212 | + <a href="?lang=en" class="segmented-btn {{if eq .Lang "en"}}active{{end}}">EN</a> | |
| 213 | + </div> | |
| 214 | + <div class="segmented" role="group" aria-label="{{t .Lang "nav.theme"}}"> | |
| 215 | + <button type="button" class="segmented-btn {{if eq .Theme "light"}}active{{end}}" data-set-theme="light">{{t .Lang "theme.light"}}</button> | |
| 216 | + <button type="button" class="segmented-btn {{if eq .Theme "dark"}}active{{end}}" data-set-theme="dark">{{t .Lang "theme.dark"}}</button> | |
| 217 | + <button type="button" class="segmented-btn {{if eq .Theme "system"}}active{{end}}" data-set-theme="system">{{t .Lang "theme.system"}}</button> | |
| 218 | + </div> | |
| 208 | 219 | </div> |
| 209 | 220 | </div> |
| 210 | 221 | </div> |
internal/web/views/pages/admin.html
+157
-39
| ... | ... | @@ -4,13 +4,49 @@ |
| 4 | 4 | <h1 class="text-lg font-semibold" style="margin:0;">{{t .Lang "admin.title"}}</h1> |
| 5 | 5 | </div> |
| 6 | 6 | |
| 7 | + <!-- Registration policy --> | |
| 8 | + <section class="card mb-4"> | |
| 9 | + <div class="card-header"> | |
| 10 | + <span class="font-semibold flex items-center gap-2">{{icon "user" "size-4"}} {{t .Lang "admin.registration"}}</span> | |
| 11 | + </div> | |
| 12 | + <form method="post" action="/admin/settings" class="card-body flex flex-col gap-3"> | |
| 13 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 14 | + <div class="text-xs text-muted">{{t .Lang "admin.registration.mode"}}</div> | |
| 15 | + <div class="registration-options"> | |
| 16 | + <label class="registration-option"> | |
| 17 | + <input type="radio" name="registration_mode" value="public" {{if eq .RegistrationMode "public"}}checked{{end}}> | |
| 18 | + <span> | |
| 19 | + <strong>{{t .Lang "admin.registration.public"}}</strong> | |
| 20 | + <small>{{t .Lang "admin.registration.public_desc"}}</small> | |
| 21 | + </span> | |
| 22 | + </label> | |
| 23 | + <label class="registration-option"> | |
| 24 | + <input type="radio" name="registration_mode" value="confirm" {{if eq .RegistrationMode "confirm"}}checked{{end}}> | |
| 25 | + <span> | |
| 26 | + <strong>{{t .Lang "admin.registration.confirm"}}</strong> | |
| 27 | + <small>{{t .Lang "admin.registration.confirm_desc"}}</small> | |
| 28 | + </span> | |
| 29 | + </label> | |
| 30 | + <label class="registration-option"> | |
| 31 | + <input type="radio" name="registration_mode" value="closed" {{if eq .RegistrationMode "closed"}}checked{{end}}> | |
| 32 | + <span> | |
| 33 | + <strong>{{t .Lang "admin.registration.closed"}}</strong> | |
| 34 | + <small>{{t .Lang "admin.registration.closed_desc"}}</small> | |
| 35 | + </span> | |
| 36 | + </label> | |
| 37 | + </div> | |
| 38 | + <div class="flex justify-end"> | |
| 39 | + <button type="submit" class="btn btn-primary btn-sm">{{t .Lang "common.save"}}</button> | |
| 40 | + </div> | |
| 41 | + </form> | |
| 42 | + </section> | |
| 43 | + | |
| 7 | 44 | <!-- Feature Toggles (Modules & Access) --> |
| 8 | 45 | <section class="card mb-4"> |
| 9 | 46 | <div class="card-header"> |
| 10 | 47 | <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "admin.features"}}</span> |
| 11 | 48 | </div> |
| 12 | 49 | <div class="dense-list"> |
| 13 | - <!-- 1. Explore Page --> | |
| 14 | 50 | <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;"> |
| 15 | 51 | <div class="flex flex-col gap-1 min-w-0"> |
| 16 | 52 | <div class="flex items-center gap-2 font-medium text-sm"> |
| ... | ... | @@ -37,35 +73,6 @@ |
| 37 | 73 | </form> |
| 38 | 74 | </div> |
| 39 | 75 | |
| 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 | 76 | <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;"> |
| 70 | 77 | <div class="flex flex-col gap-1 min-w-0"> |
| 71 | 78 | <div class="flex items-center gap-2 font-medium text-sm"> |
| ... | ... | @@ -92,7 +99,6 @@ |
| 92 | 99 | </form> |
| 93 | 100 | </div> |
| 94 | 101 | |
| 95 | - <!-- 5. Anonymous Read Access --> | |
| 96 | 102 | <div class="dense-row" style="min-height:3.25rem;padding:0.65rem 0.9rem;"> |
| 97 | 103 | <div class="flex flex-col gap-1 min-w-0"> |
| 98 | 104 | <div class="flex items-center gap-2 font-medium text-sm"> |
| ... | ... | @@ -121,8 +127,8 @@ |
| 121 | 127 | </div> |
| 122 | 128 | </section> |
| 123 | 129 | |
| 124 | - <!-- Users Table --> | |
| 125 | - <section class="card"> | |
| 130 | + <!-- Users --> | |
| 131 | + <section class="card mb-4"> | |
| 126 | 132 | <div class="card-header"> |
| 127 | 133 | <span class="font-semibold flex items-center gap-2">{{icon "users" "size-4"}} {{t .Lang "admin.users"}} ({{len .Users}})</span> |
| 128 | 134 | </div> |
| ... | ... | @@ -130,28 +136,33 @@ |
| 130 | 136 | <table class="table"> |
| 131 | 137 | <thead> |
| 132 | 138 | <tr> |
| 133 | - <th>ID</th> | |
| 134 | 139 | <th>{{t .Lang "admin.col.user"}}</th> |
| 135 | - <th>{{t .Lang "auth.email"}}</th> | |
| 140 | + <th>{{t .Lang "admin.col.status"}}</th> | |
| 136 | 141 | <th>{{t .Lang "admin.col.repos"}}</th> |
| 137 | 142 | <th>{{t .Lang "admin.col.role"}}</th> |
| 138 | 143 | <th>{{t .Lang "admin.col.joined"}}</th> |
| 144 | + <th style="text-align:right;">{{t .Lang "admin.col.actions"}}</th> | |
| 139 | 145 | </tr> |
| 140 | 146 | </thead> |
| 141 | 147 | <tbody> |
| 142 | 148 | {{range .Users}} |
| 143 | 149 | <tr> |
| 144 | - <td class="font-mono text-xs text-muted">#{{.ID}}</td> | |
| 145 | 150 | <td> |
| 146 | 151 | <a href="/{{.Username}}" class="flex items-center gap-2 font-medium"> |
| 147 | 152 | {{avatar .Username .AvatarURL}} |
| 148 | - <div class="flex flex-col"> | |
| 153 | + <span class="flex flex-col"> | |
| 149 | 154 | <span>{{.Username}}</span> |
| 150 | 155 | {{if .FullName}}<span class="text-xs text-muted">{{.FullName}}</span>{{end}} |
| 151 | - </div> | |
| 156 | + </span> | |
| 152 | 157 | </a> |
| 153 | 158 | </td> |
| 154 | - <td class="text-xs text-muted">{{.Email}}</td> | |
| 159 | + <td> | |
| 160 | + {{if .IsApproved}} | |
| 161 | + <span class="badge badge-outline">{{t $.Lang "admin.approved"}}</span> | |
| 162 | + {{else}} | |
| 163 | + <span class="badge badge-primary">{{t $.Lang "admin.pending"}}</span> | |
| 164 | + {{end}} | |
| 165 | + </td> | |
| 155 | 166 | <td class="font-mono text-xs">{{.RepoCount}}</td> |
| 156 | 167 | <td> |
| 157 | 168 | {{if .IsAdmin}} |
| ... | ... | @@ -161,11 +172,118 @@ |
| 161 | 172 | {{end}} |
| 162 | 173 | </td> |
| 163 | 174 | <td class="text-xs text-muted">{{timeAgo .CreatedAt $.Lang}}</td> |
| 175 | + <td> | |
| 176 | + <div class="admin-row-actions"> | |
| 177 | + {{if not .IsApproved}} | |
| 178 | + <form method="post" action="/admin/users/{{.ID}}/approve"> | |
| 179 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 180 | + <button type="submit" class="btn btn-primary btn-sm" title="{{t $.Lang "admin.approve"}}"> | |
| 181 | + {{icon "check" "size-3.5"}}<span class="hidden md-flex">{{t $.Lang "admin.approve"}}</span> | |
| 182 | + </button> | |
| 183 | + </form> | |
| 184 | + {{end}} | |
| 185 | + <form method="post" action="/admin/users/{{.ID}}/admin"> | |
| 186 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 187 | + <button type="submit" class="btn btn-ghost btn-sm" title="{{if .IsAdmin}}{{t $.Lang "admin.remove_admin"}}{{else}}{{t $.Lang "admin.make_admin"}}{{end}}"> | |
| 188 | + {{icon "shield" "size-3.5"}} | |
| 189 | + </button> | |
| 190 | + </form> | |
| 191 | + {{if ne .ID $.CurrentUser.ID}} | |
| 192 | + <button type="button" class="btn btn-ghost btn-sm text-destructive" title="{{t $.Lang "admin.delete_user"}}" | |
| 193 | + data-admin-delete-user="{{.ID}}" data-admin-delete-name="{{.Username}}"> | |
| 194 | + {{icon "trash" "size-3.5"}} | |
| 195 | + </button> | |
| 196 | + {{end}} | |
| 197 | + </div> | |
| 198 | + </td> | |
| 164 | 199 | </tr> |
| 165 | 200 | {{end}} |
| 166 | 201 | </tbody> |
| 167 | 202 | </table> |
| 168 | 203 | </div> |
| 169 | 204 | </section> |
| 205 | + | |
| 206 | + <!-- Repositories --> | |
| 207 | + <section class="card"> | |
| 208 | + <div class="card-header"> | |
| 209 | + <span class="font-semibold flex items-center gap-2">{{icon "git-branch" "size-4"}} {{t .Lang "admin.repos"}} ({{len .Repos}})</span> | |
| 210 | + </div> | |
| 211 | + {{if .Repos}} | |
| 212 | + <div class="dense-list"> | |
| 213 | + {{range .Repos}} | |
| 214 | + <div class="dense-row admin-repo-row"> | |
| 215 | + <a href="/{{.OwnerUsername}}/{{.Name}}" class="admin-repo-link"> | |
| 216 | + {{avatar .OwnerUsername (or .AvatarURL .OwnerAvatarURL)}} | |
| 217 | + <span class="flex flex-col min-w-0"> | |
| 218 | + <span class="text-sm"><span class="text-muted">{{.OwnerUsername}}/</span><strong>{{.Name}}</strong></span> | |
| 219 | + {{if .Description}}<span class="text-xs text-muted truncate">{{.Description}}</span>{{end}} | |
| 220 | + </span> | |
| 221 | + </a> | |
| 222 | + <div class="admin-row-actions"> | |
| 223 | + <span class="badge badge-outline">{{if .IsPrivate}}{{t $.Lang "repo.private"}}{{else}}{{t $.Lang "repo.public"}}{{end}}</span> | |
| 224 | + <button type="button" class="btn btn-ghost btn-sm text-destructive" title="{{t $.Lang "admin.delete_repo"}}" | |
| 225 | + data-admin-delete-repo="{{.ID}}" data-admin-delete-name="{{.Name}}"> | |
| 226 | + {{icon "trash" "size-3.5"}} | |
| 227 | + </button> | |
| 228 | + </div> | |
| 229 | + </div> | |
| 230 | + {{end}} | |
| 231 | + </div> | |
| 232 | + {{else}} | |
| 233 | + <div class="card-body text-xs text-muted">{{t .Lang "repo.empty_list"}}</div> | |
| 234 | + {{end}} | |
| 235 | + </section> | |
| 236 | +</div> | |
| 237 | + | |
| 238 | +<!-- Delete user confirmation --> | |
| 239 | +<div id="admin-delete-user-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "admin.delete_user_title"}}" aria-hidden="true"> | |
| 240 | + <div class="dialog-box"> | |
| 241 | + <div class="card-header"> | |
| 242 | + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "admin.delete_user_title"}}</span> | |
| 243 | + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button> | |
| 244 | + </div> | |
| 245 | + <form method="post" action="/admin/users/0/delete" id="admin-delete-user-form"> | |
| 246 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 247 | + <div class="card-body flex flex-col gap-3"> | |
| 248 | + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "admin.delete_user_desc"}}</p> | |
| 249 | + <div class="text-sm">{{t .Lang "admin.col.user"}}: <strong class="font-mono" id="admin-delete-user-target"></strong></div> | |
| 250 | + <div> | |
| 251 | + <label class="label" for="admin_delete_confirm_user">{{t .Lang "admin.confirm_user"}}</label> | |
| 252 | + <input id="admin_delete_confirm_user" type="text" name="confirm_username" class="input font-mono" autocomplete="off" required | |
| 253 | + data-confirm-input="" data-confirm-button="admin-delete-user-submit"> | |
| 254 | + </div> | |
| 255 | + </div> | |
| 256 | + <div class="card-footer flex items-center justify-end gap-2"> | |
| 257 | + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button> | |
| 258 | + <button id="admin-delete-user-submit" type="submit" class="btn btn-destructive btn-sm" disabled>{{t .Lang "admin.delete_user"}}</button> | |
| 259 | + </div> | |
| 260 | + </form> | |
| 261 | + </div> | |
| 262 | +</div> | |
| 263 | + | |
| 264 | +<!-- Delete repository confirmation --> | |
| 265 | +<div id="admin-delete-repo-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "admin.delete_repo_title"}}" aria-hidden="true"> | |
| 266 | + <div class="dialog-box"> | |
| 267 | + <div class="card-header"> | |
| 268 | + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "admin.delete_repo_title"}}</span> | |
| 269 | + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button> | |
| 270 | + </div> | |
| 271 | + <form method="post" action="/admin/repos/0/delete" id="admin-delete-repo-form"> | |
| 272 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 273 | + <div class="card-body flex flex-col gap-3"> | |
| 274 | + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "admin.delete_repo_desc"}}</p> | |
| 275 | + <div class="text-sm">{{t .Lang "admin.repos"}}: <strong class="font-mono" id="admin-delete-repo-target"></strong></div> | |
| 276 | + <div> | |
| 277 | + <label class="label" for="admin_delete_confirm_repo">{{t .Lang "admin.confirm_repo"}}</label> | |
| 278 | + <input id="admin_delete_confirm_repo" type="text" name="confirm_name" class="input font-mono" autocomplete="off" required | |
| 279 | + data-confirm-input="" data-confirm-button="admin-delete-repo-submit"> | |
| 280 | + </div> | |
| 281 | + </div> | |
| 282 | + <div class="card-footer flex items-center justify-end gap-2"> | |
| 283 | + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button> | |
| 284 | + <button id="admin-delete-repo-submit" type="submit" class="btn btn-destructive btn-sm" disabled>{{t .Lang "admin.delete_repo"}}</button> | |
| 285 | + </div> | |
| 286 | + </form> | |
| 287 | + </div> | |
| 170 | 288 | </div> |
| 171 | 289 | {{end}} |
internal/web/views/pages/login.html
+1
-1
| ... | ... | @@ -19,7 +19,7 @@ |
| 19 | 19 | <span>{{t .Lang "auth.submit_login"}}</span> |
| 20 | 20 | </button> |
| 21 | 21 | </form> |
| 22 | - {{if .RegistrationEnabled}} | |
| 22 | + {{if .RegistrationOpen}} | |
| 23 | 23 | <div class="card-footer text-xs text-muted flex items-center justify-between"> |
| 24 | 24 | <span>{{t .Lang "auth.no_account"}}</span> |
| 25 | 25 | <a href="/register" class="font-medium" style="color:hsl(var(--foreground));">{{t .Lang "nav.register"}} →</a> |
internal/web/views/pages/profile.html
+4
-2
| ... | ... | @@ -1,8 +1,10 @@ |
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="container"> |
| 3 | 3 | <!-- Profile --> |
| 4 | - <section class="mb-4" style="padding-bottom:1.4rem;border-bottom:1px solid hsl(var(--border));"> | |
| 5 | - <div class="flex items-start flex-wrap"> | |
| 4 | + {{$bg := imageURL .ProfileUser.BackgroundURL}} | |
| 5 | + <section class="profile-hero mb-4{{if $bg}} has-bg{{end}}"> | |
| 6 | + {{if $bg}}<div class="profile-hero-bg" aria-hidden="true"><img src="{{$bg}}" alt=""></div>{{end}} | |
| 7 | + <div class="profile-hero-content flex items-start flex-wrap"> | |
| 6 | 8 | {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xxl"}} |
| 7 | 9 | <div class="flex-1 min-w-0" style="margin-left:1.6rem;min-width:15rem;"> |
| 8 | 10 | <div class="flex items-center gap-2 flex-wrap"> |
internal/web/views/pages/register.html
+6
-1
| ... | ... | @@ -4,7 +4,7 @@ |
| 4 | 4 | <div class="card-header"> |
| 5 | 5 | <h1 class="text-base font-semibold" style="margin:0;">{{t .Lang "auth.register.title"}}</h1> |
| 6 | 6 | </div> |
| 7 | - {{if not .RegistrationEnabled}} | |
| 7 | + {{if not .RegistrationOpen}} | |
| 8 | 8 | <div class="card-body text-sm text-muted"> |
| 9 | 9 | <div class="flex items-center gap-2 text-destructive"> |
| 10 | 10 | {{icon "lock" "size-4"}} |
| ... | ... | @@ -14,6 +14,11 @@ |
| 14 | 14 | {{else}} |
| 15 | 15 | <form method="post" action="/register" class="card-body flex flex-col gap-3"> |
| 16 | 16 | <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> |
| 17 | + {{if eq .RegistrationMode "confirm"}} | |
| 18 | + <div class="card p-2.5 text-xs text-muted" style="background:hsl(var(--muted) / 0.35);"> | |
| 19 | + {{t .Lang "auth.register.pending_notice"}} | |
| 20 | + </div> | |
| 21 | + {{end}} | |
| 17 | 22 | <div> |
| 18 | 23 | <label class="label" for="username">{{t .Lang "auth.username"}}</label> |
| 19 | 24 | <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}$"> |
internal/web/views/pages/repo_settings.html
+26
-1
| ... | ... | @@ -7,8 +7,33 @@ |
| 7 | 7 | <div class="card-header"> |
| 8 | 8 | <span class="font-semibold flex items-center gap-2">{{icon "settings" "size-4"}} {{t .Lang "repo.tab.settings"}}</span> |
| 9 | 9 | </div> |
| 10 | - <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" class="card-body flex flex-col gap-3"> | |
| 10 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" enctype="multipart/form-data" class="card-body flex flex-col gap-3"> | |
| 11 | 11 | <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> |
| 12 | + <input type="hidden" id="repo-avatar-data-input" name="avatar_data" value=""> | |
| 13 | + <input type="hidden" id="remove-repo-avatar-input" name="remove_avatar" value="false"> | |
| 14 | + | |
| 15 | + <div class="flex items-center gap-4 flex-wrap pb-3" style="border-bottom:1px solid hsl(var(--border));"> | |
| 16 | + <div id="repo-avatar-preview-wrap"> | |
| 17 | + {{avatar .Repo.OwnerUsername (or .Repo.AvatarURL .Repo.OwnerAvatarURL) "avatar-xl"}} | |
| 18 | + </div> | |
| 19 | + <div class="flex flex-col gap-1.5"> | |
| 20 | + <div class="text-sm font-medium">{{t .Lang "repo.avatar"}}</div> | |
| 21 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 22 | + <label class="btn btn-outline btn-sm" style="cursor:pointer;"> | |
| 23 | + {{icon "image" "size-3.5"}} | |
| 24 | + <span>{{t .Lang "repo.avatar_upload"}}</span> | |
| 25 | + <input id="repo-avatar-file-input" name="avatar_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none;"> | |
| 26 | + </label> | |
| 27 | + {{if .Repo.AvatarURL}} | |
| 28 | + <button type="button" id="remove-repo-avatar-btn" class="btn btn-ghost btn-sm text-destructive"> | |
| 29 | + {{icon "trash" "size-3.5"}} | |
| 30 | + <span>{{t .Lang "repo.avatar_remove"}}</span> | |
| 31 | + </button> | |
| 32 | + {{end}} | |
| 33 | + </div> | |
| 34 | + <div class="field-hint" style="margin:0;">{{t .Lang "repo.avatar_hint"}}</div> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 12 | 37 | |
| 13 | 38 | <div> |
| 14 | 39 | <label class="label" for="description">{{t .Lang "repo.description"}}</label> |
internal/web/views/pages/settings.html
+81
-0
| ... | ... | @@ -17,6 +17,8 @@ |
| 17 | 17 | <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> |
| 18 | 18 | <input type="hidden" id="avatar-data-input" name="avatar_data" value=""> |
| 19 | 19 | <input type="hidden" id="remove-avatar-input" name="remove_avatar" value="false"> |
| 20 | + <input type="hidden" id="background-data-input" name="background_data" value=""> | |
| 21 | + <input type="hidden" id="remove-background-input" name="remove_background" value="false"> | |
| 20 | 22 | |
| 21 | 23 | <!-- Avatar Picker Row --> |
| 22 | 24 | <div class="flex items-center gap-4 flex-wrap pb-3" style="border-bottom:1px solid hsl(var(--border));"> |
| ... | ... | @@ -42,6 +44,36 @@ |
| 42 | 44 | </div> |
| 43 | 45 | </div> |
| 44 | 46 | |
| 47 | + {{$bg := imageURL .CurrentUser.BackgroundURL}} | |
| 48 | + <div class="flex flex-col gap-2 pb-3" style="border-bottom:1px solid hsl(var(--border));"> | |
| 49 | + <div class="flex items-center justify-between gap-2 flex-wrap"> | |
| 50 | + <div> | |
| 51 | + <div class="text-sm font-medium">{{t .Lang "settings.background"}}</div> | |
| 52 | + <div class="field-hint" style="margin:0;">{{t .Lang "settings.background_hint"}}</div> | |
| 53 | + </div> | |
| 54 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 55 | + <label class="btn btn-outline btn-sm" style="cursor:pointer;"> | |
| 56 | + {{icon "image" "size-3.5"}} | |
| 57 | + <span>{{t .Lang "settings.background_upload"}}</span> | |
| 58 | + <input id="background-file-input" name="background_file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none;"> | |
| 59 | + </label> | |
| 60 | + {{if $bg}} | |
| 61 | + <button type="button" id="remove-background-btn" class="btn btn-ghost btn-sm text-destructive"> | |
| 62 | + {{icon "trash" "size-3.5"}} | |
| 63 | + <span>{{t .Lang "settings.background_remove"}}</span> | |
| 64 | + </button> | |
| 65 | + {{end}} | |
| 66 | + </div> | |
| 67 | + </div> | |
| 68 | + <div id="background-preview" class="background-preview{{if not $bg}} is-empty{{end}}"> | |
| 69 | + {{if $bg}} | |
| 70 | + <img src="{{$bg}}" alt=""> | |
| 71 | + {{else}} | |
| 72 | + <span class="background-preview-placeholder">{{icon "image" "size-5"}}</span> | |
| 73 | + {{end}} | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + | |
| 45 | 77 | <div class="grid-2"> |
| 46 | 78 | <div> |
| 47 | 79 | <label class="label">{{t .Lang "auth.username"}}</label> |
| ... | ... | @@ -180,5 +212,54 @@ |
| 180 | 212 | </div> |
| 181 | 213 | </form> |
| 182 | 214 | </section> |
| 215 | + | |
| 216 | + <!-- Danger Zone --> | |
| 217 | + <section id="danger" class="card mt-4" style="border-color:hsl(var(--destructive) / 0.45);"> | |
| 218 | + <div class="card-header" style="border-bottom-color:hsl(var(--destructive) / 0.2);"> | |
| 219 | + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "alert-circle" "size-4"}} {{t .Lang "settings.delete_account"}}</span> | |
| 220 | + </div> | |
| 221 | + <div class="card-body flex items-center justify-between flex-wrap gap-3"> | |
| 222 | + <div> | |
| 223 | + <div class="font-medium text-sm">{{t .Lang "settings.delete_account"}}</div> | |
| 224 | + <div class="text-xs text-muted">{{t .Lang "settings.delete_account_desc"}}</div> | |
| 225 | + </div> | |
| 226 | + <button type="button" class="btn btn-destructive btn-sm" data-dialog-open="delete-account-dialog"> | |
| 227 | + {{icon "trash" "size-3.5"}} | |
| 228 | + <span>{{t .Lang "settings.delete_account"}}</span> | |
| 229 | + </button> | |
| 230 | + </div> | |
| 231 | + </section> | |
| 232 | +</div> | |
| 233 | + | |
| 234 | +<!-- Delete Account Confirmation --> | |
| 235 | +<div id="delete-account-dialog" class="backdrop" role="dialog" aria-modal="true" aria-label="{{t .Lang "settings.delete_account"}}" aria-hidden="true"> | |
| 236 | + <div class="dialog-box"> | |
| 237 | + <div class="card-header"> | |
| 238 | + <span class="font-semibold text-destructive flex items-center gap-2">{{icon "trash" "size-4"}} {{t .Lang "settings.delete_account"}}</span> | |
| 239 | + <button type="button" class="btn btn-ghost btn-icon" data-dialog-close aria-label="{{t .Lang "common.close"}}">{{icon "close" "size-4"}}</button> | |
| 240 | + </div> | |
| 241 | + <form method="post" action="/settings/delete"> | |
| 242 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 243 | + <div class="card-body flex flex-col gap-3"> | |
| 244 | + <p class="text-sm text-muted" style="margin:0;">{{t .Lang "settings.delete_account_desc"}}</p> | |
| 245 | + <div> | |
| 246 | + <label class="label" for="confirm_delete_username">{{t .Lang "settings.delete_account_confirm"}} <strong class="font-mono" style="color:hsl(var(--foreground));">{{.CurrentUser.Username}}</strong></label> | |
| 247 | + <input id="confirm_delete_username" type="text" name="confirm_username" class="input font-mono" autocomplete="off" required | |
| 248 | + data-confirm-input="{{.CurrentUser.Username}}" data-confirm-button="confirm-delete-account-btn"> | |
| 249 | + </div> | |
| 250 | + <div> | |
| 251 | + <label class="label" for="delete_account_password">{{t .Lang "settings.delete_password"}}</label> | |
| 252 | + <input id="delete_account_password" type="password" name="password" class="input" required autocomplete="current-password"> | |
| 253 | + </div> | |
| 254 | + </div> | |
| 255 | + <div class="card-footer flex items-center justify-end gap-2"> | |
| 256 | + <button type="button" class="btn btn-outline btn-sm" data-dialog-close>{{t .Lang "common.cancel"}}</button> | |
| 257 | + <button id="confirm-delete-account-btn" type="submit" class="btn btn-destructive btn-sm" disabled> | |
| 258 | + {{icon "trash" "size-3.5"}} | |
| 259 | + <span>{{t .Lang "settings.delete_account_button"}}</span> | |
| 260 | + </button> | |
| 261 | + </div> | |
| 262 | + </form> | |
| 263 | + </div> | |
| 183 | 264 | </div> |
| 184 | 265 | {{end}} |
internal/web/views/partials/components.html
+1
-1
| ... | ... | @@ -45,7 +45,7 @@ |
| 45 | 45 | {{define "repo_header"}} |
| 46 | 46 | <div class="repo-banner mb-3{{if .Repo.ForkedFromRepoID}} has-subrow{{end}}"> |
| 47 | 47 | <div class="repo-banner-main"> |
| 48 | - <a href="/{{.Repo.OwnerUsername}}" class="repo-avatar-link">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL "repo-avatar"}}</a> | |
| 48 | + <a href="/{{.Repo.OwnerUsername}}" class="repo-avatar-link">{{avatar .Repo.OwnerUsername (or .Repo.AvatarURL .Repo.OwnerAvatarURL) "repo-avatar"}}</a> | |
| 49 | 49 | <div class="min-w-0 repo-head-block"> |
| 50 | 50 | <div class="repo-head-row"> |
| 51 | 51 | <nav class="breadcrumb" aria-label="Repository breadcrumb"> |
internal/web/views/views.go
+11
-0
| ... | ... | @@ -50,6 +50,7 @@ func NewEngine() (*Engine, error) { |
| 50 | 50 | "formatSize": formatBytes, |
| 51 | 51 | "avatarInitial": avatarInitial, |
| 52 | 52 | "avatar": renderAvatar, |
| 53 | + "imageURL": renderImageURL, | |
| 53 | 54 | "renderMD": func(s string) template.HTML { return RenderMarkdown([]byte(s)) }, |
| 54 | 55 | "add": func(a, b int) int { return a + b }, |
| 55 | 56 | "sub": func(a, b int) int { return a - b }, |
| ... | ... | @@ -130,6 +131,16 @@ func avatarInitial(username string) string { |
| 130 | 131 | return strings.ToUpper(string(runes[0])) |
| 131 | 132 | } |
| 132 | 133 | |
| 134 | +func renderImageURL(value string) template.URL { | |
| 135 | + value = strings.TrimSpace(value) | |
| 136 | + for _, prefix := range []string{"data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,", "/", "https://", "http://"} { | |
| 137 | + if strings.HasPrefix(value, prefix) { | |
| 138 | + return template.URL(value) | |
| 139 | + } | |
| 140 | + } | |
| 141 | + return "" | |
| 142 | +} | |
| 143 | + | |
| 133 | 144 | func renderAvatar(args ...any) template.HTML { |
| 134 | 145 | if len(args) == 0 { |
| 135 | 146 | return template.HTML(`<span class="avatar">?</span>`) |