panel: shared nav, account settings, backup page, connection settings
Phase 12 (UI/UX). The navigation bar now renders once from layout.html instead of being copied into each content template, so it is present on every authenticated page — including the domain page and its delete confirmation, which had no links at all — and the current page is highlighted via .Active rather than quietly dropping out of the list. New /account page changes the administrator's username and/or password: the current password is required and the attempt is throttled on the same limiter as the login form, so this route cannot be used to brute-force past that limit. A password change invalidates every other session while keeping the one performing it; a rename carries that session over. Backup and domain import move from a card in the middle of the domain list to their own /backup page, one card each; the handlers themselves are unchanged, only the page the import form renders its errors on. The domain page gains a "Sending server settings" card (server, port, encryption) so a client can be configured without reading the docs; 587 is listed only when SUBMISSION_ENABLE is true for this deployment, which is a deploy-time flag the panel cannot verify at runtime. Client-side (static/panel.js, no libraries): Copy buttons on the values that get carried elsewhere (DKIM record, new application credentials, server name), and the Addresses field is hidden while the address mode is wildcard, where the server ignores it. Verified in a container on the dev server: setup, login, every page's nav and active item, domain and application creation, all account-form paths including cross-session invalidation, import errors, full backup download. gofmt/vet/test/docker build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- panel: navigation bar is now rendered once from the shared layout, so every
|
||||
authenticated page has it — including the domain page and the delete
|
||||
confirmation, which had no navigation links at all — and the current page is
|
||||
highlighted instead of silently missing from the list.
|
||||
- panel: new *Account* page to change the administrator's username and/or
|
||||
password (the current password is required, throttled on the same limiter as
|
||||
the login form). Changing the password invalidates all other sessions.
|
||||
- panel: *Backup & migration* moved off the domain list onto its own *Backup*
|
||||
page, with the full backup and the domain import as two separate cards.
|
||||
- panel: the domain page now shows the *Sending server settings* (server,
|
||||
port and encryption) needed to configure a mail client; port 587 is listed
|
||||
only when `SUBMISSION_ENABLE=true` for this deployment.
|
||||
- panel: *Copy* buttons on the DKIM record, on a newly issued application
|
||||
login/password and on the sending server name.
|
||||
- panel: the *Addresses* field is hidden while an application's address mode is
|
||||
*Any address of the domain*, where the server ignores it.
|
||||
- ci: disable provenance attestation on release image push, so the ghcr.io
|
||||
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
|
||||
- security: optionally honour `X-Forwarded-For` for login/setup rate-limiting
|
||||
|
||||
@@ -39,7 +39,9 @@ docker compose up -d
|
||||
This starts SelfPost alone; it assumes Apache is already installed on the host
|
||||
as the reverse proxy (see below) and expects certificates at `./certs`. The
|
||||
first log line (`docker compose logs -f`) prints the one-time setup link —
|
||||
open it to create the admin account.
|
||||
open it to create the admin account. That username and password can be changed
|
||||
later from the panel's *Account* page (changing the password signs out every
|
||||
other session).
|
||||
|
||||
## Reverse proxy (mandatory)
|
||||
|
||||
@@ -101,7 +103,7 @@ Two related but distinct operations — spec 7.5:
|
||||
|
||||
- **Full backup** (whole `/data`: SQLite, all domains' DKIM keys, all
|
||||
applications' SASL credentials, `manifest.json` with the version that
|
||||
created it): panel button (dashboard → *Backup & migration*), or from the
|
||||
created it): panel button (*Backup* → *Full backup*), or from the
|
||||
host:
|
||||
```sh
|
||||
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||
@@ -113,8 +115,8 @@ Two related but distinct operations — spec 7.5:
|
||||
known version, there'd be no way to tell which image restoring a given
|
||||
backup actually requires.
|
||||
|
||||
- **Export/import a single domain** (dashboard → domain page → *Export
|
||||
domain*): moves one domain — its DKIM key and its applications' **working**
|
||||
- **Export/import a single domain** (domain page → *Export domain* to write the
|
||||
file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working**
|
||||
SASL passwords — to a different SelfPost instance without regenerating
|
||||
anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full
|
||||
restore, this works across different hostnames/instances.
|
||||
|
||||
@@ -28,6 +28,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
|
||||
@@ -58,6 +58,7 @@ type config struct {
|
||||
setupTokenPath string
|
||||
hostname string
|
||||
cookieSecure bool
|
||||
submissionEnabled bool
|
||||
trustedProxies []*net.IPNet
|
||||
|
||||
opendkimDir string
|
||||
@@ -86,6 +87,10 @@ func loadConfig() config {
|
||||
// Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a
|
||||
// development-only escape hatch for testing over plain HTTP.
|
||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||
// Whether this deployment also runs the 587 submission listener. The
|
||||
// panel only displays it as a client connection setting; the comparison
|
||||
// matches postfix-config.sh, which enables the listener on "true" alone.
|
||||
submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true",
|
||||
// Reverse-proxy addresses allowed to supply X-Forwarded-For for
|
||||
// rate-limiting (plan.md item A.1). Empty by default: an untrusted peer's
|
||||
// XFF header is trivially forgeable, so it's ignored unless the panel is
|
||||
|
||||
+8
-118
@@ -7,6 +7,10 @@
|
||||
Ниже остаётся только то, что **ещё не сделано**: открытые вопросы для
|
||||
согласования и опциональная линия 2.x.x.
|
||||
|
||||
**Фаза 12 (UI/UX: общий nav-partial, `/account`, `/backup`, параметры
|
||||
подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в
|
||||
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
|
||||
|
||||
**Основа:** [specification.md](specification.md) v1.0.
|
||||
|
||||
---
|
||||
@@ -27,7 +31,7 @@
|
||||
3. **CSRF — только `SameSite=Lax`, без токенов.** Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». **Вопрос:** считать `SameSite=Lax` достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен.
|
||||
4. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`). Префикс `__Host-` дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. **Вопрос:** переименовать (учесть dev-режим `PANEL_COOKIE_SECURE=false` — `__Host-` требует `Secure`, т.е. только когда secure включён).
|
||||
5. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл `/data/setup-token` (0600) — альтернатива, уже реализована в коде ([internal/web/setup.go](../internal/web/setup.go) `announce`/token file). **Решено:** базовый вариант объявления — stdout (по ТЗ), код не меняется. Остаётся только осветить это в пользовательской документации и указать на уже существующий файл `/data/setup-token` как более защищённую альтернативу для тех, у кого логи контейнера уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.**
|
||||
6. **Нет 2FA / смены пароля админа / нескольких админов из UI.** ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. **Решено:** добавить раздел настроек аккаунта (логин + пароль) — см. Фазу 12 ниже. 2FA и мульти-админ остаются явно 2.x/вне объёма.
|
||||
6. **Нет 2FA / смены пароля админа / нескольких админов из UI.** ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. **Решено и реализовано (Фаза 12):** добавлен раздел настроек аккаунта `/account` (логин + пароль, с проверкой текущего пароля и инвалидацией остальных сессий). 2FA и мульти-админ остаются явно 2.x/вне объёма.
|
||||
|
||||
### B. Надёжность и эксплуатация
|
||||
|
||||
@@ -46,120 +50,6 @@
|
||||
|
||||
---
|
||||
|
||||
## Фаза 12 (v1.x) — UI/UX: навигация, аккаунт, backup и параметры подключения
|
||||
|
||||
**Статус:** запланирована, не начата.
|
||||
|
||||
**Цель:** устранить конкретные недостатки панели, замеченные при использовании: (1) навигационная шапка не показывает, на какой странице находится админ; (2) нет способа сменить логин/пароль администратора после `/setup`, кроме пересоздания состояния; (3) полный бэкап и импорт домена свалены в один блок на дашборде, хотя это два разных по смыслу и риску действия; (4) страница домена не показывает сервер/порт/шифрование, нужные для настройки клиента отправки; (5) значения, которые нужно переносить во внешние сервисы (DKIM-запись, пароль приложения), нельзя скопировать одной кнопкой; (6) поле «Addresses» показывается даже в режиме, где оно не используется.
|
||||
|
||||
### A. Навигационная шапка обязательна на каждой странице панели
|
||||
|
||||
**Правило (закладывается на будущее, не только для текущих страниц):** навигационная шапка —
|
||||
обязательный элемент **каждой** аутентифицированной страницы панели, без исключений. Чтобы это не
|
||||
превращалось в чек-лист «не забыть добавить нав в очередной новый шаблон» (как уже случилось с
|
||||
`domain_detail.html`/`domain_delete.html`, см. ниже, и как иначе случилось бы с будущими `/account`,
|
||||
`/backup`, `/status` из этой же и следующей фазы) — нав должен рендериться **структурно**, из
|
||||
[layout.html](../internal/web/templates/layout.html) (общей обёртки всех страниц), а не копипастой
|
||||
в каждом content-шаблоне. Тогда гарантия «шапка есть везде» не зависит от того, вспомнил ли автор
|
||||
конкретной страницы её вставить.
|
||||
|
||||
Сейчас же топбар (`Domains`/`Send log`/`Queue`/`Log`) продублирован вручную и по-разному в разных
|
||||
content-шаблонах (каждый определяет `{{define "content"}}` независимо, `layout.html` их просто
|
||||
оборачивает, см. [layout.html:81](../internal/web/templates/layout.html)):
|
||||
- [dashboard.html](../internal/web/templates/dashboard.html), [queue.html](../internal/web/templates/queue.html), [sendlog.html](../internal/web/templates/sendlog.html), [logtail.html](../internal/web/templates/logtail.html) содержат нав-ссылки, но без выделения текущей страницы, и текущая страница обычно **не включает ссылку на саму себя** (например, `queue.html` не содержит пункта «Queue»), из-за чего создаётся впечатление, что кнопка «пропадает», а не выделяется;
|
||||
- [domain_detail.html](../internal/web/templates/domain_detail.html) и [domain_delete.html](../internal/web/templates/domain_delete.html) — **самая посещаемая страница панели** (там живут DKIM-запись, приложения, лимиты) — вообще **не содержат нав-ссылок**: топбар там ограничен `{{.User}}`/«Sign out», и чтобы перейти на Send log/Queue/Log, нужно сначала вернуться на «← All domains». Именно это отсутствие и стало поводом сформулировать правило выше как общее, а не как точечный фикс двух файлов.
|
||||
|
||||
**Реализация:**
|
||||
- Вынести `{{define "nav"}}` и вызвать его **из `layout.html`**, один раз, до `{{template "content" .}}` — не из отдельных content-шаблонов. Заодно туда же уходит и `{{.User}}`/«Sign out», которые сейчас тоже продублированы в топбаре каждого шаблона — так они тоже перестают зависеть от того, вспомнили их вставить или нет.
|
||||
- Content-шаблоны сохраняют только то, что специфично для страницы (заголовок `<h1>`, специфичные действия вроде будущего «Reload» на `/status`) — без списка нав-ссылок и без `{{.User}}`/Sign out.
|
||||
- Добавить в данные каждого шаблона поле `Active string` (`"domains"`/`"queue"`/`"sendlog"`/`"logtail"`, позже — `"status"`/`"account"`/`"backup"`) — простая правка в `handleDashboard`, `handleQueue`, `handleSendLog`, `handleLogTail`, `handleDomainDetail`, `handleDeleteConfirm`, а также во всех новых хендлерах, которые появятся в этой и следующей фазе. `layout.html` получает `.Active` наравне с остальными полями (`Title`, `User`), т.к. оборачивает все content-шаблоны одним и тем же кодом — ничего специально прокидывать через `content` не нужно.
|
||||
- Активный пункт рендерится как `<span aria-current="page" class="active">`, а не `<a>` (не ссылка сама на себя), остальные — обычные ссылки.
|
||||
- CSS: `.nav [aria-current]` — визуально выделенное состояние (цвет/подчёркивание/фон), уже в стиле остальной палитры `layout.html`.
|
||||
|
||||
### B. Раздел настроек аккаунта (логин, пароль)
|
||||
|
||||
Сейчас нет способа сменить логин или пароль администратора после `/setup` — только пересоздание состояния БД. `internal/store/admin.go` содержит только `CreateAdmin`/`GetAdmin`, без `Update*`.
|
||||
|
||||
- **Store:** `UpdateAdmin(username, passwordHash string) error` (один UPDATE по `id = 1`).
|
||||
- **Web:** новый маршрут `GET/POST /account` в authed-группе ([web.go](../internal/web/web.go)); `handleAccount` показывает форму (текущий логин, поля «новый логин», «текущий пароль», «новый пароль», «подтверждение нового пароля»); `handlePostAccount` проверяет текущий пароль (`bcrypt.CompareHashAndPassword`, как в `handleLogin`) перед применением изменений, затем вызывает `UpdateAdmin`.
|
||||
- Ссылка «Account» — в общей навигации (пункт A), рядом с `{{.User}}`/«Sign out».
|
||||
- **Security:** та же lockout-защита, что у логина (rate-limit по неверному «текущему паролю», чтобы `/account` нельзя было использовать для брутфорса пароля обходным путём); при успешной смене пароля — инвалидировать все активные in-memory сессии, кроме текущей (заставить перелогиниться остальные при параллельном доступе — здесь один админ, так что риск минимален, но защищает от кражи старой cookie).
|
||||
- **Шаблон:** `account.html` по образцу `login.html`/`setup.html` (`.card.narrow`).
|
||||
|
||||
### C. Backup/migration — отдельная страница, раздельные блоки
|
||||
|
||||
Сейчас на [dashboard.html](../internal/web/templates/dashboard.html) (строки 57–82) полный бэкап
|
||||
(«Download full backup») и импорт домена («Import a domain») — **два разных по смыслу и риску
|
||||
действия** — свалены в одну карточку «Backup & migration» посреди страницы со списком доменов.
|
||||
Экспорт одного домена при этом уже (правильно) живёт отдельно, на самой странице домена
|
||||
([domain_detail.html:181](../internal/web/templates/domain_detail.html)).
|
||||
|
||||
- Новый маршрут `GET /backup` (authed) с собственным пунктом в общей навигации (пункт A) — например «Backup».
|
||||
- На дашборде вместо целой карточки остаётся одна ссылка/карточка-указатель на `/backup` (или пункт навигации целиком заменяет карточку — решить при реализации, что выглядит чище).
|
||||
- На странице `/backup` — **две отдельные карточки**, не одна:
|
||||
1. **Full backup** — тот же текст и форма (`POST /backup`), что сейчас, без изменений в логике.
|
||||
2. **Import a domain** — та же форма (`POST /domains/import`), без изменений в логике.
|
||||
- Домен-экспорт на `domain_detail.html` не переносится — он уже привязан к конкретному домену и корректно расположен рядом с его данными; трогать не нужно.
|
||||
- Handler: новый `handleBackupPage` рендерит `backup.html`; существующие `handleBackup`/`handleImportDomain` не меняются (те же POST-маршруты).
|
||||
|
||||
**Готово, когда:** нав рендерится один раз из `layout.html` (не копипастой по content-шаблонам), поэтому присутствует на каждой странице панели, включая `domain_detail.html`/`domain_delete.html` (где нав-ссылок сейчас нет вовсе) и на всех будущих страницах этой и следующей фазы без отдельной правки каждой из них; текущий пункт визуально выделен и не дублируется как кликабельная ссылка на себя; `/account` позволяет сменить логин и/или пароль администратора с проверкой текущего пароля; после смены пароля старые сессии, кроме текущей, недействительны; `/backup` — отдельная страница с двумя визуально раздельными карточками (полный бэкап; импорт домена), дашборд больше не показывает эти формы напрямую; `gofmt`/`vet`/`test`/`docker build` зелёные.
|
||||
|
||||
**Риски:** смена логина не должна затронуть ничего, кроме таблицы `admin` (это учётная запись панели, не SASL-логины приложений — они хранятся отдельно в `applications`, см. Фазу 4); нужно убедиться, что rate-limit смены пароля не создаёт новый вектор lockout-DoS сверх уже описанного в п. A.1 открытых вопросов; вынос backup/import на отдельную страницу — чисто вёрстка, логика обработчиков `/backup` и `/domains/import` не меняется, так что риск регресса минимален.
|
||||
|
||||
### D. Параметры подключения клиента на странице домена
|
||||
|
||||
Сейчас [domain_detail.html](../internal/web/templates/domain_detail.html) показывает DKIM-запись и
|
||||
таблицу приложений (логин/режим адресов), но нигде на странице нет данных, нужных, чтобы **настроить
|
||||
почтовый клиент/скрипт для отправки** — сервер, порт, тип шифрования. Карточка «New application
|
||||
password» (строки 17–27) показывает только логин/пароль конкретного приложения — этого недостаточно
|
||||
без сервера/порта/шифрования рядом.
|
||||
|
||||
- Новая карточка **«Sending server settings»** на `domain_detail.html`, рядом с карточкой DKIM (до или после — решить при вёрстке), с фиксированными для всего инстанса значениями:
|
||||
- **Server:** `{{.Hostname}}` (то же значение `SELFPOST_HOSTNAME`, которое уже используется для setup-ссылки и SASL realm — сервер уже хранит его в `Server.cfg.Hostname`, [web.go:25](../internal/web/web.go); прокинуть в данные шаблона `domain_detail`, аналогично тому, как это уже сделано для `dashboard`/`setup`).
|
||||
- **Port 465 — SSL/TLS (implicit)** — всегда доступен (primary listener, spec 5).
|
||||
- **Port 587 — STARTTLS (submission)** — показывать строку только если включён `SUBMISSION_ENABLE` (сейчас это чисто deploy-время env, [.env.example:10](../deploy/.env.example); нужно завести `Config.SubmissionEnabled bool` в [web.go](../internal/web/web.go), прокинуть из `os.Getenv("SUBMISSION_ENABLE")` в [cmd/panel/main.go](../cmd/panel/main.go) по аналогии с `Hostname`). Если submission выключен — строку не показывать вовсе, а не показывать «недоступно», чтобы не путать.
|
||||
- **Username:** логин конкретного приложения — не общий для домена; сослаться на таблицу «Applications» ниже на той же странице, а не дублировать значение здесь (оно меняется per-application).
|
||||
- **Password:** пояснение, что пароль отображается один раз при создании/регенерации приложения (уже описано в карточке `NewCred`) — здесь не показываем.
|
||||
- Значения read-only (`.code`, как остальные DNS-блоки), без форм — это справочная информация, не настройка.
|
||||
|
||||
**Готово (доп. к критерию блока A/B/C):** страница домена показывает сервер/порт(ы)/шифрование, необходимые для настройки клиента отправки, без необходимости смотреть в документацию или `.env`; порт 587 отображается только когда submission действительно включён на этом инстансе.
|
||||
|
||||
**Риски:** `SubmissionEnabled` — deploy-time флаг (`docker-compose.yml`), а не что-то, что панель может проверить рантаймом (например, слушает ли порт 587 реально) — если оператор выставил `SUBMISSION_ENABLE=true` в `.env`, но не перезапустил compose с проброшенным портом 587, страница покажет строку, которая не работает. Отметить это как известное ограничение (как и остальные deploy/env-производные показатели), не решать в рамках этой фазы усложнением (например, реальной проверкой прослушивания порта — это ближе к `/status`, не к странице домена).
|
||||
|
||||
### E. Кнопка «Copy» на полях, которые переносятся во внешние сервисы
|
||||
|
||||
Значения из карточек `.code` сейчас нужно выделять мышью вручную — DKIM-запись (name/value),
|
||||
логин/пароль нового приложения, будущие поля из пункта D (сервер) регулярно копируются в другой
|
||||
интерфейс (DNS-панель регистратора, почтовый клиент, скрипт). Тривиально добавить копирование одной
|
||||
кнопкой.
|
||||
|
||||
- Общий JS-хелпер в [layout.html](../internal/web/templates/layout.html) (без библиотек): `navigator.clipboard.writeText(text)`, вызываемый по клику маленькой кнопки/иконки рядом с каждым `.code`-элементом; кратковременная визуальная обратная связь (например, текст кнопки на 1–2 секунды меняется на «Copied»).
|
||||
- Разметка: небольшая обёртка `.code-row` (код + кнопка `Copy`) вместо голого `.code`, значение бралось из `textContent` элемента (не из отдельного JS-массива — тогда не рассинхронизируется с тем, что реально показано).
|
||||
- Применить везде, где сейчас есть `.code` для значений, которые логично куда-то переносить: DKIM `Record.Name`/`Record.Value` ([domain_detail.html:35](../internal/web/templates/domain_detail.html), [domain_detail.html:41](../internal/web/templates/domain_detail.html)), `NewCred.Login`/`NewCred.Password` ([domain_detail.html:23](../internal/web/templates/domain_detail.html), [domain_detail.html:25](../internal/web/templates/domain_detail.html)), поля карточки «Sending server settings» из пункта D. Таблица приложений (`td.code` с логином) — тоже кандидат, но менее приоритетно (короткое значение, легко выделить руками).
|
||||
- **Секьюрити:** `navigator.clipboard.writeText` требует secure context (HTTPS или `localhost`) — в проде это всегда так (панель форсирует HTTPS), в dev-режиме с `PANEL_COOKIE_SECURE=false` по голому HTTP кнопка может тихо не сработать в некоторых браузерах. Не городить `document.execCommand('copy')`-fallback ради dev-режима — задокументировать как известное ограничение и/или обернуть в `try/catch`, чтобы отсутствие Clipboard API не ломало страницу, а просто оставляло текст доступным для ручного выделения.
|
||||
|
||||
**Готово:** у DKIM-записи, нового пароля приложения и параметров подключения (сервер) есть кнопка «Copy», по клику значение оказывается в буфере обмена и показывается краткое подтверждение.
|
||||
|
||||
**Риски:** нет побочных эффектов на бэкенд — чистая клиентская правка; единственный нюанс — secure-context ограничение Clipboard API в dev, описанное выше.
|
||||
|
||||
### F. Скрывать поле адресов в режиме «Any address»
|
||||
|
||||
Обе формы с выбором режима адресов — «Add an application» ([domain_detail.html:163–171](../internal/web/templates/domain_detail.html)) и «Edit mode» на существующем приложении ([domain_detail.html:72–79](../internal/web/templates/domain_detail.html)) — всегда показывают textarea «Addresses», даже когда выбран `Any address of the domain` (wildcard), где это поле не используется и просто игнорируется сервером. Из-за этого неочевидно, что поле относится только к режиму «List».
|
||||
|
||||
- Чистый клиентский JS (без изменений на сервере — валидация `mode`/`addresses` в `internal/app` не трогается, т.к. в wildcard-режиме бэкенд и так игнорирует содержимое textarea): на `change` у `<select name="mode">` показывать/прятать соответствующий `<label>`+`<textarea addresses>` через `hidden`/`display:none`, в зависимости от того, выбран ли `$.Wildcard` или `$.List`.
|
||||
- Начальное состояние при загрузке страницы — тоже должно учитывать текущее значение `select` (важно для формы «Edit mode», где `select` уже может быть предзаполнен значением `List`, а не только для формы добавления, где по умолчанию `Wildcard`).
|
||||
- Реализовать один небольшой переиспользуемый скрипт (или `<script>` внизу `domain_detail.html`), т.к. на странице несколько экземпляров этой пары select+textarea (форма добавления + одна на каждое существующее приложение в таблице).
|
||||
|
||||
**Готово:** при выборе «Any address of the domain» поле «Addresses» скрывается (и в форме добавления, и в «Edit mode» для существующих приложений); при выборе «Specific addresses (list)» — снова показывается с уже введённым значением, если было.
|
||||
|
||||
**Риски:** нет — чисто клиентское поведение, серверная валидация уже игнорирует `addresses` для wildcard-режима, так что скрытие поля ничего не меняет по сути обработки формы.
|
||||
|
||||
**Модель:** Sonnet (UI + рутинный CRUD, не риск-критичный тракт доставки).
|
||||
|
||||
**Зависимости:** не блокируется другими фазами; вводит общий `nav`-partial и `Active`-механизм, которым позже пользуется Фаза 13 (добавляет туда пункт «Status» и переносит «Domains» на `/domains`) — предпочтительно делать Фазу 12 первой.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов
|
||||
|
||||
**Статус:** запланирована, не начата.
|
||||
@@ -197,7 +87,7 @@ password» (строки 17–27) показывает только логин/
|
||||
- Список доменов (карточки «Add a sending domain» + «Domains» из [dashboard.html](../internal/web/templates/dashboard.html)) переезжает на новый маршрут `GET /domains` (тот же `handleDashboard`, просто перевешенный на другой путь; `POST /domains` для добавления домена остаётся как есть — коллизий с `GET /domains` нет, разные методы).
|
||||
- Корневой `GET /{$}` начинает рендерить `/status` (страницу из блока A) — либо редиректом `/{$}` → `/status`, либо status-хендлер напрямую вешается и на `/`, и на `/status` (без редиректа, чуть дешевле). Редирект проще и не создаёт двух путей для одного контента — предпочтительный вариант.
|
||||
- **Логин:** `handleLogin` после успешной аутентификации сейчас редиректит на `/` — поведение не меняется (просто `/` теперь означает статус, а не домены), правки в `handlers_auth.go` не требуется.
|
||||
- **Навигация (Фаза 12.A):** пункт «Status» становится первым в общем `nav`-partial и получает `Active == "status"`; пункт «Domains» указывает на `/domains` вместо `/`. Это расширяет список `Active`-значений, заведённый в Фазе 12, а не меняет его архитектуру.
|
||||
- **Навигация:** пункт «Status» становится первым в общем `nav`-partial ([layout.html](../internal/web/templates/layout.html), Фаза 12) и получает `Active == "status"`; пункт «Domains» указывает на `/domains` вместо `/`. Это расширяет список `Active`-значений, заведённый в Фазе 12, а не меняет его архитектуру.
|
||||
- Везде, где по коду сейчас зашит редирект/ссылка на `/` как «страница доменов» (например, `<a class="back" href="/">← Domains</a>` в `queue.html`, `sendlog.html`, `logtail.html`, `domain_detail.html`, `domain_delete.html`), ссылку нужно поменять на `/domains`.
|
||||
|
||||
**Готово (доп. к критерию блока A/B):** `GET /` открывает `/status`; список доменов доступен по `GET /domains`; все ссылки «← Domains» и пункт навигации «Domains» ведут на `/domains`; логин после успешной аутентификации попадает на страницу статуса.
|
||||
@@ -237,7 +127,7 @@ password» (строки 17–27) показывает только логин/
|
||||
|
||||
**Модель:** Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности).
|
||||
|
||||
**Зависимости:** использует общий `nav`-partial и `Active`-механизм навигации, вводимые Фазой 12 — реализовывать после неё (или расширить тот же PR); использует уже реализованные `internal/postfix.Queue()`, DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.
|
||||
**Зависимости:** нет — общий `nav`-partial и `Active`-механизм навигации уже введены Фазой 12; использует также уже реализованные `internal/postfix.Queue()`, DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.
|
||||
|
||||
---
|
||||
|
||||
@@ -248,7 +138,7 @@ password» (строки 17–27) показывает только логин/
|
||||
**Цель:** довести до кода два уже принятых, но пока не реализованных решения из
|
||||
раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76)
|
||||
(остальные пункты раздела A либо уже реализованы — п.1, либо являются открытыми
|
||||
вопросами без решения — п.3/4, либо уже вынесены в свою фазу — п.6/Фаза 12).
|
||||
вопросами без решения — п.3/4, либо уже реализованы в Фазе 12 — п.6).
|
||||
|
||||
### A. Security-заголовки ответа (пункт A.2)
|
||||
|
||||
|
||||
+3
-2
@@ -35,9 +35,10 @@
|
||||
## Текущее состояние
|
||||
|
||||
- **Базовый линейный план 0→11 (v1.0) полностью выполнен и принят** (аудит безопасности ТЗ 7.6 — полное соответствие, деплой в проде подтверждён). Подробности — в git-истории и `CHANGELOG.md`.
|
||||
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования) и **Фаза 12 (v1.x, запланирована, не начата)** — страница статуса `/status` (процессы/очередь/TLS/milter-сокеты/PTR) + DNS-статус домена (DKIM/SPF-эвристика/DMARC) на странице домена; согласована с пользователем, готова к реализации.
|
||||
- **Фаза 12 (v1.x, UI/UX) выполнена:** общий `nav`-partial в `layout.html` (нав на каждой аутентифицированной странице + подсветка текущей через `Active`), `/account` (смена логина/пароля админа с проверкой текущего пароля и инвалидацией остальных сессий, `store.UpdateAdmin`), `/backup` (полный бэкап и импорт домена — две отдельные карточки, убраны с дашборда), карточка «Sending server settings» на странице домена (сервер/465/587 по `SUBMISSION_ENABLE`), кнопки Copy у DKIM-записи и нового пароля приложения, скрытие поля «Addresses» в режиме wildcard (`static/panel.js`). Проверено в контейнере на dev-сервере (setup→login→домен→приложение→смена пароля→импорт), `gofmt`/`vet`/`test`/`docker build` зелёные.
|
||||
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), **Фаза 13 (v1.x, запланирована, не начата)** — страница статуса `/status` (процессы/очередь/TLS/milter-сокеты/PTR) + DNS-статус домена (DKIM/SPF-эвристика/DMARC), `/status` как стартовая страница и перенос кнопки Reload; **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
|
||||
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
|
||||
|
||||
## Рабочая петля (dev loop) — ВАЖНО
|
||||
|
||||
Локально (Windows, `<repo>`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → `rsync` дерева на сервер → `go build`/`go vet`/`docker build`/тесты там. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу).
|
||||
Локально (Windows, `<repo>`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → залить дерево на сервер → `go build`/`go vet`/`docker build`/тесты там. `rsync` в локальном git-bash нет, поэтому дерево едет tar'ом по ssh: `tar -czf - --exclude=.git . | ssh root@selfpost.example.com 'rm -rf /root/selfpost-src && mkdir -p /root/selfpost-src && tar -xzf - -C /root/selfpost-src'`; Go на сервере — в `/usr/local/go/bin` (не в `PATH` по умолчанию); образ — `docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .`. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу).
|
||||
|
||||
@@ -42,6 +42,29 @@ func (s *Store) CreateAdmin(username, passwordHash string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAdmin replaces the administrator's username and password hash. It
|
||||
// touches only the admin row (id = 1): panel credentials are unrelated to the
|
||||
// SASL logins applications authenticate with, which live in their own table.
|
||||
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
|
||||
// silently create an account.
|
||||
func (s *Store) UpdateAdmin(username, passwordHash string) error {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
|
||||
username, passwordHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update admin: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update admin: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNoAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
|
||||
func (s *Store) GetAdmin() (Admin, error) {
|
||||
var (
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
|
||||
t.Fatalf("CreateAdmin: %v", err)
|
||||
}
|
||||
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
|
||||
t.Fatalf("UpdateAdmin: %v", err)
|
||||
}
|
||||
|
||||
a, err := st.GetAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdmin: %v", err)
|
||||
}
|
||||
if a.Username != "operator" || a.PasswordHash != "hash-two" {
|
||||
t.Fatalf("unexpected admin after update: %+v", a)
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
t.Fatal("update dropped created_at")
|
||||
}
|
||||
}
|
||||
|
||||
// An update before setup must not create the account: only the one-time setup
|
||||
// flow may do that (spec 7.6.1).
|
||||
func TestUpdateAdminWithoutAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
|
||||
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
|
||||
}
|
||||
exists, err := st.AdminExists()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminExists: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("UpdateAdmin created an administrator")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// handleAccount serves the administrator's own account settings: the username
|
||||
// and password chosen during setup are the only panel credentials (spec 7.6.1),
|
||||
// and until now they could be changed only by recreating the state. Changing
|
||||
// them here never touches application SASL logins, which are a separate
|
||||
// identity system (spec 5.1).
|
||||
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
|
||||
case http.MethodPost:
|
||||
s.submitAccount(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// renderAccount draws the settings form. formUsername repopulates the username
|
||||
// field after a rejected submission; the password fields are never repopulated.
|
||||
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
||||
s.render(w, status, "account", map[string]any{
|
||||
"Title": "SelfPost — account",
|
||||
"User": currentUser(r),
|
||||
"Active": "account",
|
||||
"FormUsername": formUsername,
|
||||
"Error": formErr,
|
||||
"Flash": accountFlash(r),
|
||||
})
|
||||
}
|
||||
|
||||
// accountFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
func accountFlash(r *http.Request) string {
|
||||
switch r.URL.Query().Get("updated") {
|
||||
case "username":
|
||||
return "Username changed."
|
||||
case "password":
|
||||
return "Password changed. Any other signed-in sessions were signed out."
|
||||
case "both":
|
||||
return "Username and password changed. Any other signed-in sessions were signed out."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// submitAccount applies a username and/or password change. The current password
|
||||
// is always required, so a stolen session alone cannot lock the administrator
|
||||
// out of their own panel, and the attempt is throttled on the same limiter as
|
||||
// the login form so this route cannot be used to brute-force the password past
|
||||
// that limit (spec 7.6.5).
|
||||
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
||||
s.renderAccount(w, r, http.StatusTooManyRequests,
|
||||
"Too many attempts. Please wait and try again.", currentUser(r))
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
current := r.PostFormValue("current_password")
|
||||
password := r.PostFormValue("new_password")
|
||||
confirm := r.PostFormValue("new_password_confirm")
|
||||
|
||||
admin, err := s.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: account: get admin failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if username == "" {
|
||||
username = admin.Username
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
|
||||
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
|
||||
return
|
||||
}
|
||||
|
||||
renaming := username != admin.Username
|
||||
if renaming {
|
||||
if err := validateUsername(username); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An empty pair of new-password fields means "leave the password alone", so
|
||||
// the username can be changed on its own.
|
||||
repassword := password != "" || confirm != ""
|
||||
if repassword {
|
||||
if password != confirm {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(password); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !renaming && !repassword {
|
||||
s.renderAccount(w, r, http.StatusBadRequest,
|
||||
"Nothing to change: enter a new username, a new password, or both.", username)
|
||||
return
|
||||
}
|
||||
|
||||
hash := admin.PasswordHash
|
||||
if repassword {
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: account: hashing password failed: %v", err)
|
||||
s.renderAccount(w, r, http.StatusInternalServerError,
|
||||
"Internal error. Please try again.", username)
|
||||
return
|
||||
}
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAdmin(username, hash); err != nil {
|
||||
logf("panel: account: update admin failed: %v", err)
|
||||
msg := "Could not save the changes. Please check the logs and try again."
|
||||
if errors.Is(err, store.ErrNoAdmin) {
|
||||
msg = "There is no administrator account to update."
|
||||
}
|
||||
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep this session usable under the new name, and — when the password
|
||||
// changed — drop every other session so a cookie captured under the old
|
||||
// password stops working.
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
if renaming {
|
||||
s.sessions.Rename(c.Value, username)
|
||||
}
|
||||
if repassword {
|
||||
s.sessions.DestroyOthers(c.Value)
|
||||
}
|
||||
}
|
||||
|
||||
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
|
||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// updatedFlag names what changed, for the fixed post-redirect flash message.
|
||||
func updatedFlag(renamed, repassword bool) string {
|
||||
switch {
|
||||
case renamed && repassword:
|
||||
return "both"
|
||||
case renamed:
|
||||
return "username"
|
||||
default:
|
||||
return "password"
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,14 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
s.render(w, status, "domain_detail", map[string]any{
|
||||
"Title": "SelfPost — " + d.Name,
|
||||
"User": currentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
"Record": record,
|
||||
// Client connection settings (the same for every domain on this
|
||||
// instance): the hostname clients connect to, and whether the optional
|
||||
// submission listener is enabled in this deployment.
|
||||
"Hostname": s.cfg.Hostname,
|
||||
"SubmissionEnabled": s.cfg.SubmissionEnabled,
|
||||
"Apps": appViews,
|
||||
"Error": view.FormErr,
|
||||
"FormLogin": view.FormLogin,
|
||||
|
||||
@@ -17,6 +17,24 @@ import (
|
||||
// room while refusing anything large enough to be an abuse attempt.
|
||||
const maxImportBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// handleBackupPage renders the backup/migration screen: the full-server backup
|
||||
// and the domain import are separate actions with different risk, so each gets
|
||||
// its own card here rather than sharing a block on the domain list.
|
||||
func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderBackupPage(w, r, http.StatusOK, "")
|
||||
}
|
||||
|
||||
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
||||
// (spec 7.5.B) next to the form that produced it.
|
||||
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
||||
s.render(w, status, "backup", map[string]any{
|
||||
"Title": "SelfPost — backup",
|
||||
"User": currentUser(r),
|
||||
"Active": "backup",
|
||||
"ImportErr": importErr,
|
||||
})
|
||||
}
|
||||
|
||||
// handleBackup streams a full-server backup as a download (spec 7.5.A). It is an
|
||||
// authenticated admin action (this handler sits behind the auth middleware). The
|
||||
// archive carries DKIM private keys, the admin password hash and SASL
|
||||
@@ -75,17 +93,17 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
// domain on this instance (spec 7.5.B). The domain name is normalised and
|
||||
// validated here (spec 7.6.2); the domain service validates the selector, each
|
||||
// login and address, and the DKIM key before writing anything. On success it
|
||||
// redirects to the new domain's page; on failure it re-renders the dashboard
|
||||
// with a friendly message.
|
||||
// redirects to the new domain's page; on failure it re-renders the backup page,
|
||||
// where the import form lives, with a friendly message.
|
||||
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
||||
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Could not read the uploaded file (too large or not a valid upload).")
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Choose a domain export file to import.")
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
@@ -94,7 +112,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
dec := json.NewDecoder(file)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&exp); err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "That file is not a valid SelfPost domain export.")
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,7 +120,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
// same gate the add-domain form uses (spec 7.6.2).
|
||||
exp.Domain = normalizeDomain(exp.Domain)
|
||||
if err := validateDomain(exp.Domain); err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Invalid domain in export file: "+err.Error())
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,7 +128,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
logf("panel: import domain %q: %v", exp.Domain, err)
|
||||
status, msg := importErrorMessage(err)
|
||||
s.renderDashboard(w, r, status, "", "", msg)
|
||||
s.renderBackupPage(w, r, status, msg)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||
|
||||
@@ -13,14 +13,13 @@ import (
|
||||
// domains with their DKIM/selector and application counts, plus the add-domain
|
||||
// form (spec 7.2.2). Applications and the send log arrive in later phases.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDashboard(w, r, http.StatusOK, "", "", "")
|
||||
s.renderDashboard(w, r, http.StatusOK, "", "")
|
||||
}
|
||||
|
||||
// renderDashboard renders the domain list. formErr and formName repopulate the
|
||||
// add-domain form after a rejected submission; importErr surfaces a failed
|
||||
// domain import (spec 7.5.B); flash surfaces a one-shot status message keyed by
|
||||
// a redirect query flag (never reflected user input).
|
||||
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName, importErr string) {
|
||||
// add-domain form after a rejected submission; flash surfaces a one-shot status
|
||||
// message keyed by a redirect query flag (never reflected user input).
|
||||
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||||
domains, err := s.domains.List()
|
||||
if err != nil {
|
||||
logf("panel: dashboard: list domains: %v", err)
|
||||
@@ -30,10 +29,10 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
|
||||
s.render(w, status, "dashboard", map[string]any{
|
||||
"Title": "SelfPost",
|
||||
"User": currentUser(r),
|
||||
"Active": "domains",
|
||||
"Domains": domains,
|
||||
"Error": formErr,
|
||||
"FormName": formName,
|
||||
"ImportErr": importErr,
|
||||
"Flash": dashboardFlash(r),
|
||||
})
|
||||
}
|
||||
@@ -56,25 +55,25 @@ func dashboardFlash(r *http.Request) string {
|
||||
// publish is shown (spec 7.2.3).
|
||||
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "", "")
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||
return
|
||||
}
|
||||
raw := r.PostFormValue("name")
|
||||
name := normalizeDomain(raw)
|
||||
if err := validateDomain(name); err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw, "")
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
||||
return
|
||||
}
|
||||
|
||||
d, err := s.domains.Add(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrDomainExists) {
|
||||
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw, "")
|
||||
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
||||
return
|
||||
}
|
||||
logf("panel: add domain %q: %v", name, err)
|
||||
s.renderDashboard(w, r, http.StatusInternalServerError,
|
||||
"Could not add the domain. Please check the logs and try again.", raw, "")
|
||||
"Could not add the domain. Please check the logs and try again.", raw)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||
@@ -90,6 +89,7 @@ func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, http.StatusOK, "domain_delete", map[string]any{
|
||||
"Title": "SelfPost — delete " + d.Name,
|
||||
"User": currentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
data["Title"] = "SelfPost — send log"
|
||||
data["User"] = currentUser(r)
|
||||
data["Active"] = "sendlog"
|
||||
s.render(w, http.StatusOK, "sendlog", data)
|
||||
}
|
||||
|
||||
@@ -112,6 +113,7 @@ func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, http.StatusOK, "queue", map[string]any{
|
||||
"Title": "SelfPost — mail queue",
|
||||
"User": currentUser(r),
|
||||
"Active": "queue",
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
})
|
||||
@@ -144,6 +146,7 @@ func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, http.StatusOK, "logtail", map[string]any{
|
||||
"Title": "SelfPost — mail log",
|
||||
"User": currentUser(r),
|
||||
"Active": "logtail",
|
||||
"Lines": lines,
|
||||
"Error": errText,
|
||||
})
|
||||
|
||||
@@ -54,6 +54,32 @@ func (s *sessionStore) Lookup(token string) (string, bool) {
|
||||
return sess.username, true
|
||||
}
|
||||
|
||||
// Rename updates the username carried by a session, keeping its expiry. It is
|
||||
// used when the administrator renames their own account so the current session
|
||||
// keeps working under the new name.
|
||||
func (s *sessionStore) Rename(token, username string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.sessions[token]; ok {
|
||||
sess.username = username
|
||||
s.sessions[token] = sess
|
||||
}
|
||||
}
|
||||
|
||||
// DestroyOthers invalidates every session except keep. It is called when the
|
||||
// administrator changes their password: a stolen cookie issued under the old
|
||||
// password must stop working, while the admin performing the change stays
|
||||
// signed in.
|
||||
func (s *sessionStore) DestroyOthers(keep string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for token := range s.sessions {
|
||||
if token != keep {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy invalidates a session token (logout).
|
||||
func (s *sessionStore) Destroy(token string) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSessionRename(t *testing.T) {
|
||||
s := newSessionStore()
|
||||
token := s.Create("admin")
|
||||
|
||||
s.Rename(token, "operator")
|
||||
|
||||
name, ok := s.Lookup(token)
|
||||
if !ok {
|
||||
t.Fatal("session lost after rename")
|
||||
}
|
||||
if name != "operator" {
|
||||
t.Fatalf("session username = %q, want %q", name, "operator")
|
||||
}
|
||||
}
|
||||
|
||||
// A password change must invalidate every other session (so a cookie captured
|
||||
// under the old password stops working) while keeping the one performing the
|
||||
// change signed in.
|
||||
func TestSessionDestroyOthers(t *testing.T) {
|
||||
s := newSessionStore()
|
||||
keep := s.Create("admin")
|
||||
other := s.Create("admin")
|
||||
|
||||
s.DestroyOthers(keep)
|
||||
|
||||
if _, ok := s.Lookup(keep); !ok {
|
||||
t.Fatal("current session was destroyed")
|
||||
}
|
||||
if _, ok := s.Lookup(other); ok {
|
||||
t.Fatal("other session survived")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Panel progressive enhancement. Everything here is optional convenience: the
|
||||
// pages are fully usable with JavaScript disabled or blocked, and nothing is
|
||||
// sent to the server from this file.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// --- Copy buttons on .code values ------------------------------------
|
||||
// Values that get carried into another interface (a DNS panel, a mail
|
||||
// client) sit in a .code-row wrapper next to a Copy button. The text is read
|
||||
// from the .code element itself, so it can never drift from what is shown.
|
||||
// navigator.clipboard needs a secure context (HTTPS or localhost); over plain
|
||||
// HTTP in development it is simply absent, in which case the value stays
|
||||
// selectable by hand.
|
||||
document.addEventListener("click", function (ev) {
|
||||
var button = ev.target.closest("button.copy");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
var row = button.closest(".code-row");
|
||||
var code = row && row.querySelector(".code");
|
||||
if (!code || !navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(code.textContent).then(function () {
|
||||
var original = button.textContent;
|
||||
button.textContent = "Copied";
|
||||
setTimeout(function () {
|
||||
button.textContent = original;
|
||||
}, 1500);
|
||||
}, function () {
|
||||
/* Clipboard refused (permissions, insecure context): leave the page be. */
|
||||
});
|
||||
});
|
||||
|
||||
// --- Address list shown only in list mode -----------------------------
|
||||
// The "Addresses" field applies to list mode only; in wildcard mode the
|
||||
// server ignores it, so hiding it removes a field that does nothing. The
|
||||
// toggle runs on load too, because the edit form of an existing application
|
||||
// may already be set to list mode.
|
||||
function syncAddressField(select) {
|
||||
var form = select.closest("form");
|
||||
var field = form && form.querySelector("[data-addresses]");
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
// The mode values come from the server (store.AddressModeList), so the
|
||||
// select carries the one that means "list" rather than this script
|
||||
// hard-coding it.
|
||||
field.hidden = select.value !== select.dataset.listMode;
|
||||
}
|
||||
|
||||
function initAddressFields(root) {
|
||||
root.querySelectorAll("select[data-list-mode]").forEach(function (select) {
|
||||
syncAddressField(select);
|
||||
select.addEventListener("change", function () {
|
||||
syncAddressField(select);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initAddressFields(document);
|
||||
});
|
||||
})();
|
||||
@@ -26,6 +26,8 @@ var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"account": {"templates/account.html"},
|
||||
"backup": {"templates/backup.html"},
|
||||
"domain_detail": {"templates/domain_detail.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
|
||||
@@ -72,6 +74,14 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// The layout's navigation compares .Active against each item, so the key
|
||||
// must exist on every authenticated page. Defaulting it here keeps a page
|
||||
// that forgets it from failing to render — it simply highlights nothing.
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if _, has := m["Active"]; !has {
|
||||
m["Active"] = ""
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
logf("panel: render %s: %v", page, err)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<h1>Account</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card narrow">
|
||||
<h2>Panel credentials</h2>
|
||||
<p class="muted">These are the credentials for this control panel only.
|
||||
Applications keep their own logins and passwords, which are not affected.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/account">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" name="current_password" type="password"
|
||||
autocomplete="current-password" required>
|
||||
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
||||
|
||||
<label for="new_password_confirm">Confirm new password</label>
|
||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
</form>
|
||||
<p class="muted">Leave both new-password fields empty to change the username
|
||||
only. Changing the password signs out every other session; this one stays
|
||||
signed in.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<h1>Backup & migration</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Full backup</h2>
|
||||
<p class="muted">Download a full backup of all persistent state — the database,
|
||||
every domain's DKIM key and the application credentials. Use it to move the
|
||||
whole server to a new machine: restore it into a container of the
|
||||
<strong>same SelfPost version</strong>, with the same data mount, before first
|
||||
start. TLS certificates and the mail queue are not included.</p>
|
||||
<p class="muted"><strong>The backup file is a secret</strong> (it contains
|
||||
private keys and credentials). Store and transfer it securely and delete it
|
||||
once the restore succeeds.</p>
|
||||
<form class="inline" method="post" action="/backup">
|
||||
<button type="submit">Download full backup</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Import a domain</h2>
|
||||
<p class="muted">Move a single domain here from another SelfPost instance using
|
||||
a domain export file (from that domain's page). Its DKIM key and application
|
||||
passwords come across, so the published DNS record needs no change. The export
|
||||
file is a secret, like a full backup.</p>
|
||||
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/import" enctype="multipart/form-data">
|
||||
<label for="importfile">Domain export file</label>
|
||||
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
|
||||
<button type="submit">Import domain</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -2,16 +2,9 @@
|
||||
<div class="topbar">
|
||||
<h1>SelfPost</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/sendlog">Send log</a>
|
||||
<a href="/queue">Queue</a>
|
||||
<a href="/logtail">Log</a>
|
||||
<form class="inline" method="post" action="/reload">
|
||||
<button type="submit">Reload</button>
|
||||
</form>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,31 +46,4 @@
|
||||
<p class="muted">No domains yet. Add one above to get started.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Backup & migration</h2>
|
||||
<p class="muted">Download a full backup of all persistent state — the database,
|
||||
every domain's DKIM key and the application credentials. Use it to move the
|
||||
whole server to a new machine: restore it into a container of the
|
||||
<strong>same SelfPost version</strong>, with the same data mount, before first
|
||||
start. TLS certificates and the mail queue are not included.</p>
|
||||
<p class="muted"><strong>The backup file is a secret</strong> (it contains
|
||||
private keys and credentials). Store and transfer it securely and delete it
|
||||
once the restore succeeds.</p>
|
||||
<form class="inline" method="post" action="/backup">
|
||||
<button type="submit">Download full backup</button>
|
||||
</form>
|
||||
|
||||
<h2>Import a domain</h2>
|
||||
<p class="muted">Move a single domain here from another SelfPost instance using
|
||||
a domain export file (from that domain's page). Its DKIM key and application
|
||||
passwords come across, so the published DNS record needs no change. The export
|
||||
file is a secret, like a full backup.</p>
|
||||
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/import" enctype="multipart/form-data">
|
||||
<label for="importfile">Domain export file</label>
|
||||
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
|
||||
<button type="submit">Import domain</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/domains/{{.Domain.ID}}">← Back to {{.Domain.Name}}</a>
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/">← All domains</a>
|
||||
|
||||
@@ -20,9 +12,15 @@
|
||||
<p class="muted">This password is shown <strong>once only</strong> and is not
|
||||
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
||||
<label>Login</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.NewCred.Login}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
<label>Password</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.NewCred.Password}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -32,18 +30,47 @@
|
||||
It is not a secret and can be viewed at any time.</p>
|
||||
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Also configure SPF and DMARC for the domain (see the
|
||||
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sending server settings</h2>
|
||||
<p class="muted">Point the mail client or script at these settings and
|
||||
authenticate with an application login and password from the
|
||||
<strong>Applications</strong> section below. They are the same for every
|
||||
domain on this server.</p>
|
||||
|
||||
<label>Server</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Hostname}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Port and encryption</label>
|
||||
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
||||
587 — STARTTLS (submission){{end}}</span>
|
||||
|
||||
<p class="muted">Authentication is required on every port. The username is the
|
||||
application's login (see the table below) and the password is the one shown
|
||||
once when that application was created or its password regenerated — if it was
|
||||
lost, generate a new one.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Applications</h2>
|
||||
<p class="muted">Each application is a SASL login/password an app or script
|
||||
@@ -70,13 +97,15 @@
|
||||
<summary>Edit mode</summary>
|
||||
<form method="post" action="/applications/{{.ID}}/mode">
|
||||
<label>Address mode</label>
|
||||
<select name="mode">
|
||||
<select name="mode" data-list-mode="{{$.List}}">
|
||||
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
<label>Addresses (for list mode; one per line or comma-separated)</label>
|
||||
<div data-addresses>
|
||||
<label>Addresses (one per line or comma-separated)</label>
|
||||
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
||||
{{end}}{{$a}}{{end}}</textarea>
|
||||
</div>
|
||||
<button type="submit">Save mode</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -161,14 +190,16 @@
|
||||
value="{{.FormLogin}}" required>
|
||||
|
||||
<label for="mode">Address mode</label>
|
||||
<select id="mode" name="mode">
|
||||
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
||||
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
|
||||
<label for="addresses">Addresses (for list mode; one per line or comma-separated)</label>
|
||||
<div data-addresses>
|
||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="3"
|
||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Create application</button>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/static/favicon.png" type="image/png">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
@@ -75,11 +76,63 @@
|
||||
details form { margin-top: 0.6rem; }
|
||||
.credential { border-color: #f5c518; background: #fffbeb; }
|
||||
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
|
||||
/* Panel navigation: rendered once from this layout, so it is present on every
|
||||
authenticated page without each content template having to include it. */
|
||||
.nav {
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
|
||||
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid #e2e5e9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
|
||||
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; }
|
||||
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
|
||||
.nav .links [aria-current] {
|
||||
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
|
||||
}
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code { flex: 1; min-width: 0; }
|
||||
button.copy {
|
||||
flex: none; margin-top: 0.3rem; padding: 0.45rem 0.7rem; font-size: 0.8rem;
|
||||
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
|
||||
}
|
||||
button.copy:hover { background: #e2e7ee; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
button.copy { background: #22262b !important; border-color: #2b3138 !important; }
|
||||
button.copy:hover { background: #2b3138 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{if .User}}{{template "nav" .}}{{end}}
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
|
||||
every authenticated page has it without the page's own template having to
|
||||
remember to include it; .Active names the current page so it is highlighted
|
||||
instead of linking to itself. Unauthenticated pages (login, setup) carry no
|
||||
.User and get no nav. */}}
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
<div class="links">
|
||||
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/">Domains</a>{{end}}
|
||||
{{if eq .Active "sendlog"}}<span aria-current="page">Send log</span>{{else}}<a href="/sendlog">Send log</a>{{end}}
|
||||
{{if eq .Active "queue"}}<span aria-current="page">Queue</span>{{else}}<a href="/queue">Queue</a>{{end}}
|
||||
{{if eq .Active "logtail"}}<span aria-current="page">Log</span>{{else}}<a href="/logtail">Log</a>{{end}}
|
||||
{{if eq .Active "backup"}}<span aria-current="page">Backup</span>{{else}}<a href="/backup">Backup</a>{{end}}
|
||||
</div>
|
||||
<div class="session">
|
||||
<span class="muted">{{.User}}</span>
|
||||
{{if eq .Active "account"}}<span aria-current="page">Account</span>{{else}}<a href="/account">Account</a>{{end}}
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Mail log</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/">Domains</a>
|
||||
<a href="/sendlog">Send log</a>
|
||||
<a href="/queue">Queue</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/">← Domains</a>
|
||||
|
||||
<div class="card">
|
||||
<h2>mail.log tail</h2>
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Mail queue</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/">Domains</a>
|
||||
<a href="/sendlog">Send log</a>
|
||||
<a href="/logtail">Log</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/">← Domains</a>
|
||||
|
||||
<div class="card">
|
||||
<h2>postqueue -p</h2>
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>Send log</h1>
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<a href="/">Domains</a>
|
||||
<a href="/queue">Queue</a>
|
||||
<a href="/logtail">Log</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="back" href="/">← Domains</a>
|
||||
|
||||
<div class="card">
|
||||
<h2>Filter</h2>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The navigation is rendered from the layout, not copied into each page, so
|
||||
// every page template must resolve it. This is what makes "the nav is on every
|
||||
// authenticated page" a structural property instead of a checklist item.
|
||||
func TestEveryPageResolvesNav(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
for name, page := range tmpl.pages {
|
||||
if page.Lookup("nav") == nil {
|
||||
t.Errorf("page %q does not resolve the shared nav template", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavMarksActivePage(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "queue",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `<span aria-current="page">Queue</span>`) {
|
||||
t.Errorf("active page is not marked:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `href="/queue"`) {
|
||||
t.Errorf("active page still links to itself:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `href="/sendlog"`) {
|
||||
t.Errorf("inactive pages are not linked:\n%s", out)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -28,6 +28,11 @@ type Config struct {
|
||||
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
|
||||
// over plain HTTP in development, never for production.
|
||||
CookieSecure bool
|
||||
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
|
||||
// runs the 587/STARTTLS submission listener next to the primary 465 one
|
||||
// (spec 5). The panel only reports it on the domain page's connection
|
||||
// settings; it is a deploy-time flag, not something the panel can verify.
|
||||
SubmissionEnabled bool
|
||||
// MailLogPath is where Postfix's delivery log lives, read by the mail.log
|
||||
// monitoring view (spec 7.2.13). It is the same path the log-tailer role
|
||||
// follows in cmd/panel.
|
||||
@@ -132,7 +137,12 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
|
||||
authed.HandleFunc("POST /reload", s.handleReload)
|
||||
|
||||
// Full-server backup download (spec 7.5.A).
|
||||
// Administrator's own panel credentials.
|
||||
authed.HandleFunc("/account", s.handleAccount)
|
||||
|
||||
// Backup and migration: the page with both actions (spec 7.5.A-B), and the
|
||||
// full-server backup download itself.
|
||||
authed.HandleFunc("GET /backup", s.handleBackupPage)
|
||||
authed.HandleFunc("POST /backup", s.handleBackup)
|
||||
|
||||
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
|
||||
|
||||
Reference in New Issue
Block a user