feat(settings): add user profile and avatar #49

Merged
tony merged 1 commits from codex/block-21-user-settings-profile into main 2026-08-26 21:22:22 +02:00
14 changed files with 473 additions and 48 deletions
+1
View File
@@ -39,6 +39,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Dedicated administrator Audit and Settings pages; notification channels, audit retention/purge and game-container labels retain their existing backend contracts outside the Dashboard.
- Audit uses server-side filtering and 50-event pagination; timestamps remain UTC in SQLite and are rendered in the Compose `TZ` IANA timezone with an invalid-zone fallback to UTC. Instance audit events retain a minimal game/name/slug snapshot so history stays readable after instance deletion.
- Separate personal account settings and administrator user management, including email/password preferences, active-state session revocation, protected global roles, per-instance memberships and permission overrides.
- Personal account settings display the authenticated email and support CSRF-protected email updates plus local PNG/JPEG avatar upload, replacement and deletion; avatars are normalized to private 256px PNG files and fall back to username initials in the identity header.
- Administrator-configurable browser session policy: seven-day absolute lifetime and 24-hour inactivity timeout by default, bounded validation, optional inactivity expiry, dynamic enforcement for existing sessions, and throttled activity persistence. Normal authorized operations no longer require an arbitrary recent-authentication window.
- Restrictive browser headers and bounded public HTTP headers.
- Hardened read-only two-service Compose, capability dropping, private agent networking and distinct minimal OCI image targets.
+2 -1
View File
@@ -6,7 +6,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
| Entity | Purpose | Important fields |
|---|---|---|
| `users` | Local identities | id, username, email, password_hash, global_role, disabled_at, language, created_at |
| `users` | Local identities | id, username, email, avatar_path, avatar_content_type, password_hash, global_role, disabled_at, language, created_at |
| `sessions` | Revocable browser sessions | id_hash, user_id, created_at, expires_at, last_seen_at |
| `system_settings.session_policy` | Global administrator-managed session lifetime policy | max_lifetime_seconds, inactivity_timeout_seconds, inactivity_enabled |
| `instance_memberships` | Per-instance baseline role | instance_id, user_id, role (`user`, `manager`) |
@@ -46,6 +46,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
- A port tuple `(host_ip scope, host_port, protocol)` cannot be assigned twice by DoGaMa.
- Mount host paths are canonical absolute paths below configured roots.
- Secret fields never coexist in plaintext settings.
- User avatars are stored as internally named, normalized PNG files below DoGaMa's private avatar directory; SQLite stores only the internal filename and content type.
- Backup metadata becomes `available` only after archive finalization and checksum persistence.
- Imports expire and their staging directories are cleaned unless attached as a managed backup.
- Audit `summary_json` is allow-listed by event type and contains no secret values or full uploaded content.
+3 -1
View File
@@ -59,7 +59,9 @@ global `admin`/`user` role, activation and deactivation, and per-instance
`user`/`manager` memberships with explicit allow/deny overrides. Administrators
have implicit instance access, so per-instance assignments are shown only for
global users. Personal settings let every active user update their email,
password and saved interface language.
optional avatar, password and saved interface language. Avatars accept PNG or
JPEG input up to 2 MiB, are normalized locally for the UI, and fall back to
user initials.
The game-container label editor is a multiline `key=value` field with one label per line, the complete allowed-variable list, and explicit `apply immediately` versus `apply on next start` choices. Immediate application confirms that affected containers stop and are recreated, connected players disconnect, persistent data remains, and displays affected/running counts when known.
+48 -9
View File
@@ -66,13 +66,15 @@ func (p SessionPolicy) Validate() error {
// User is the authenticated principal exposed to application handlers.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
Language string `json:"language"`
Email string `json:"email"`
AuthenticatedAt time.Time `json:"-"`
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
Language string `json:"language"`
Email string `json:"email"`
AvatarPath string `json:"-"`
AvatarContentType string `json:"-"`
AuthenticatedAt time.Time `json:"-"`
}
// Session contains a new opaque browser credential and CSRF token.
@@ -242,15 +244,19 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
now := s.now().UTC()
var user User
var expiresAt, lastSeenAt, createdAt string
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.global_role, u.language, s.expires_at, s.last_seen_at, s.created_at
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.email, u.global_role, u.language, s.expires_at, s.last_seen_at, s.created_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Role, &user.Language, &expiresAt, &lastSeenAt, &createdAt)
WHERE s.id_hash = ? AND u.disabled_at IS NULL`, digest(token)).Scan(&user.ID, &user.Username, &user.Email, &user.Role, &user.Language, &expiresAt, &lastSeenAt, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidSession
}
if err != nil {
return User{}, fmt.Errorf("load session: %w", err)
}
var avatarPath, avatarContentType string
if avatarErr := s.db.QueryRowContext(ctx, "SELECT avatar_path, avatar_content_type FROM users WHERE id=?", user.ID).Scan(&avatarPath, &avatarContentType); avatarErr == nil {
user.AvatarPath, user.AvatarContentType = avatarPath, avatarContentType
}
expires, err1 := time.Parse(time.RFC3339Nano, expiresAt)
lastSeen, err2 := time.Parse(time.RFC3339Nano, lastSeenAt)
authenticatedAt, err3 := time.Parse(time.RFC3339Nano, createdAt)
@@ -317,6 +323,36 @@ func (s *Service) UpdateEmail(ctx context.Context, userID, email string) error {
return nil
}
func (s *Service) SetAvatar(ctx context.Context, userID, path, contentType string) (string, error) {
var previous string
if err := s.db.QueryRowContext(ctx, "SELECT avatar_path FROM users WHERE id=?", userID).Scan(&previous); err != nil {
return "", fmt.Errorf("load user avatar: %w", err)
}
result, err := s.db.ExecContext(ctx, "UPDATE users SET avatar_path=?, avatar_content_type=? WHERE id=?", path, contentType, userID)
if err != nil {
return "", fmt.Errorf("save user avatar: %w", err)
}
if changed, _ := result.RowsAffected(); changed != 1 {
return "", errors.New("user not found")
}
return previous, nil
}
func (s *Service) ClearAvatar(ctx context.Context, userID string) (string, error) {
var previous string
if err := s.db.QueryRowContext(ctx, "SELECT avatar_path FROM users WHERE id=?", userID).Scan(&previous); err != nil {
return "", fmt.Errorf("load user avatar: %w", err)
}
result, err := s.db.ExecContext(ctx, "UPDATE users SET avatar_path='', avatar_content_type='' WHERE id=?", userID)
if err != nil {
return "", fmt.Errorf("clear user avatar: %w", err)
}
if changed, _ := result.RowsAffected(); changed != 1 {
return "", errors.New("user not found")
}
return previous, nil
}
func (s *Service) ChangePassword(ctx context.Context, userID, current, next string) error {
var stored string
if err := s.db.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE id=? AND disabled_at IS NULL", userID).Scan(&stored); err != nil || !verifyPassword(current, stored) {
@@ -493,6 +529,9 @@ func validateCredentials(username, password string) error {
func normalizeEmail(value string) (string, error) {
email := strings.ToLower(strings.TrimSpace(value))
if email == "" || len(email) > 254 {
return "", errors.New("email address is invalid")
}
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Address != email {
return "", errors.New("email address is invalid")
+3 -1
View File
@@ -10,7 +10,9 @@ CREATE TABLE users (
global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')),
disabled_at TEXT,
created_at TEXT NOT NULL
, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')));
, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')),
avatar_path TEXT NOT NULL DEFAULT '',
avatar_content_type TEXT NOT NULL DEFAULT '' CHECK (avatar_content_type IN ('', 'image/png')));
CREATE TABLE sessions (
id_hash BLOB PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+1 -1
View File
@@ -20,7 +20,7 @@ func (s *server) accountPage(w http.ResponseWriter, r *http.Request) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
data := pageData{Title: "Settings", Language: s.language(r, user.Language), Languages: supportedLanguageOptions(), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "account"}
data := pageData{Title: localized(s.language(r, user.Language), "account.title"), Language: s.language(r, user.Language), Languages: supportedLanguageOptions(), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "account"}
if s.notifications != nil {
data.NotificationPreferences, _ = s.notifications.Preferences(r.Context(), user.ID)
}
+212
View File
@@ -0,0 +1,212 @@
package web
import (
"bytes"
"image"
_ "image/jpeg"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"unicode"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
)
const maxAvatarBytes = 2 << 20
func userInitials(user auth.User) string {
value := strings.TrimSpace(user.Username)
if value == "" {
value = strings.TrimSpace(user.Email)
}
parts := strings.FieldsFunc(value, func(r rune) bool { return unicode.IsSpace(r) || r == '-' || r == '_' })
if len(parts) >= 2 {
runes := []rune(strings.ToUpper(string([]rune(parts[0])[0]) + string([]rune(parts[1])[0])))
return string(runes[:minInt(2, len(runes))])
}
runes := []rune(strings.ToUpper(value))
if len(runes) > 2 {
runes = runes[:2]
}
return string(runes)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func (s *server) accountAvatar(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.AvatarPath == "" || user.AvatarContentType != "image/png" {
http.NotFound(w, r)
return
}
path, ok := s.avatarPath(user.AvatarPath)
if !ok {
http.NotFound(w, r)
return
}
body, err := os.ReadFile(path)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Cache-Control", "private, max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func (s *server) accountAvatarUpload(w http.ResponseWriter, r *http.Request) {
user, ok := s.avatarFormUser(w, r, maxAvatarBytes+maxFormBytes)
if !ok {
return
}
file, _, err := r.FormFile("avatar")
if err != nil {
s.problem(w, http.StatusUnprocessableEntity, localized(s.language(r, user.Language), "account.avatar_invalid"))
return
}
defer func() { _ = file.Close() }()
body, err := io.ReadAll(io.LimitReader(file, maxAvatarBytes+1))
if err != nil || len(body) > maxAvatarBytes {
s.problem(w, http.StatusRequestEntityTooLarge, localized(s.language(r, user.Language), "account.avatar_too_large"))
return
}
config, format, err := image.DecodeConfig(bytes.NewReader(body))
if err != nil || (format != "png" && format != "jpeg") || config.Width < 1 || config.Height < 1 || config.Width > 4096 || config.Height > 4096 || int64(config.Width)*int64(config.Height) > 16*1024*1024 {
s.problem(w, http.StatusUnprocessableEntity, localized(s.language(r, user.Language), "account.avatar_invalid"))
return
}
source, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
s.problem(w, http.StatusUnprocessableEntity, localized(s.language(r, user.Language), "account.avatar_invalid"))
return
}
imageBody := resizeAvatar(source, 256)
var encoded bytes.Buffer
if err := png.Encode(&encoded, imageBody); err != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
if err := os.MkdirAll(s.avatarRoot, 0o700); err != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
name := "avatar-" + randomToken() + ".png"
path := filepath.Join(s.avatarRoot, name)
temporary, err := os.CreateTemp(s.avatarRoot, ".avatar-*")
if err != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
temporaryPath := temporary.Name()
defer func() { _ = os.Remove(temporaryPath) }()
if err := temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
if _, err := temporary.Write(encoded.Bytes()); err != nil || temporary.Close() != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
if err := os.Rename(temporaryPath, path); err != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
previous, err := s.auth.SetAvatar(r.Context(), user.ID, name, "image/png")
if err != nil {
_ = os.Remove(path)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
s.removeAvatar(previous)
http.Redirect(w, r, "/account#profile", http.StatusSeeOther)
}
func (s *server) accountAvatarDelete(w http.ResponseWriter, r *http.Request) {
user, ok := s.accountForm(w, r)
if !ok {
return
}
current, err := s.auth.ClearAvatar(r.Context(), user)
if err != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
s.removeAvatar(current)
http.Redirect(w, r, "/account#profile", http.StatusSeeOther)
}
func (s *server) avatarFormUser(w http.ResponseWriter, r *http.Request, limit int64) (auth.User, bool) {
user, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return auth.User{}, false
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
if err := r.ParseMultipartForm(maxAvatarBytes); err != nil {
s.problem(w, http.StatusRequestEntityTooLarge, localized(s.language(r, user.Language), "account.avatar_too_large"))
return auth.User{}, false
}
session, err := r.Cookie(sessionCookie)
if err != nil || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return auth.User{}, false
}
return user, true
}
func resizeAvatar(source image.Image, max int) image.Image {
width, height := source.Bounds().Dx(), source.Bounds().Dy()
if width <= max && height <= max {
return source
}
scale := float64(max) / float64(width)
if height > width {
scale = float64(max) / float64(height)
}
newWidth, newHeight := maxInt(1, int(float64(width)*scale)), maxInt(1, int(float64(height)*scale))
destination := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
for y := 0; y < newHeight; y++ {
for x := 0; x < newWidth; x++ {
sx := source.Bounds().Min.X + x*width/newWidth
sy := source.Bounds().Min.Y + y*height/newHeight
destination.Set(x, y, source.At(sx, sy))
}
}
return destination
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func (s *server) avatarPath(name string) (string, bool) {
if name == "" || filepath.Base(name) != name || !strings.HasSuffix(name, ".png") || s.avatarRoot == "" {
return "", false
}
path := filepath.Join(s.avatarRoot, name)
return path, filepath.Dir(path) == filepath.Clean(s.avatarRoot)
}
func (s *server) removeAvatar(name string) {
if path, ok := s.avatarPath(name); ok {
_ = os.Remove(path)
}
}
+128
View File
@@ -0,0 +1,128 @@
package web
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestAccountAvatarLifecycleAndOwnership(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
authService := auth.New(db)
root := t.TempDir()
handler, err := NewHandlerCompleteWithCatalogAndDeployment(authService, nil, nil, nil, nil, nil, nil, nil, root, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
setup := request(t, handler, http.MethodGet, "/setup", nil)
setupCSRF := namedCookie(t, setup, csrfCookie)
created := formRequest(t, handler, "/setup", url.Values{"csrf_token": {setupCSRF.Value}, "username": {"admin"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, setupCSRF)
assertStatus(t, created, http.StatusSeeOther)
loginPage := request(t, handler, http.MethodGet, "/login", nil)
loginCSRF := namedCookie(t, loginPage, csrfCookie)
login := formRequest(t, handler, "/login", url.Values{"csrf_token": {loginCSRF.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, loginCSRF)
session := namedCookie(t, login, sessionCookie)
csrf := namedCookie(t, login, csrfCookie)
cookies := []*http.Cookie{session, csrf}
account := request(t, handler, http.MethodGet, "/account", cookies)
assertStatus(t, account, http.StatusOK)
if !strings.Contains(account.Body.String(), "admin@example.test") || strings.Contains(account.Body.String(), "Signed in as") {
t.Fatal("account profile does not show the current email without redundant identity text")
}
if response := uploadAvatar(t, handler, cookies, csrf.Value, "image/png", pngBytes(t)); response.Code != http.StatusSeeOther {
t.Fatalf("PNG upload status = %d", response.Code)
}
avatar := request(t, handler, http.MethodGet, "/account/avatar", cookies)
assertStatus(t, avatar, http.StatusOK)
if avatar.Header().Get("Content-Type") != "image/png" || len(avatar.Body.Bytes()) == 0 {
t.Fatal("stored avatar was not served as PNG")
}
files, _ := filepath.Glob(filepath.Join(root, ".dogama", "avatars", "*.png"))
if len(files) != 1 {
t.Fatalf("avatar files after upload = %d, want 1", len(files))
}
if response := uploadAvatar(t, handler, cookies, csrf.Value, "image/jpeg", jpegBytes(t)); response.Code != http.StatusSeeOther {
t.Fatalf("JPEG replacement status = %d", response.Code)
}
files, _ = filepath.Glob(filepath.Join(root, ".dogama", "avatars", "*.png"))
if len(files) != 1 {
t.Fatalf("avatar files after replacement = %d, want 1", len(files))
}
if response := formRequest(t, handler, "/account/avatar/delete", url.Values{"csrf_token": {csrf.Value}}, cookies...); response.Code != http.StatusSeeOther {
t.Fatalf("avatar deletion status = %d", response.Code)
}
if response := request(t, handler, http.MethodGet, "/account/avatar", cookies); response.Code != http.StatusNotFound {
t.Fatalf("deleted avatar status = %d, want 404", response.Code)
}
account = request(t, handler, http.MethodGet, "/account", cookies)
if !strings.Contains(account.Body.String(), `<span class="avatar">AD</span>`) {
t.Fatal("initial fallback is not visible after avatar deletion")
}
if response := uploadAvatar(t, handler, cookies, "wrong", "image/png", pngBytes(t)); response.Code != http.StatusForbidden {
t.Fatalf("invalid avatar CSRF status = %d", response.Code)
}
if response := request(t, handler, http.MethodGet, "/account/avatar", nil); response.Code != http.StatusSeeOther {
t.Fatalf("anonymous avatar status = %d", response.Code)
}
}
func uploadAvatar(t *testing.T, handler http.Handler, cookies []*http.Cookie, csrf, contentType string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var payload bytes.Buffer
form := multipart.NewWriter(&payload)
_ = form.WriteField("csrf_token", csrf)
part, err := form.CreateFormFile("avatar", "avatar.bin")
if err != nil {
t.Fatal(err)
}
_, _ = part.Write(body)
_ = form.Close()
req := httptest.NewRequest(http.MethodPost, "/account/avatar", &payload)
req.Header.Set("Content-Type", form.FormDataContentType())
for _, cookie := range cookies {
req.AddCookie(cookie)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, req)
return response
}
func pngBytes(t *testing.T) []byte { return encodeImage(t, "png") }
func jpegBytes(t *testing.T) []byte { return encodeImage(t, "jpeg") }
func encodeImage(t *testing.T, format string) []byte {
t.Helper()
imageValue := image.NewRGBA(image.Rect(0, 0, 8, 8))
for y := 0; y < 8; y++ {
for x := 0; x < 8; x++ {
imageValue.Set(x, y, color.RGBA{R: 220, G: 40, B: 150, A: 255})
}
}
var body bytes.Buffer
if format == "png" {
_ = png.Encode(&body, imageValue)
} else {
_ = jpeg.Encode(&body, imageValue, &jpeg.Options{Quality: 80})
}
return body.Bytes()
}
+11
View File
@@ -75,6 +75,17 @@ func init() {
}
}
func init() {
for language, values := range map[string]map[string]string{
"en": {"account.title": "Settings", "account.introduction": "Manage your personal account.", "account.profile": "Profile", "account.security": "Security", "account.language": "Language", "account.email": "Email", "account.save_email": "Save email", "account.avatar": "Avatar", "account.avatar_alt": "User avatar", "account.avatar_help": "PNG or JPEG, up to 2 MiB.", "account.save_avatar": "Save avatar", "account.delete_avatar": "Remove avatar", "account.avatar_invalid": "The avatar must be a valid PNG or JPEG image.", "account.avatar_too_large": "The avatar must be no larger than 2 MiB.", "account.current_password": "Current password", "account.new_password": "New password", "account.confirm_password": "Confirm password", "account.change_password": "Change password", "account.save_language": "Save language"},
"fr": {"account.title": "Paramètres", "account.introduction": "Gérez votre compte personnel.", "account.profile": "Profil", "account.security": "Sécurité", "account.language": "Langue", "account.email": "E-mail", "account.save_email": "Enregistrer l’e-mail", "account.avatar": "Avatar", "account.avatar_alt": "Avatar utilisateur", "account.avatar_help": "PNG ou JPEG, 2 Mio maximum.", "account.save_avatar": "Enregistrer l’avatar", "account.delete_avatar": "Supprimer l’avatar", "account.avatar_invalid": "L’avatar doit être une image PNG ou JPEG valide.", "account.avatar_too_large": "L’avatar ne doit pas dépasser 2 Mio.", "account.current_password": "Mot de passe actuel", "account.new_password": "Nouveau mot de passe", "account.confirm_password": "Confirmer le mot de passe", "account.change_password": "Changer le mot de passe", "account.save_language": "Enregistrer la langue"},
} {
for key, value := range values {
messages[language][key] = value
}
}
}
type languageOption struct {
Code string
Display string
+7 -2
View File
@@ -59,6 +59,7 @@ type server struct {
notifications *notification.Service
catalogScan func(context.Context) (catalog.ScanResult, error)
serversRoot string
avatarRoot string
moduleRuntime moduleRuntime
}
@@ -248,6 +249,7 @@ func NewHandlerCompleteWithCatalogAndDeployment(authService *auth.Service, repos
// only after construction to retain compatibility with API-only constructors.
if concrete, ok := h.(*completeHandler); ok {
concrete.server.serversRoot = filepath.Clean(serversRoot)
concrete.server.avatarRoot = filepath.Join(concrete.server.serversRoot, ".dogama", "avatars")
}
return h, nil
}
@@ -275,7 +277,7 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
}
func newHandlerServicesWithCatalog(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "divDuration": func(value time.Duration, divisor int64) int64 { return int64(value) / divisor }, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "initials": userInitials, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "divDuration": func(value time.Duration, divisor int64) int64 { return int64(value) / divisor }, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
@@ -283,7 +285,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
if locationErr != nil {
logger.Warn("invalid audit display timezone; using UTC", "timezone", os.Getenv("TZ"), "event", "audit.timezone.invalid")
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, auditLocation: location, notifications: notificationService, catalogScan: scanner}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, auditLocation: location, notifications: notificationService, catalogScan: scanner, avatarRoot: filepath.Join(os.TempDir(), "dogama-profile-avatars")}
if repository != nil {
s.permissions = authorization.New(repository)
}
@@ -386,7 +388,10 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("GET /catalog/{id}/deploy", s.deploymentPage)
mux.HandleFunc("POST /catalog/{id}/deploy", s.deploymentSubmit)
mux.HandleFunc("GET /account", s.accountPage)
mux.HandleFunc("GET /account/avatar", s.accountAvatar)
mux.HandleFunc("POST /account/notifications", s.accountNotificationsForm)
mux.HandleFunc("POST /account/avatar", s.accountAvatarUpload)
mux.HandleFunc("POST /account/avatar/delete", s.accountAvatarDelete)
mux.HandleFunc("POST /account/email", s.accountEmailForm)
mux.HandleFunc("POST /account/password", s.accountPasswordForm)
mux.HandleFunc("POST /account/language", s.accountLanguageForm)
+3 -3
View File
@@ -906,15 +906,15 @@ func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{session, sessionCSRF})
assertStatus(t, home, http.StatusOK)
if !strings.Contains(home.Body.String(), "Signed in as <strong>admin</strong>") {
t.Fatalf("protected page did not identify user: %s", home.Body.String())
if !strings.Contains(home.Body.String(), `<span class="avatar">AD</span>`) {
t.Fatalf("protected page did not render the user identity: %s", home.Body.String())
}
if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" || home.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
t.Fatal("security headers missing")
}
account := request(t, handler, http.MethodGet, "/account", []*http.Cookie{session, sessionCSRF})
assertStatus(t, account, http.StatusOK)
if !strings.Contains(account.Body.String(), `action="/account/email"`) || !strings.Contains(account.Body.String(), `action="/account/password"`) || !strings.Contains(account.Body.String(), `action="/account/language"`) {
if !strings.Contains(account.Body.String(), `admin@example.test`) || !strings.Contains(account.Body.String(), `action="/account/email"`) || !strings.Contains(account.Body.String(), `action="/account/password"`) || !strings.Contains(account.Body.String(), `action="/account/language"`) {
t.Fatal("account settings forms missing")
}
badAccountCSRF := formRequest(t, handler, "/account/email", url.Values{"csrf_token": {"wrong"}, "email": {"new@example.test"}}, session, sessionCSRF)
+10
View File
@@ -142,6 +142,16 @@ a{
font-weight: 900;
text-transform: uppercase
}
.avatar-image{
object-fit: cover;
overflow: hidden
}
.profile-identity{
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px
}
.app-main{
min-height: 100vh;
margin-left: 250px;
+39 -26
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>
Settings · DoGaMa
{{.Msg "account.title"}} · DoGaMa
</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/theme.css">
@@ -15,79 +15,92 @@
<main class="app-main">
<div class="page-heading">
<h1>
Settings
{{.Msg "account.title"}}
</h1>
<p>
Personal account preferences.
{{.Msg "account.introduction"}}
</p>
</div>
<nav class="tabs">
<a href="#profile">
Profile
{{.Msg "account.profile"}}
</a>
<a href="#security">
Security
{{.Msg "account.security"}}
</a>
<a href="#language">
Language
{{.Msg "account.language"}}
</a>
<a href="#notifications">
Notifications
{{.Msg "settings.notifications"}}
</a>
</nav>
<section class="panel settings-section" id="profile">
<h2>
Profile
{{.Msg "account.profile"}}
</h2>
<p>
Signed in as
<strong>
{{.User.Username}}
</strong>
.
</p>
<div class="profile-identity">
{{if .User.AvatarPath}}
<img class="avatar avatar-image" src="/account/avatar" alt="{{.Msg "account.avatar_alt"}}">
{{else}}
<span class="avatar">{{initials .User}}</span>
{{end}}
<strong>{{.User.Username}}</strong>
</div>
<form method="post" action="/account/email">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
Email
<input name="email" type="email" value="{{.User.Email}}" required autocomplete="email">
{{.Msg "account.email"}}
<input name="email" type="email" value="{{.User.Email}}" maxlength="254" required autocomplete="email">
</label>
<button type="submit">
Save email
{{.Msg "account.save_email"}}
</button>
</form>
<form method="post" action="/account/avatar" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>{{.Msg "account.avatar"}} <input name="avatar" type="file" accept="image/png,image/jpeg"></label>
<small class="help">{{.Msg "account.avatar_help"}}</small>
<button type="submit">{{.Msg "account.save_avatar"}}</button>
</form>
{{if .User.AvatarPath}}
<form method="post" action="/account/avatar/delete">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit">{{.Msg "account.delete_avatar"}}</button>
</form>
{{end}}
</section>
<section class="panel settings-section" id="security">
<h2>
Security
{{.Msg "account.security"}}
</h2>
<form method="post" action="/account/password">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
Current password
{{.Msg "account.current_password"}}
<input name="current_password" type="password" required autocomplete="current-password">
</label>
<label>
New password
{{.Msg "account.new_password"}}
<input name="new_password" type="password" required minlength="12" autocomplete="new-password">
</label>
<label>
Confirm new password
{{.Msg "account.confirm_password"}}
<input name="confirm_password" type="password" required minlength="12" autocomplete="new-password">
</label>
<button type="submit">
Change password
{{.Msg "account.change_password"}}
</button>
</form>
</section>
<section class="panel settings-section" id="language">
<h2>
Language
{{.Msg "account.language"}}
</h2>
<form method="post" action="/account/language">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
Language
{{.Msg "account.language"}}
<select name="language">
{{range .Languages}}
<option value="{{.Code}}"{{if eq $.Language .Code}} selected{{end}}>
@@ -97,7 +110,7 @@
</select>
</label>
<button type="submit">
Save language
{{.Msg "account.save_language"}}
</button>
</form>
</section>
+5 -4
View File
@@ -36,11 +36,12 @@
{{end}}
</nav>
<div class="account">
<p class="sr-only">{{.Msg "account.signed_in"}} <strong>{{.User.Username}}</strong>.</p>
<div class="account-card">
<span class="avatar">
DG
</span>
{{if .User.AvatarPath}}
<img class="avatar avatar-image" src="/account/avatar" alt="{{.Msg "account.avatar_alt"}}">
{{else}}
<span class="avatar">{{initials .User}}</span>
{{end}}
<span>
<strong>
{{.User.Username}}