Initial commit
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
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
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user