Compare commits
2
Commits
62f2300a46
...
1bb9d20f88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb9d20f88 | ||
|
|
8eff892903 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user