Compare commits

...

3 Commits

Author SHA1 Message Date
mix 15baa1e5d0 feat(panel): add domain-admin role with per-domain authorization
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:43:59 +03:00
mix c9076655b9 docs: expand domain-admin plan with schema, routes, and UI spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:36:09 +03:00
mix e34d81498e release: 1.1.0
Close CHANGELOG [Unreleased] as 1.1.0; remove erroneous DMARC entries
retroactively copied into older sections; bump the compose pin and image
references.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-10 23:30:46 +03:00
34 changed files with 1641 additions and 391 deletions
+23 -36
View File
@@ -5,6 +5,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Added
- panel: **domain-admin role** — global administrators manage panel users and
assign domains; domain administrators see only their domains (applications,
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
backup, mail queue, system log, domain add/delete, and `/reload` stay
global-only. SQLite migration `0005_panel_users` migrates the single
administrator into a global user; sessions and full backup restore carry users
and domain bindings.
### Changed
- panel: `/` redirects domain administrators to `/domains`; global users still
land on `/status`. Navigation hides global-only sections for domain
administrators.
## [1.1.0] - 2026-08-10
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
packaging on every page, and an internal split of `internal/web` ahead of
domain-admin work. Upgrading runs one SQLite migration (empty defaults;
existing DNS guidance is unchanged until you set a report address).
### Changed ### Changed
- `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`, - `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
@@ -40,12 +63,6 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- `SECURITY.md` — how to report a vulnerability privately (GitHub private - `SECURITY.md` — how to report a vulnerability privately (GitHub private
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get vulnerability reporting, `public@mixeme.ru` as fallback), which releases get
fixes, and what is in and out of scope for a relay. No response time is fixes, and what is in and out of scope for a relay. No response time is
@@ -112,12 +129,6 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the - A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
`mail.log` lines Postfix wrote about that message, oldest first — the `mail.log` lines Postfix wrote about that message, oldest first — the
connection to the receiving server, its reply, and the status that reply was connection to the receiving server, its reply, and the status that reply was
@@ -239,12 +250,6 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- A page per delivery (`/deliveries/{id}`), reached from the *Details* link on - A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
every send-log row. It carries what the log itself no longer shows — the every send-log row. It carries what the log itself no longer shows — the
sending domain, the application the message was submitted under, the Postfix sending domain, the application the message was submitted under, the Postfix
@@ -460,12 +465,6 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- Optional password encryption for the two secret-bearing downloads (plan - Optional password encryption for the two secret-bearing downloads (plan
phase 1.5, `docs/code-review.md`): an *Encrypt with a password* checkbox on phase 1.5, `docs/code-review.md`): an *Encrypt with a password* checkbox on
the full-backup and domain-export forms writes a `.spbk` / `.spde` envelope the full-backup and domain-export forms writes a `.spbk` / `.spde` envelope
@@ -567,12 +566,6 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- The project's mark is now in use rather than only on file. The README opens - The project's mark is now in use rather than only on file. The README opens
with the full stamp; the panel carries the compact one at the left of its with the full stamp; the panel carries the compact one at the left of its
navigation bar, linking to the status page, and the full one above the card navigation bar, linking to the status page, and the full one above the card
@@ -805,12 +798,6 @@ of `docs/implementation-plan.md`).
### Added ### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- Panel (Go, single static binary) with SQLite persistence, one-time - Panel (Go, single static binary) with SQLite persistence, one-time
crypto-random setup link, bcrypt admin auth, session cookies. crypto-random setup link, bcrypt admin auth, session cookies.
- Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
+1 -1
View File
@@ -95,7 +95,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.0.1 ghcr.io/mixeme/selfpost:1.1.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+1 -1
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.0.1 image: ghcr.io/mixeme/selfpost:1.1.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+1 -1
View File
@@ -141,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
```sh ```sh
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default) make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
make build VERSION=1.0.1 make build VERSION=1.1.0
``` ```
Or directly: Or directly:
+1 -1
View File
@@ -381,7 +381,7 @@ but it can look like an open port in external scans.
## Fixed image tag ## Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.0.1`. Intermediate deliberately never `:latest`. The current pin is `1.1.0`. Intermediate
CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check above: image was published. Pinning matters because of the backup version check above:
the panel binary's embedded version and the image tag that produced it are the the panel binary's embedded version and the image tag that produced it are the
+204 -17
View File
@@ -3,37 +3,49 @@
**Status:** agreed **Status:** agreed
**Version:** target bump **1.x** MINOR, given a compatible migration of the **Version:** target bump **1.x** MINOR, given a compatible migration of the
current administrator into a global one. current administrator into a global one.
**Order:** recommended after [web-split](web-split.md), before **Order:** recommended after [web-split](web-split.md) (done), before
[inbound-relay](inbound-relay.md). [inbound-relay](inbound-relay.md).
--- ---
## What this is ## What this is
Today the panel has exactly one subject: `requireAuth` is a boolean gate, not a Today the panel has exactly one subject: `RequireAuth` is a boolean gate, not a
role ([web.go](../../internal/web/web.go) — the role ([web.go](../../internal/web/web.go) — the
`mux.Handle("/", s.requireAuth(authed))` wrapper), and the session carries `mux.Handle("/", s.auth.RequireAuth(authed))` wrapper), and the session carries
nothing beyond the fact of being signed in. nothing beyond the fact of being signed in.
The role grants access to **explicitly assigned domains** (one or several); the Two panel roles:
list of domains is set by the **global administrator**. For each domain on that
list:
- that domain's applications (creation, sender mode, password regeneration, | Role | Scope |
deletion, its own L2 limit); |------|-------|
- the domain's DKIM/DNS status; | **global** | Full panel except nothing new — same powers as today's single admin |
- the send log filtered to the domain — the filter already exists in the log | **domain_admin** | Only **assigned** domains (one or several; list set by global admin) |
([sendLogData](../../internal/web/handlers/handlers_monitor.go)).
What stays outside the role is what is global by nature: For each assigned domain, a domain-admin can:
- applications (create, sender mode, password regeneration, delete, L2 limit);
- DKIM/DNS status and recheck;
- per-domain DMARC `rua=` (inherit / none / custom) — full control on the
domain page;
- send log filtered to assigned domains;
- domain export (encrypted `.spde` optional, same as today);
- domain-level L2 rate limit.
What stays **global-only** (domain-admin gets 404 or redirect):
- adding and removing domains; - adding and removing domains;
- creating domain-admin users and assigning domains to them; - domain import;
- creating/editing/deleting panel users and assigning domains;
- `/reload`; - `/reload`;
- the full backup (that is all of `/data` including `sasldb2`, i.e. every - full backup (`/backup` all of `/data` including every domain's `sasldb2`);
domain at once); - mail queue (`/mail-queue*`);
- the queue and the `mail.log` tail — those are server-wide and not tied to a - system log tail (`/system-log*`);
domain. - status page (`/status*`) — server-wide health, queue summary, reload, DNS
recheck of the **hostname**; same treatment as queue and system log.
Domain-admin **self-service** on `/account`: username and password only (not
global DMARC report email).
## Why this extends v1.0 ## Why this extends v1.0
@@ -56,6 +68,181 @@ to one specific role, because what is needed is not a second all-powerful admin
but limited access for the owner of one or several domains, with the list set but limited access for the owner of one or several domains, with the list set
by the global administrator.)* by the global administrator.)*
---
## Schema and migration
**New migration** `0005_panel_users.sql`:
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
dmarc_report_email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE user_domains (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, domain_id)
);
-- Migrate existing administrator → global user (idempotent guard via admin count).
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
SELECT username, password_hash, 'global', dmarc_report_email, created_at
FROM admin WHERE id = 1;
DROP TABLE admin;
```
**Sessions:** keep `sessions.username` (no schema change). On login and
`RequireAuth`, resolve username → `User` row (role + domain IDs). Stale session
after username change behaves as today (`Lookup` fails → redirect login).
**Backup/restore:** full backup already snapshots `selfpost.db` via
`VACUUM INTO`; users and bindings restore with the DB. No manifest format change
required (same `selfpost-full-backup`).
**DMARC two levels:**
- **Global** `users.dmarc_report_email` — only on global user's `/account`;
default `rua=` when a domain uses *inherit*.
- **Per-domain** `domains.dmarc_rua` — domain-admin edits on the domain page
(existing handler); domain-admin never sees the global default field.
---
## Principal model
Request context carries a `Principal` (in `internal/web/auth`):
```go
type Role string // "global" | "domain_admin"
type Principal struct {
ID int64
Username string
Role Role
Domains []int64 // assigned domain IDs; empty for global (meaning "all")
}
```
Helpers:
- `CurrentPrincipal(r)` — from context;
- `IsGlobal(p)``p.Role == "global"`;
- `CanAccessDomain(p, domainID)` — global or `domainID` in `p.Domains`;
- `CanAccessApp(p, app)``CanAccessDomain(p, app.DomainID)`.
`lookupDomain` / `lookupApplication` in handlers call `CanAccess*` after
existence check; return 404 (not 403) to avoid leaking IDs.
---
## Route matrix
| Method | Path | global | domain_admin |
|--------|------|--------|--------------|
| GET | `/` | → `/status` | → `/domains` |
| GET | `/status`, `/status/fragment` | yes | **no** (404) |
| POST | `/status/recheck` | yes | **no** |
| GET | `/domains` | all domains | assigned only |
| POST | `/domains` | yes | **no** |
| POST | `/domains/import` | yes | **no** |
| GET | `/domains/{id}` | yes | assigned |
| POST | `/domains/{id}/dns-recheck` | yes | assigned |
| GET/POST | `/domains/{id}/delete` | yes | **no** |
| POST | `/domains/{id}/applications` | yes | assigned |
| POST | `/domains/{id}/ratelimit` | yes | assigned |
| POST | `/domains/{id}/dmarc` | yes | assigned |
| POST | `/domains/{id}/export` | yes | assigned |
| POST | `/applications/{aid}/*` | yes | if app in assigned domain |
| POST | `/reload` | yes | **no** |
| GET/POST | `/account` | username, password, global DMARC email | username, password only |
| GET/POST | `/backup` | yes | **no** |
| GET | `/deliveries*` | all (optional filter) | clamped to assigned domains |
| GET | `/mail-queue*` | yes | **no** |
| GET | `/system-log*` | yes | **no** |
| GET | `/users` | list users | **no** |
| GET/POST | `/users/new` | create user | **no** |
| GET/POST | `/users/{uid}` | edit/delete user | **no** |
| POST | `/logout` | yes | yes |
**Deliveries:** for domain-admin, `sendLogData` forces filter to assigned
domain set; dropdowns list only assigned domains/apps; reject `domain` query
param outside assignment; `HandleDelivery` checks log row's `domain` field.
---
## User management UI (global only)
New routes under `/users`:
- **List** — username, role, assigned domain names (or "all" for global).
- **Create** — username, password, role (`domain_admin` default), multi-select
domains (required when role is `domain_admin`).
- **Edit** — change password (optional), reassign domains, delete user.
- **Guards:** cannot delete the last `global` user; cannot demote self to
`domain_admin` without another global user; domain-admin role cannot access
these routes.
Templates: `users.html`, `user_form.html`; nav link visible only for global
users.
---
## Auth / setup / sessions
- **Setup** (`/setup/{token}`): unchanged semantics — creates first **global**
user via `CreateGlobalUser`; `AdminExists``UserExists`.
- **Login:** authenticate against `users` by username + bcrypt.
- **Password change:** per-user `UpdateUser`; domain-admin cannot change
another user's password.
- **Session rename / destroy others:** unchanged behaviour keyed by username.
---
## Navigation
[layout.html](../../internal/web/view/templates/layout.html) `nav` template:
- **global:** all items today (status, domains, deliveries, mail queue, system
log, backup, settings) + **Users**.
- **domain_admin:** domains, deliveries, settings only.
Pass `IsGlobal` (or `Principal`) into every rendered page.
---
## Security
- **CSRF:** keep origin-check-only for now ([security.md](../security.md) ADR);
note in CHANGELOG that multi-user panel reopens the ADR — no CSRF tokens in
this phase.
- **Export encryption:** optional password on domain export remains; full backup
encryption trigger ("second administrator") is satisfied by domain-admin
existing — no change required.
- **Authorization tests:** table-driven tests for global vs domain-admin on
representative handlers; explicit `{aid}` cross-domain mutation blocked.
---
## Implementation order
1. Migration `0005_panel_users.sql` + `store/users.go` (CRUD, domain bindings).
2. Auth: login against `users`, `Principal` in context, setup creates global user.
3. `CanAccessDomain` / `CanAccessApp`; harden `lookupDomain` / `lookupApplication`.
4. Route guards: global-only middleware or per-handler checks.
5. Filter lists: dashboard, deliveries, domain detail DMARC inherit source.
6. User management handlers + templates.
7. Nav visibility + default redirect (`/`).
8. Tests + `go build` / `go vet` / `go test`; CHANGELOG `[Unreleased]`.
---
## Done when ## Done when
- A global administrator and a domain-admin with different rights both work - A global administrator and a domain-admin with different rights both work
+1 -1
View File
@@ -1,6 +1,6 @@
# Plan: web-split (splitting `internal/web`) # Plan: web-split (splitting `internal/web`)
**Status:** done (see [CHANGELOG](../CHANGELOG.md) `[Unreleased]`) **Status:** done (see [CHANGELOG](../CHANGELOG.md) `[1.1.0]`)
**Version:** `1.x`; an internal refactor, it does not force a break on its own. **Version:** `1.x`; an internal refactor, it does not force a break on its own.
--- ---
-85
View File
@@ -1,85 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
var ErrNoAdmin = errors.New("no administrator account")
// Admin is the single panel administrator (security.md).
type Admin struct {
Username string
PasswordHash string
DMARCReportEmail string // default rua= for all sending domains; empty = none
CreatedAt time.Time
}
// AdminExists reports whether the administrator account has been created. This
// doubles as the "primary setup complete" flag: once true, the /setup route is
// permanently gone (security.md).
func (s *Store) AdminExists() (bool, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
return false, fmt.Errorf("count admin: %w", err)
}
return n > 0, nil
}
// CreateAdmin inserts the administrator row. It fails if one already exists,
// which — combined with the id=1 constraint — makes admin creation one-shot
// even under a race between two setup submissions.
func (s *Store) CreateAdmin(username, passwordHash string) error {
_, err := s.db.Exec(
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("create admin: %w", err)
}
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, dmarcReportEmail string) error {
res, err := s.db.Exec(
"UPDATE admin SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = 1",
username, passwordHash, dmarcReportEmail,
)
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 (
a Admin
createdAt string
)
err := s.db.QueryRow("SELECT username, password_hash, dmarc_report_email, created_at FROM admin WHERE id = 1").
Scan(&a.Username, &a.PasswordHash, &a.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return Admin{}, ErrNoAdmin
}
if err != nil {
return Admin{}, fmt.Errorf("get admin: %w", err)
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return a, nil
}
-56
View File
@@ -1,56 +0,0 @@
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", "reports@hub.example"); err != nil {
t.Fatalf("UpdateAdmin: %v", err)
}
a, err := st.GetAdmin()
if err != nil {
t.Fatalf("GetAdmin: %v", err)
}
if a.DMARCReportEmail != "reports@hub.example" {
t.Fatalf("dmarc email = %q", a.DMARCReportEmail)
}
if err := st.UpdateAdmin("operator", "hash-three", ""); err != nil {
t.Fatalf("clear dmarc email: %v", err)
}
a, err = st.GetAdmin()
if err != nil {
t.Fatalf("GetAdmin: %v", err)
}
if a.Username != "operator" || a.PasswordHash != "hash-three" {
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 (security.md).
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,26 @@
-- Panel users and domain-admin assignments. Migrates the single admin row into
-- a global user; drops the admin table.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
dmarc_report_email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE user_domains (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, domain_id)
);
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
SELECT username, password_hash, 'global', dmarc_report_email, created_at
FROM admin WHERE id = 1;
INSERT OR REPLACE INTO settings (key, value)
SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1;
DROP TABLE admin;
+32
View File
@@ -0,0 +1,32 @@
package store
import (
"database/sql"
"errors"
"fmt"
)
// GetSetting returns a settings value or empty string when missing.
func (s *Store) GetSetting(key string) (string, error) {
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get setting %s: %w", key, err)
}
return value, nil
}
// SetSetting upserts a settings key.
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
key, value,
)
if err != nil {
return fmt.Errorf("set setting %s: %w", key, err)
}
return nil
}
+364
View File
@@ -0,0 +1,364 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrNoUser is returned when primary setup has not happened yet.
var ErrNoUser = errors.New("no panel user")
// ErrUserNotFound is returned when a panel user id or username does not exist.
var ErrUserNotFound = errors.New("user not found")
// ErrUserExists is returned when a username is already taken.
var ErrUserExists = errors.New("username already taken")
// ErrLastGlobal is returned when deleting or demoting the last global user.
var ErrLastGlobal = errors.New("cannot remove last global administrator")
// Role identifies a panel user's access level.
type Role string
const (
RoleGlobal Role = "global"
RoleDomainAdmin Role = "domain_admin"
)
// User is a panel login (not an application SASL account).
type User struct {
ID int64
Username string
PasswordHash string
Role Role
DMARCReportEmail string
CreatedAt time.Time
DomainIDs []int64
}
// UserExists reports whether any panel user exists (setup complete).
func (s *Store) UserExists() (bool, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n); err != nil {
return false, fmt.Errorf("count users: %w", err)
}
return n > 0, nil
}
// CreateGlobalUser inserts the first global user during setup.
func (s *Store) CreateGlobalUser(username, passwordHash string) error {
exists, err := s.UserExists()
if err != nil {
return err
}
if exists {
return fmt.Errorf("create global user: users already exist")
}
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
username, passwordHash, RoleGlobal, now,
)
if err != nil {
return fmt.Errorf("create global user: %w", err)
}
return nil
}
// GetUserByUsername returns a user with domain assignments loaded.
func (s *Store) GetUserByUsername(username string) (User, error) {
var (
u User
createdAt string
)
err := s.db.QueryRow(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, fmt.Errorf("get user by username: %w", err)
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
if err != nil {
return User{}, err
}
return u, nil
}
// GetUser returns a user by id with domain assignments.
func (s *Store) GetUser(id int64) (User, error) {
var (
u User
createdAt string
)
err := s.db.QueryRow(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
id,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, fmt.Errorf("get user: %w", err)
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
if err != nil {
return User{}, err
}
return u, nil
}
// ListUsers returns every panel user without domain ids.
func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query(
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var (
u User
createdAt string
)
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
return nil, fmt.Errorf("list users scan: %w", err)
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
users = append(users, u)
}
return users, rows.Err()
}
// UserRow is a user plus assigned domain names for the management list.
type UserRow struct {
User User
DomainNames []string
}
// ListUserRows returns users with assigned domain names for the management UI.
func (s *Store) ListUserRows() ([]UserRow, error) {
users, err := s.ListUsers()
if err != nil {
return nil, err
}
rows := make([]UserRow, len(users))
for i, u := range users {
rows[i].User = u
if u.Role == RoleGlobal {
continue
}
names, err := s.listUserDomainNames(u.ID)
if err != nil {
return nil, err
}
rows[i].DomainNames = names
}
return rows, nil
}
// CountGlobalUsers returns how many global-role users exist.
func (s *Store) CountGlobalUsers() (int, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = ?", RoleGlobal).Scan(&n); err != nil {
return 0, fmt.Errorf("count global users: %w", err)
}
return n, nil
}
// CreateUser inserts a panel user and optional domain assignments.
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
if role == RoleDomainAdmin && len(domainIDs) == 0 {
return 0, fmt.Errorf("create user: domain_admin requires domains")
}
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
username, passwordHash, role, now,
)
if err != nil {
if isUniqueViolation(err) {
return 0, ErrUserExists
}
return 0, fmt.Errorf("create user: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create user id: %w", err)
}
if role == RoleDomainAdmin {
if err := s.setUserDomains(id, domainIDs); err != nil {
return 0, err
}
}
return id, nil
}
// UpdateUser replaces username, password hash, and DMARC email for a user.
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error {
u, err := s.GetUser(id)
if err != nil {
return err
}
res, err := s.db.Exec(
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?",
username, passwordHash, dmarcReportEmail, id,
)
if err != nil {
if isUniqueViolation(err) {
return ErrUserExists
}
return fmt.Errorf("update user: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update user: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
if u.Role == RoleGlobal {
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil {
return err
}
}
return nil
}
// SetUserRole updates a user's role.
func (s *Store) SetUserRole(userID int64, role Role) error {
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
if err != nil {
return fmt.Errorf("set user role: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set user role: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
return nil
}
// ClearUserDomains removes all domain assignments for a user.
func (s *Store) ClearUserDomains(userID int64) error {
_, err := s.db.Exec("DELETE FROM user_domains WHERE user_id = ?", userID)
if err != nil {
return fmt.Errorf("clear user domains: %w", err)
}
return nil
}
// SetUserDomains replaces domain assignments for a domain_admin user.
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
u, err := s.GetUser(userID)
if err != nil {
return err
}
if u.Role != RoleDomainAdmin {
return fmt.Errorf("set user domains: user is not domain_admin")
}
if len(domainIDs) == 0 {
return fmt.Errorf("set user domains: at least one domain required")
}
return s.setUserDomains(userID, domainIDs)
}
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global user.
func (s *Store) DeleteUser(id int64) error {
u, err := s.GetUser(id)
if err != nil {
return err
}
if u.Role == RoleGlobal {
n, err := s.CountGlobalUsers()
if err != nil {
return err
}
if n <= 1 {
return ErrLastGlobal
}
}
res, err := s.db.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
return nil
}
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode.
func (s *Store) GlobalDMARCReportEmail() (string, error) {
return s.GetSetting("dmarc_report_email")
}
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
rows, err := s.db.Query("SELECT domain_id FROM user_domains WHERE user_id = ? ORDER BY domain_id", userID)
if err != nil {
return nil, fmt.Errorf("list user domains: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("list user domains scan: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Store) listUserDomainNames(userID int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT d.name FROM user_domains ud JOIN domains d ON d.id = ud.domain_id WHERE ud.user_id = ? ORDER BY d.name",
userID,
)
if err != nil {
return nil, fmt.Errorf("list user domain names: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("list user domain names scan: %w", err)
}
names = append(names, name)
}
return names, rows.Err()
}
func (s *Store) setUserDomains(userID int64, domainIDs []int64) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("set user domains begin: %w", err)
}
if _, err := tx.Exec("DELETE FROM user_domains WHERE user_id = ?", userID); err != nil {
tx.Rollback()
return fmt.Errorf("set user domains clear: %w", err)
}
for _, did := range domainIDs {
if _, err := tx.Exec("INSERT INTO user_domains (user_id, domain_id) VALUES (?, ?)", userID, did); err != nil {
tx.Rollback()
return fmt.Errorf("set user domains insert: %w", err)
}
}
return tx.Commit()
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"errors"
"testing"
)
func TestUpdateUser(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash-one"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
u, err := st.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if err := st.UpdateUser(u.ID, "operator", "hash-two", "reports@hub.example"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
got, err := st.GetUser(u.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got.DMARCReportEmail != "reports@hub.example" {
t.Fatalf("dmarc email = %q", got.DMARCReportEmail)
}
email, err := st.GlobalDMARCReportEmail()
if err != nil {
t.Fatalf("GlobalDMARCReportEmail: %v", err)
}
if email != "reports@hub.example" {
t.Fatalf("settings dmarc = %q", email)
}
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
t.Fatalf("clear dmarc email: %v", err)
}
got, err = st.GetUser(u.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got.Username != "operator" || got.PasswordHash != "hash-three" {
t.Fatalf("unexpected user after update: %+v", got)
}
if got.CreatedAt.IsZero() {
t.Fatal("update dropped created_at")
}
}
func TestUpdateUserWithoutUser(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateUser(1, "operator", "hash", ""); !errors.Is(err, ErrUserNotFound) {
t.Fatalf("UpdateUser without user = %v, want ErrUserNotFound", err)
}
exists, err := st.UserExists()
if err != nil {
t.Fatalf("UserExists: %v", err)
}
if exists {
t.Fatal("UpdateUser created a user")
}
}
func TestCreateDomainAdminUser(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
d, err := st.AddDomain("example.com", "s1")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if len(u.DomainIDs) != 1 || u.DomainIDs[0] != d.ID {
t.Fatalf("domain ids = %v, want [%d]", u.DomainIDs, d.ID)
}
}
func TestDeleteLastGlobalUser(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
u, err := st.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if err := st.DeleteUser(u.ID); !errors.Is(err, ErrLastGlobal) {
t.Fatalf("DeleteUser = %v, want ErrLastGlobal", err)
}
}
+9 -10
View File
@@ -61,7 +61,7 @@ func (m *Module) clearSessionCookies(w http.ResponseWriter) {
// HandleLogin serves the login form (GET) and authenticates (POST). // HandleLogin serves the login form (GET) and authenticates (POST).
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) { func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := m.store.AdminExists() exists, err := m.store.UserExists()
if err != nil { if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
@@ -106,22 +106,21 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username")) username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password") password := r.PostFormValue("password")
admin, err := m.store.GetAdmin() user, err := m.store.GetUserByUsername(username)
if err != nil { if err != nil {
if !errors.Is(err, store.ErrNoAdmin) { if !errors.Is(err, store.ErrUserNotFound) {
logf("panel: login: get admin failed: %v", err) logf("panel: login: get user failed: %v", err)
} }
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return return
} }
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
if username != admin.Username || pwErr != nil {
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return return
} }
token := m.sessions.Create(admin.Username) token := m.sessions.Create(user.Username)
m.setSessionCookie(w, token) m.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
@@ -222,13 +221,13 @@ func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token strin
return return
} }
if err := m.store.CreateAdmin(username, string(hash)); err != nil { if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
if exists, _ := m.store.AdminExists(); exists { if exists, _ := m.store.UserExists(); exists {
m.setup.complete() m.setup.complete()
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
logf("panel: setup: create admin failed: %v", err) logf("panel: setup: create user failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.") m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return return
} }
+20 -7
View File
@@ -1,17 +1,12 @@
package auth package auth
import ( import (
"context"
"net/http" "net/http"
) )
type ctxKey int
const usernameKey ctxKey = 0
// RequireAuth wraps a handler so only requests with a valid session cookie // RequireAuth wraps a handler so only requests with a valid session cookie
// reach it; everyone else is redirected to the login page. The authenticated // reach it; everyone else is redirected to the login page. The authenticated
// username is stashed in the request context for downstream handlers. // principal is stashed in the request context for downstream handlers.
func (m *Module) RequireAuth(next http.Handler) http.Handler { func (m *Module) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := m.sessionToken(r) token, ok := m.sessionToken(r)
@@ -27,7 +22,13 @@ func (m *Module) RequireAuth(next http.Handler) http.Handler {
if isSessionActivity(r) && m.sessions.Touch(token) { if isSessionActivity(r) && m.sessions.Touch(token) {
m.setSessionCookie(w, token) m.setSessionCookie(w, token)
} }
ctx := context.WithValue(r.Context(), usernameKey, username) u, err := m.store.GetUserByUsername(username)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
p := principalFromUser(u)
ctx := withPrincipal(r.Context(), p)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
@@ -43,3 +44,15 @@ func CurrentUser(r *http.Request) string {
} }
return "" return ""
} }
// RequireGlobal wraps a handler that only global administrators may reach.
func RequireGlobal(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := CurrentPrincipal(r.Context())
if !ok || !p.IsGlobal() {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
+86
View File
@@ -0,0 +1,86 @@
package auth
import (
"context"
"net/http"
"github.com/mixeme/selfpost/internal/store"
)
type ctxKey int
const (
usernameKey ctxKey = 0
principalKey ctxKey = 1
)
// Role is a panel user's access level.
type Role = store.Role
const (
RoleGlobal = store.RoleGlobal
RoleDomainAdmin = store.RoleDomainAdmin
)
// Principal is the authenticated panel user attached to a request.
type Principal struct {
ID int64
Username string
Role Role
Domains []int64 // assigned domain IDs; empty for global (all domains)
}
// IsGlobal reports whether the principal has full panel access.
func (p Principal) IsGlobal() bool {
return p.Role == RoleGlobal
}
// CanAccessDomain reports whether the principal may access a domain id.
func (p Principal) CanAccessDomain(domainID int64) bool {
if p.IsGlobal() {
return true
}
for _, id := range p.Domains {
if id == domainID {
return true
}
}
return false
}
// CanAccessApp reports whether the principal may access an application.
func (p Principal) CanAccessApp(app store.Application) bool {
return p.CanAccessDomain(app.DomainID)
}
func principalFromUser(u store.User) Principal {
return Principal{
ID: u.ID,
Username: u.Username,
Role: u.Role,
Domains: u.DomainIDs,
}
}
func withPrincipal(ctx context.Context, p Principal) context.Context {
ctx = context.WithValue(ctx, usernameKey, p.Username)
return context.WithValue(ctx, principalKey, p)
}
// CurrentPrincipal returns the authenticated principal from the request context.
func CurrentPrincipal(ctx context.Context) (Principal, bool) {
if v, ok := ctx.Value(principalKey).(Principal); ok {
return v, true
}
return Principal{}, false
}
// PrincipalFromRequest returns the authenticated principal from an HTTP request.
func PrincipalFromRequest(r *http.Request) (Principal, bool) {
return CurrentPrincipal(r.Context())
}
// RequestWithPrincipal attaches a principal for middleware-equivalent tests.
func RequestWithPrincipal(r *http.Request, p Principal) *http.Request {
return r.WithContext(withPrincipal(r.Context(), p))
}
+3 -3
View File
@@ -33,7 +33,7 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
} }
func (m *setupManager) bootstrap() error { func (m *setupManager) bootstrap() error {
done, err := m.store.AdminExists() done, err := m.store.UserExists()
if err != nil { if err != nil {
return err return err
} }
@@ -48,9 +48,9 @@ func (m *setupManager) bootstrap() error {
} }
func (m *setupManager) activeToken() (string, bool) { func (m *setupManager) activeToken() (string, bool) {
done, err := m.store.AdminExists() done, err := m.store.UserExists()
if err != nil { if err != nil {
logf("panel: setup: admin check failed: %v", err) logf("panel: setup: user check failed: %v", err)
return "", false return "", false
} }
if done { if done {
+65
View File
@@ -0,0 +1,65 @@
package handlers
import (
"net/http"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
func (h *Handlers) principal(r *http.Request) (auth.Principal, bool) {
return auth.PrincipalFromRequest(r)
}
func (h *Handlers) requireGlobal(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
p, ok := h.principal(r)
if !ok || !p.IsGlobal() {
http.NotFound(w, r)
return auth.Principal{}, false
}
return p, true
}
func (h *Handlers) pageBase(r *http.Request) map[string]any {
p, _ := h.principal(r)
return map[string]any{
"User": auth.CurrentUser(r),
"IsGlobal": p.IsGlobal(),
}
}
func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
if p.IsGlobal() {
return h.store.ListDomains()
}
all, err := h.store.ListDomains()
if err != nil {
return nil, err
}
var out []store.Domain
for _, d := range all {
if p.CanAccessDomain(d.ID) {
out = append(out, d)
}
}
return out, nil
}
func domainNameSet(domains []store.Domain) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
m[d.Name] = true
}
return m
}
func domainIDSet(p auth.Principal) map[int64]bool {
if p.IsGlobal() {
return nil
}
m := make(map[int64]bool, len(p.Domains))
for _, id := range p.Domains {
m[id] = true
}
return m
}
+75 -65
View File
@@ -14,21 +14,22 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// HandleAccount serves the administrator's own account settings: the username // HandleAccount serves the signed-in user's account settings.
// and password chosen during setup are the only panel credentials
// (security.md), 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 (architecture.md § Mail path).
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
admin, err := h.store.GetAdmin() p, ok := h.principal(r)
if err != nil { if !ok {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail) u, err := h.store.GetUser(p.ID)
if err != nil {
logf("panel: account: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
case http.MethodPost: case http.MethodPost:
h.submitAccount(w, r) h.submitAccount(w, r)
default: default:
@@ -37,33 +38,30 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
} }
} }
// renderAccount draws the settings form. formUsername and formDMARCEmail func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
// repopulate fields after a rejected submission; password fields are never
// repopulated.
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) {
var reportAuth dnscheck.Result var reportAuth dnscheck.Result
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { if showDMARC && formDMARCEmail != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
reportAuth = h.dns.ReportAuth(ctx, hub) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
cancel() reportAuth = h.dns.ReportAuth(ctx, hub)
cancel()
}
} }
h.view.Render(w, status, "account", map[string]any{ data := h.pageBase(r)
"Title": "SelfPost — settings", data["Title"] = "SelfPost — settings"
"User": auth.CurrentUser(r), data["Active"] = "account"
"Active": "account", data["FormUsername"] = formUsername
"FormUsername": formUsername, data["FormDMARCEmail"] = formDMARCEmail
"FormDMARCEmail": formDMARCEmail, data["ShowDMARC"] = showDMARC
"ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)), data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
"ReportAuthExample": dnscheck.ReportAuthExample(), data["ReportAuthExample"] = dnscheck.ReportAuthExample()
"ReportAuthDNS": reportAuth, data["ReportAuthDNS"] = reportAuth
"ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail), data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
"Error": formErr, data["Error"] = formErr
"Flash": accountFlash(r), data["Flash"] = accountFlash(r)
}) h.view.Render(w, status, "account", data)
} }
// 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 { func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") { switch r.URL.Query().Get("updated") {
case "username": case "username":
@@ -85,19 +83,28 @@ func accountFlash(r *http.Request) string {
} }
} }
// 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 (security.md).
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
if !h.auth.AllowLoginAttempt(r) { if !h.auth.AllowLoginAttempt(r) {
p, _ := h.principal(r)
h.renderAccount(w, r, http.StatusTooManyRequests, h.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "") "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "") p, _ := h.principal(r)
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
return
}
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := h.store.GetUser(p.ID)
if err != nil {
logf("panel: account: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
@@ -106,73 +113,76 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
password := r.PostFormValue("new_password") password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm") confirm := r.PostFormValue("new_password_confirm")
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
if !p.IsGlobal() {
admin, err := h.store.GetAdmin() dmarcEmail = user.DMARCReportEmail
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
} }
if username == "" { if username == "" {
username = admin.Username username = user.Username
} }
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail) h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
return return
} }
renaming := username != admin.Username renaming := username != user.Username
if renaming { if renaming {
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return return
} }
} }
if err := validate.Email(dmarcEmail); err != nil { if p.IsGlobal() {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) if err := validate.Email(dmarcEmail); err != nil {
return h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
return
}
} }
emailChanging := dmarcEmail != admin.DMARCReportEmail emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
repassword := password != "" || confirm != "" repassword := password != "" || confirm != ""
if repassword { if repassword {
if password != confirm { if password != confirm {
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail) h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
return return
} }
if err := validate.AdminPassword(password); err != nil { if err := validate.AdminPassword(password); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return return
} }
} }
if !renaming && !repassword && !emailChanging { if !renaming && !repassword && !emailChanging {
h.renderAccount(w, r, http.StatusBadRequest, h.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail) "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
return return
} }
hash := admin.PasswordHash hash := user.PasswordHash
if repassword { if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
logf("panel: account: hashing password failed: %v", err) logf("panel: account: hashing password failed: %v", err)
h.renderAccount(w, r, http.StatusInternalServerError, h.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail) "Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
return return
} }
hash = string(newHash) hash = string(newHash)
} }
if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil { if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
logf("panel: account: update admin failed: %v", err) logf("panel: account: update user failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again." msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) { if errors.Is(err, store.ErrUserNotFound) {
msg = "There is no administrator account to update." msg = "There is no user account to update."
} }
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail) if errors.Is(err, store.ErrUserExists) {
msg = "That username is already in use."
h.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
return
}
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
return return
} }
@@ -185,7 +195,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
} }
logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging) logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther) http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
} }
+47 -53
View File
@@ -10,7 +10,6 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate" "github.com/mixeme/selfpost/internal/web/validate"
) )
@@ -104,13 +103,12 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
// What DNS actually publishes for the domain today, checked against the key // What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page // this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing. // after a form post costs nothing.
admin, err := h.store.GetAdmin() profileEmail, err := h.store.GlobalDMARCReportEmail()
if err != nil { if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err) logf("panel: domain %d: global dmarc email: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
profileEmail := admin.DMARCReportEmail
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail) reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
dns, srv := h.domainDNS(d, record, profileEmail, false) dns, srv := h.domainDNS(d, record, profileEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
@@ -134,52 +132,43 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
dmarcSource = "settings" dmarcSource = "settings"
} }
h.view.Render(w, status, "domain_detail", map[string]any{ data := h.pageBase(r)
"Title": "SelfPost — " + d.Name, data["Title"] = "SelfPost — " + d.Name
"User": auth.CurrentUser(r), data["Active"] = "domains"
"Active": "domains", data["Domain"] = d
"Domain": d, data["Record"] = record
"Record": record, data["DNS"] = dns
"DNS": dns, data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs)
// SPF and DMARC are the operator's to write — SelfPost cannot generate data["DMARCName"] = dnscheck.DMARCRecordName(d.Name)
// them the way it generates the DKIM record — so the page shows what data["DMARCExample"] = dnscheck.DMARCExample(reportEmail)
// this server expects rather than leaving it to the documentation. The data["DMARCSource"] = dmarcSource
// same builders phrase the suggestions in the check messages, so the data["ProfileDMARCEmail"] = profileEmail
// page and the checks below it never recommend different records. data["ResolvedDMARCEmail"] = reportEmail
"SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs), data["DMARCRuaMode"] = dmarcMode
"DMARCName": dnscheck.DMARCRecordName(d.Name), data["DMARCRuaCustom"] = dmarcCustom
"DMARCExample": dnscheck.DMARCExample(reportEmail), data["ReportAuthName"] = reportAuthName
"DMARCSource": dmarcSource, data["ReportAuthValue"] = reportAuthValue
"ProfileDMARCEmail": profileEmail, data["NeedsReportAuth"] = needsReportAuth
"ResolvedDMARCEmail": reportEmail, data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
"DMARCRuaMode": dmarcMode, data["Hostname"] = h.cfg.Hostname
"DMARCRuaCustom": dmarcCustom, data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
"ReportAuthName": reportAuthName, data["Apps"] = appViews
"ReportAuthValue": reportAuthValue, data["Error"] = view.FormErr
"NeedsReportAuth": needsReportAuth, data["FormLogin"] = view.FormLogin
"SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name), data["FormMode"] = view.FormMode
// Client connection settings (the same for every domain on this data["FormAddrs"] = view.FormAddrs
// instance): the hostname clients connect to, and whether the optional data["NewCred"] = view.NewCred
// submission listener is enabled in this deployment. data["Flash"] = detailFlash(r)
"Hostname": h.cfg.Hostname, data["Wildcard"] = store.AddressModeWildcard
"SubmissionEnabled": h.cfg.SubmissionEnabled, data["List"] = store.AddressModeList
"Apps": appViews, data["RateLimitErr"] = view.RateLimitErr
"Error": view.FormErr, data["ExportErr"] = view.ExportErr
"FormLogin": view.FormLogin, data["MinPwLen"] = validate.MinSecretFilePasswordLen
"FormMode": view.FormMode, data["DomainHasRL"] = domainRLok && domainRL.Active()
"FormAddrs": view.FormAddrs, data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n")
"NewCred": view.NewCred, data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
"Flash": detailFlash(r), data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
"Wildcard": store.AddressModeWildcard, h.view.Render(w, status, "domain_detail", data)
"List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr,
"ExportErr": view.ExportErr,
"MinPwLen": validate.MinSecretFilePasswordLen,
"DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
})
} }
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC // domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
@@ -213,13 +202,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
admin, err := h.store.GetAdmin() profileEmail, err := h.store.GlobalDMARCReportEmail()
if err != nil { if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err) logf("panel: domain %d: global dmarc email: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.domainDNS(d, record, admin.DMARCReportEmail, true) h.domainDNS(d, record, profileEmail, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
} }
@@ -387,6 +376,11 @@ func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (st
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return store.Application{}, false return store.Application{}, false
} }
p, ok := h.principal(r)
if !ok || !p.CanAccessApp(a) {
http.NotFound(w, r)
return store.Application{}, false
}
return a, true return a, true
} }
+16 -9
View File
@@ -13,7 +13,6 @@ import (
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/secretfile" "github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate" "github.com/mixeme/selfpost/internal/web/validate"
) )
@@ -28,6 +27,9 @@ const maxImportBytes = 1 << 20 // 1 MiB
// and the domain import are separate actions with different risk, so each gets // 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. // its own card here rather than sharing a block on the domain list.
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.renderBackupPage(w, r, http.StatusOK, "") h.renderBackupPage(w, r, http.StatusOK, "")
} }
@@ -42,14 +44,13 @@ func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, stat
// password), importErr to the import card, so neither message appears under the // password), importErr to the import card, so neither message appears under the
// wrong form. // wrong form.
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) { func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
h.view.Render(w, status, "backup", map[string]any{ data := h.pageBase(r)
"Title": "SelfPost — backup", data["Title"] = "SelfPost — backup"
"User": auth.CurrentUser(r), data["Active"] = "backup"
"Active": "backup", data["ImportErr"] = importErr
"ImportErr": importErr, data["BackupErr"] = backupErr
"BackupErr": backupErr, data["MinPwLen"] = validate.MinSecretFilePasswordLen
"MinPwLen": validate.MinSecretFilePasswordLen, h.view.Render(w, status, "backup", data)
})
} }
// HandleBackup streams a full-server backup as a download (architecture.md § // HandleBackup streams a full-server backup as a download (architecture.md §
@@ -61,6 +62,9 @@ func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request,
// way out, so the file that lands on their disk — wherever it is copied // way out, so the file that lands on their disk — wherever it is copied
// afterwards — is useless without the password. // afterwards — is useless without the password.
func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
password, pwErr := secretFilePassword(r) password, pwErr := secretFilePassword(r)
if pwErr != "" { if pwErr != "" {
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
@@ -184,6 +188,9 @@ func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
// re-renders the backup page, where the import form lives, with a friendly // re-renders the backup page, where the import form lives, with a friendly
// message. // message.
func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes) r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil { if err := r.ParseMultipartForm(maxImportBytes); err != nil {
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).") h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
+34 -12
View File
@@ -29,27 +29,31 @@ func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) { func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
domains, err := h.domains.List() p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domains, err := h.assignedDomains(p)
if err != nil { if err != nil {
logf("panel: dashboard: list domains: %v", err) logf("panel: dashboard: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.view.Render(w, status, "dashboard", map[string]any{ data := h.pageBase(r)
"Title": "SelfPost", data["Title"] = "SelfPost"
"User": auth.CurrentUser(r), data["Active"] = "domains"
"Active": "domains", data["Domains"] = h.domainRows(domains)
"Domains": h.domainRows(domains), data["Error"] = formErr
"Error": formErr, data["FormName"] = formName
"FormName": formName, data["Flash"] = dashboardFlash(r)
"Flash": dashboardFlash(r), h.view.Render(w, status, "dashboard", data)
})
} }
func (h *Handlers) domainRows(domains []store.Domain) []domainRow { func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
profileEmail := "" profileEmail := ""
if admin, err := h.store.GetAdmin(); err == nil { if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
profileEmail = admin.DMARCReportEmail profileEmail = email
} }
rows := make([]domainRow, len(domains)) rows := make([]domainRow, len(domains))
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -82,6 +86,9 @@ func dashboardFlash(r *http.Request) string {
// OpenDKIM reload), and redirects to the domain's page so the DNS record to // OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md). // publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "") h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return return
@@ -109,6 +116,9 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
// HandleDeleteConfirm shows the cascade warning before a domain is removed. // HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
d, ok := h.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
return return
@@ -118,11 +128,15 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
"User": auth.CurrentUser(r), "User": auth.CurrentUser(r),
"Active": "domains", "Active": "domains",
"Domain": d, "Domain": d,
"IsGlobal": true,
}) })
} }
// HandleDeleteDomain performs the deletion and returns to the domain list. // HandleDeleteDomain performs the deletion and returns to the domain list.
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
id, ok := parseDomainID(w, r) id, ok := parseDomainID(w, r)
if !ok { if !ok {
return return
@@ -145,6 +159,9 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
// HandleReload re-applies both the OpenDKIM configuration and the Postfix // HandleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (architecture.md § Panel HTTP surface). // sender map on demand (architecture.md § Panel HTTP surface).
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
if err := h.domains.Resync(); err != nil { if err := h.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err) logf("panel: manual reload (opendkim): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError) http.Error(w, "reload failed", http.StatusInternalServerError)
@@ -173,6 +190,11 @@ func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.D
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return store.Domain{}, false return store.Domain{}, false
} }
p, ok := h.principal(r)
if !ok || !p.CanAccessDomain(d.ID) {
http.NotFound(w, r)
return store.Domain{}, false
}
return d, true return d, true
} }
+87 -23
View File
@@ -5,6 +5,7 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"net/url" "net/url"
"sort"
"strconv" "strconv"
"time" "time"
@@ -38,7 +39,9 @@ func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
return return
} }
data["Title"] = "SelfPost — deliveries" data["Title"] = "SelfPost — deliveries"
data["User"] = auth.CurrentUser(r) for k, v := range h.pageBase(r) {
data[k] = v
}
data["Active"] = "deliveries" data["Active"] = "deliveries"
h.view.Render(w, http.StatusOK, "deliveries", data) h.view.Render(w, http.StatusOK, "deliveries", data)
} }
@@ -85,12 +88,29 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !p.IsGlobal() {
allowed, err := h.assignedDomains(p)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !domainNameSet(allowed)[row.Domain] {
http.NotFound(w, r)
return
}
}
row.Subject = mailhdr.DecodeSubject(row.Subject) row.Subject = mailhdr.DecodeSubject(row.Subject)
logRows, logNote := h.deliveryLog(row) logRows, logNote := h.deliveryLog(row)
h.view.Render(w, http.StatusOK, "delivery", map[string]any{ h.view.Render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery", "Title": "SelfPost — delivery",
"User": auth.CurrentUser(r), "User": auth.CurrentUser(r),
"Active": "deliveries", "Active": "deliveries",
"IsGlobal": p.IsGlobal(),
"Row": row, "Row": row,
// The status in the panel's own badge vocabulary, so the headline reads // The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel. // the same way as every other health signal in the panel.
@@ -269,11 +289,31 @@ func deliveriesBackURL(r *http.Request) string {
// string, queries the store, and assembles everything the template needs // string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination). // (filter dropdown options plus the current selection, rows, and pagination).
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
p, ok := h.principal(r)
if !ok {
return nil, errors.New("no principal")
}
q := r.URL.Query() q := r.URL.Query()
filter := store.SendLogFilter{ filter := store.SendLogFilter{
Domain: q.Get("domain"), Domain: q.Get("domain"),
AppLogin: q.Get("app"), AppLogin: q.Get("app"),
} }
assigned, err := h.assignedDomains(p)
if err != nil {
return nil, err
}
allowedNames := domainNameSet(assigned)
if !p.IsGlobal() {
if filter.Domain != "" && !allowedNames[filter.Domain] {
filter.Domain = ""
}
if filter.Domain == "" && len(assigned) == 1 {
filter.Domain = assigned[0].Name
}
}
page := parsePage(q.Get("p")) page := parsePage(q.Get("p"))
total, err := h.store.CountSendLog(filter) total, err := h.store.CountSendLog(filter)
@@ -284,23 +324,33 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Decode on the way out as well as on the way in: rows the journal-milter
// wrote before it decoded subjects itself still hold the raw header, and
// they are the ones an operator is most likely to be looking at.
for i := range rows { for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject) rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
} }
domains, err := h.store.ListDomains()
if err != nil { domainNames := make([]string, 0, len(assigned))
return nil, err for _, d := range assigned {
domainNames = append(domainNames, d.Name)
} }
domainNames := make([]string, len(domains))
for i, d := range domains { loginSet := make(map[string]bool)
domainNames[i] = d.Name for _, d := range assigned {
apps, err := h.store.ListApplicationsByDomain(d.ID)
if err != nil {
return nil, err
}
for _, a := range apps {
loginSet[a.Login] = true
}
} }
logins, err := h.store.ListApplicationLogins() logins := make([]string, 0, len(loginSet))
if err != nil { for login := range loginSet {
return nil, err logins = append(logins, login)
}
sort.Strings(logins)
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] {
filter.AppLogin = ""
} }
lastPage := 1 lastPage := 1
@@ -335,18 +385,25 @@ func parsePage(v string) int {
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP // HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// surface). // surface).
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
out, errText := readQueue() out, errText := readQueue()
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{ h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue", "Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r), "User": auth.CurrentUser(r),
"Active": "mail_queue", "Active": "mail_queue",
"Output": out, "IsGlobal": true,
"Error": errText, "Output": out,
"Error": errText,
}) })
} }
// HandleMailQueueBody serves the HTMX polling fragment for the queue view. // HandleMailQueueBody serves the HTMX polling fragment for the queue view.
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
out, errText := readQueue() out, errText := readQueue()
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{ h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
"Output": out, "Output": out,
@@ -369,18 +426,25 @@ func readQueue() (string, string) {
// HandleSystemLog renders the System log page over mail.log (architecture.md § // HandleSystemLog renders the System log page over mail.log (architecture.md §
// Panel HTTP surface). // Panel HTTP surface).
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
lines, errText := h.readLogTail() lines, errText := h.readLogTail()
h.view.Render(w, http.StatusOK, "system_log", map[string]any{ h.view.Render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — system log", "Title": "SelfPost — system log",
"User": auth.CurrentUser(r), "User": auth.CurrentUser(r),
"Active": "system_log", "Active": "system_log",
"Lines": lines, "IsGlobal": true,
"Error": errText, "Lines": lines,
"Error": errText,
}) })
} }
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view. // HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
lines, errText := h.readLogTail() lines, errText := h.readLogTail()
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{ h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
"Lines": lines, "Lines": lines,
@@ -10,6 +10,7 @@ import (
"testing" "testing"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
) )
// After log rotation renames mail.log away, Postfix takes about a second to // After log rotation renames mail.log away, Postfix takes about a second to
@@ -252,6 +253,11 @@ func getBody(t *testing.T, h http.HandlerFunc, target string) string {
t.Helper() t.Helper()
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil) req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, auth.Principal{
ID: 1,
Username: "admin",
Role: auth.RoleGlobal,
})
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" { if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
req.SetPathValue("id", rest) req.SetPathValue("id", rest)
} }
+11 -1
View File
@@ -9,23 +9,33 @@ import (
) )
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
data := h.statusBody() data := h.statusBody()
srv := h.dns.Server(h.cfg.Hostname, false) srv := h.dns.Server(h.cfg.Hostname, false)
data["Title"] = "SelfPost — status" data["Title"] = "SelfPost — status"
data["User"] = auth.CurrentUser(r) data["User"] = auth.CurrentUser(r)
data["Active"] = "status" data["Active"] = "status"
data["IsGlobal"] = true
data["Flash"] = statusFlash(r) data["Flash"] = statusFlash(r)
data["Hostname"] = h.cfg.Hostname data["Hostname"] = h.cfg.Hostname
data["PTR"] = srv.PTR data["PTR"] = srv.PTR
h.view.Render(w, http.StatusOK, "status", data) h.view.Render(w, http.StatusOK, "status", data)
} }
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) { func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody()) h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
} }
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
h.dns.Server(h.cfg.Hostname, true) h.dns.Server(h.cfg.Hostname, true)
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther) http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
} }
+328
View File
@@ -0,0 +1,328 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
type userFormView struct {
FormErr string
FormUsername string
FormRole string
FormDomains map[int64]bool
FormPassword string
}
// HandleUsers lists panel users (global only).
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
rows, err := h.store.ListUserRows()
if err != nil {
logf("panel: list users: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — users"
data["Active"] = "users"
data["Users"] = rows
data["Flash"] = usersFlash(r)
h.view.Render(w, http.StatusOK, "users", data)
}
func usersFlash(r *http.Request) string {
switch r.URL.Query().Get("done") {
case "created":
return "User created."
case "updated":
return "User updated."
case "deleted":
return "User deleted."
default:
return ""
}
}
// HandleUserNew creates a panel user (global only).
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
switch r.Method {
case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
case http.MethodPost:
h.submitUserCreate(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// HandleUserEdit edits or deletes a panel user (global only).
func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
selected := make(map[int64]bool, len(u.DomainIDs))
for _, id := range u.DomainIDs {
selected[id] = true
}
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
FormUsername: u.Username,
FormRole: string(u.Role),
FormDomains: selected,
})
case http.MethodPost:
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
if r.PostFormValue("action") == "delete" {
h.submitUserDelete(w, r, u)
return
}
h.submitUserUpdate(w, r, u)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view userFormView) {
domains, err := h.store.ListDomains()
if err != nil {
logf("panel: user form: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — user"
data["Active"] = "users"
data["UserID"] = userID
data["Domains"] = domains
data["Error"] = view.FormErr
data["FormUsername"] = view.FormUsername
data["FormRole"] = view.FormRole
data["FormDomains"] = view.FormDomains
data["FormPassword"] = view.FormPassword
data["IsEdit"] = userID != 0
h.view.Render(w, status, "user_form", data)
}
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
domainIDs := parseDomainIDs(r)
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: create user hash: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
return
}
if _, err := h.store.CreateUser(username, string(hash), role, domainIDs); err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
logf("panel: create user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
return
}
http.Redirect(w, r, "/users?done=created", http.StatusSeeOther)
}
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
domainIDs := parseDomainIDs(r)
selected := domainIDSetFromForm(r)
if username == "" {
username = u.Username
}
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot demote the last global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
return
}
}
if u.ID == p.ID && u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot demote yourself without another global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
return
}
}
hash := u.PasswordHash
if password != "" {
if err := validate.AdminPassword(password); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: update user hash: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
hash = string(newHash)
}
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
logf("panel: update user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role != u.Role {
if err := h.store.SetUserRole(u.ID, role); err != nil {
logf("panel: set user role: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role == store.RoleGlobal {
if err := h.store.ClearUserDomains(u.ID); err != nil {
logf("panel: clear user domains: %v", err)
}
}
}
if role == store.RoleDomainAdmin {
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
logf("panel: set user domains: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
}
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
}
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if u.ID == p.ID {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
if err := h.store.DeleteUser(u.ID); err != nil {
if errors.Is(err, store.ErrLastGlobal) {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
logf("panel: delete user %d: %v", u.ID, err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
}
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
func parseDomainIDs(r *http.Request) []int64 {
var ids []int64
for _, v := range r.PostForm["domain_ids"] {
id, err := strconv.ParseInt(v, 10, 64)
if err == nil && id > 0 {
ids = append(ids, id)
}
}
return ids
}
func domainIDSetFromForm(r *http.Request) map[int64]bool {
m := make(map[int64]bool)
for _, id := range parseDomainIDs(r) {
m[id] = true
}
return m
}
+3 -1
View File
@@ -23,6 +23,7 @@
<label for="new_password_confirm">Confirm new password</label> <label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password"> <input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
{{if .ShowDMARC}}
<h3>DMARC aggregate reports</h3> <h3>DMARC aggregate reports</h3>
<p class="muted">Optional default <code>rua=</code> address for every sending <p class="muted">Optional default <code>rua=</code> address for every sending
domain (can be overridden per domain). Use a mailbox on a domain that domain (can be overridden per domain). Use a mailbox on a domain that
@@ -58,11 +59,12 @@
{{end}}</span>{{end}} {{end}}</span>{{end}}
{{end}} {{end}}
{{end}} {{end}}
{{end}}
<button type="submit">Save changes</button> <button type="submit">Save changes</button>
</form> </form>
<p class="muted">Leave both new-password fields empty to change the username <p class="muted">Leave both new-password fields empty to change the username
or DMARC address only. Changing the password signs out every other session; {{if .ShowDMARC}}or DMARC address {{end}}only. Changing the password signs out every other session;
this one stays signed in.</p> this one stays signed in.</p>
</div> </div>
{{end}} {{end}}
+3 -1
View File
@@ -3,6 +3,7 @@
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .IsGlobal}}
<div class="card"> <div class="card">
<h2>Add a sending domain</h2> <h2>Add a sending domain</h2>
<form method="post" action="/domains"> <form method="post" action="/domains">
@@ -16,6 +17,7 @@
<p class="muted">A DKIM key is generated for the domain; you then publish the <p class="muted">A DKIM key is generated for the domain; you then publish the
shown DNS record. Adding a domain does not create an application.</p> shown DNS record. Adding a domain does not create an application.</p>
</div> </div>
{{end}}
<div class="card"> <div class="card">
<h2>Domains</h2> <h2>Domains</h2>
@@ -31,7 +33,7 @@
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td> <td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
<td class="muted">{{.DKIMSelector}}</td> <td class="muted">{{.DKIMSelector}}</td>
<td>{{.AppCount}}</td> <td>{{.AppCount}}</td>
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td> {{if $.IsGlobal}}<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
+6 -1
View File
@@ -64,14 +64,19 @@
the column: the mark, the panel's pages, the current page's own sections, the column: the mark, the panel's pages, the current page's own sections,
and the session last. The mark goes to /status, the page the panel opens and the session last. The mark goes to /status, the page the panel opens
on — named outright rather than as "/", which is only a redirect to it. */}} on — named outright rather than as "/", which is only a redirect to it. */}}
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a> <a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
<div class="links"> <div class="links">
{{if .IsGlobal}}
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}} {{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}} {{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}} {{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}} {{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}} {{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}} {{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-account"}}Users</span>{{else}}<a href="/users">{{template "icon-account"}}Users</a>{{end}}
{{end}}
</div> </div>
{{template "sections" .}} {{template "sections" .}}
<div class="session"> <div class="session">
@@ -0,0 +1,38 @@
{{define "content"}}
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
<div class="card narrow">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="password">Password{{if .IsEdit}} (leave empty to keep){{end}}</label>
<input id="password" name="password" type="password" autocomplete="new-password" {{if not .IsEdit}}required{{end}}>
<label for="role">Role</label>
<select id="role" name="role">
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
</select>
<fieldset id="domain-pick">
<legend>Assigned domains</legend>
<p class="muted">Required for domain administrators.</p>
{{range .Domains}}
<label class="checkbox">
<input type="checkbox" name="domain_ids" value="{{.ID}}" {{if index $.FormDomains .ID}}checked{{end}}>
{{.Name}}
</label>
{{end}}
</fieldset>
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
{{if .IsEdit}}
<button type="submit" name="action" value="delete" class="danger">Delete user</button>
{{end}}
</form>
<p class="muted"><a href="/users">Back to users</a></p>
</div>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "content"}}
<h1>Panel users</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card">
<p><a href="/users/new">Create user</a></p>
{{if .Users}}
<table>
<thead>
<tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.User.Username}}</td>
<td>{{if eq .User.Role "global"}}Global{{else}}Domain admin{{end}}</td>
<td class="muted">{{if eq .User.Role "global"}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
<td class="actions"><a href="/users/{{.User.ID}}">Edit</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No panel users besides the global administrator yet.</p>
{{end}}
</div>
{{end}}
+6 -4
View File
@@ -207,8 +207,9 @@ func TestNavMarksActivePage(t *testing.T) {
} }
var buf bytes.Buffer var buf bytes.Buffer
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{ err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin", "User": "admin",
"Active": "mail_queue", "Active": "mail_queue",
"IsGlobal": true,
}) })
if err != nil { if err != nil {
t.Fatalf("execute nav: %v", err) t.Fatalf("execute nav: %v", err)
@@ -234,8 +235,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{ if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin", "User": "admin",
"Active": "status", "Active": "status",
"IsGlobal": true,
}); err != nil { }); err != nil {
t.Fatalf("execute nav: %v", err) t.Fatalf("execute nav: %v", err)
} }
+2
View File
@@ -35,6 +35,8 @@ var pageFiles = map[string][]string{
"login": {"templates/login.html"}, "login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"}, "dashboard": {"templates/dashboard.html"},
"account": {"templates/account.html"}, "account": {"templates/account.html"},
"users": {"templates/users.html"},
"user_form": {"templates/user_form.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"}, "backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"}, "domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"}, "domain_delete": {"templates/domain_delete.html"},
+13 -2
View File
@@ -129,7 +129,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/logout", s.auth.HandleLogout) mux.HandleFunc("/logout", s.auth.HandleLogout)
authed := http.NewServeMux() authed := http.NewServeMux()
authed.HandleFunc("GET /{$}", redirectToStatus) authed.HandleFunc("GET /{$}", redirectHome)
authed.HandleFunc("GET /status", h.HandleStatus) authed.HandleFunc("GET /status", h.HandleStatus)
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment) authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck) authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)
@@ -153,6 +153,12 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("/account", h.HandleAccount) authed.HandleFunc("/account", h.HandleAccount)
authed.HandleFunc("GET /users", h.HandleUsers)
authed.HandleFunc("GET /users/new", h.HandleUserNew)
authed.HandleFunc("POST /users/new", h.HandleUserNew)
authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit)
authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit)
authed.HandleFunc("GET /backup", h.HandleBackupPage) authed.HandleFunc("GET /backup", h.HandleBackupPage)
authed.HandleFunc("POST /backup", h.HandleBackup) authed.HandleFunc("POST /backup", h.HandleBackup)
@@ -168,7 +174,12 @@ func (s *Server) Handler() http.Handler {
return s.secure(mux) return s.secure(mux)
} }
func redirectToStatus(w http.ResponseWriter, r *http.Request) { func redirectHome(w http.ResponseWriter, r *http.Request) {
p, ok := auth.PrincipalFromRequest(r)
if ok && !p.IsGlobal() {
http.Redirect(w, r, "/domains", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/status", http.StatusSeeOther) http.Redirect(w, r, "/status", http.StatusSeeOther)
} }