Files
volumen/internal/admin/setup.go
T
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

241 lines
8.0 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"errors"
"html/template"
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// setupNeeded reports whether the first-run wizard should serve: a
// readable users file that holds no accounts. A file that cannot be
// read answers through the second value: the wizard would refuse to
// write over it anyway, so the caller says so instead of offering a
// form that cannot work.
func (a *Admin) setupNeeded() (needed bool, broken error) {
if err := a.deps.Users.Health(); err != nil {
return false, err
}
return !a.deps.Users.Any(), nil
}
// registerSetupRoutes mounts the wizard. The routes are public in the
// same sense the login is public: they exist for the owner of the
// installation before any account does, and the wizard retires itself
// as soon as one account exists.
func (a *Admin) registerSetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/setup", a.handleSetupForm)
mux.HandleFunc("POST /admin/setup", a.handleSetup)
}
// handleSetupForm serves the wizard while no account exists. Once one
// does, the route sends the browser back to the login, which is the
// same answer as deleting the route: the first run happens exactly
// once. The ?lang query re-renders the page in another shipped language:
// the language chips are real links, so the choice works without
// JavaScript too.
func (a *Admin) handleSetupForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" && a.deps.Users.Find(sess.Get("user")) != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
lang := i18n.Normalize(r.URL.Query().Get("lang"))
a.renderSetup(w, r, "", http.StatusOK, lang)
}
// renderSetup draws the wizard page in the given language ("" keeps the
// site default) and with the error the last attempt reported when there
// is one. The wizard always opens on the clean defaults: the site
// language and the shipped scheme, never on the anonymous preview
// cookies, which belong to the login screen after an account exists and
// here would only be a leftover from a half-finished or reset setup. The
// cookies are expired on the way so the next screen starts from the same
// truth the form shows. The page carries both languages of its own
// strings so the chips can swap the text without a reload.
func (a *Admin) renderSetup(w http.ResponseWriter, r *http.Request, errorMsg string, status int, lang string) {
if lang == "" {
lang = a.siteLanguage()
}
data := a.pageData(r)
data.IsSetup = true
data.Lang = lang
data.Theme = web.DefaultTheme
data.Error = errorMsg
data.SetupI18n = setupI18n(data.Config.Admin.MinPasswordLength)
expires := &http.Cookie{MaxAge: -1, Path: "/admin", HttpOnly: true}
c1 := *expires
c1.Name = i18n.Cookie
http.SetCookie(w, &c1)
c2 := *expires
c2.Name = web.ThemeCookie
http.SetCookie(w, &c2)
a.renderPage(w, r, "setup.html", data, status)
}
// setupI18nKeys are the wizard's own interface strings, keyed by their
// English source; the page swaps them client-side when a language chip
// is clicked, so the typed values survive. "password.hint" is added
// separately because it carries the configured length.
var setupI18nKeys = []string{
"Welcome to Volumen",
"Set up the administrator account to open this installation.",
"Account",
"Username",
"Display name",
"Your real name",
"Password",
"Show password",
"Hide password",
"Language",
"Colour scheme",
"The page takes the colours as you choose.",
"Create account",
}
// setupI18n builds the page's bilingual payload: every wizard string in
// both shipped languages, and the script catalogue per language, escaped
// for embedding in a script element the way the shared catalogue is.
func setupI18n(minLength int) template.JS {
ui := make(map[string]map[string]string, len(setupI18nKeys)+1)
for _, key := range setupI18nKeys {
ui[key] = map[string]string{
"en": i18n.Admin.T("en", key),
"cs": i18n.Admin.T("cs", key),
}
}
ui["password.hint"] = map[string]string{
"en": i18n.Admin.N("en", "password.min", minLength),
"cs": i18n.Admin.N("cs", "password.min", minLength),
}
payload := map[string]any{
"ui": ui,
"js": map[string]any{
"en": i18n.Admin.JS("en"),
"cs": i18n.Admin.JS("cs"),
},
}
b, err := json.Marshal(payload, json.Deterministic(true))
if err != nil {
return "{}"
}
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
}
// siteLanguage is the interface language a request falls back to when no
// account and no cookie decide it: the configured site language when the
// UI ships it, English otherwise.
func (a *Admin) siteLanguage() string {
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
return lang
}
return "en"
}
// handleSetup creates the first administrator account, stores the
// interface choices with it and signs the operator straight in. The
// password goes through the same server policy as every other password
// change; the page meter is advice, this is the gate.
func (a *Admin) handleSetup(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if username == "" {
username = "admin"
}
name := strings.TrimSpace(r.PostFormValue("name"))
language := i18n.Normalize(r.PostFormValue("language"))
if language == "" {
language = a.siteLanguage()
}
theme := r.PostFormValue("theme")
if !web.ValidTheme(theme) {
theme = web.DefaultTheme
}
secret := r.PostFormValue("password")
if !usernameRe.MatchString(username) {
a.renderSetup(w, r, i18n.Admin.T(language, "Username may use letters, numbers, dot, dash, underscore."), http.StatusUnprocessableEntity, language)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(secret, minLen, maxLen); key != "" {
msg := i18n.Admin.T(language, key)
if n > 0 {
msg = i18n.Admin.N(language, key, n)
}
a.renderSetup(w, r, msg, http.StatusUnprocessableEntity, language)
return
}
user, err := a.deps.Users.AddFirst(username, secret, language, theme, name)
switch {
case err == nil:
case errors.Is(err, users.ErrUsersExist):
// Another claim won the race a moment ago; the wizard is gone
// and the account is already there.
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
default:
a.renderSetup(w, r, i18n.Admin.Tf(language, "The account could not be created: %s", err.Error()), http.StatusInternalServerError, language)
return
}
// Sign the operator in with the same session shape the login uses,
// so the wizard ends where a first sign-in would: inside the
// dashboard, on one request.
sess := session.FromContext(r.Context())
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
a.record(r, "setup.first_user", user.Username, nil)
secure := a.cookieSecure()
http.SetCookie(w, &http.Cookie{
Name: i18n.Cookie,
Value: language,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: web.ThemeCookie,
Value: theme,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}