go-loose/internal/server/profile.go
Daniel Noulet c7c81fb65c feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00

163 lines
5.9 KiB
Go

package server
import (
"bytes"
"errors"
"net/http"
"strings"
"time"
"github.com/danyel/go-loose/internal/avatar"
"github.com/danyel/go-loose/internal/store"
)
// maxProfileBody bounds a profile update. It allows an avatar at its 2 MiB
// limit after base64 inflation plus the JSON envelope.
const maxProfileBody = 4 << 20
// maxDisplayName bounds a self-selected display name.
const maxDisplayName = 200
type profileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Memberships []store.Membership `json:"memberships"`
IsSystemAdministrator bool `json:"is_system_administrator"`
NameCustomized bool `json:"name_customized"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at"`
ProfileUpdatedAt *time.Time `json:"profile_updated_at,omitempty"`
AvatarUpdatedAt *time.Time `json:"avatar_updated_at,omitempty"`
}
func (s *Server) profilePage(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "profile.html")
}
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "load profile", err)
return
}
writeJSON(w, http.StatusOK, s.profileResponse(profile))
}
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var request struct {
DisplayName *string `json:"display_name"`
Avatar string `json:"avatar"`
RemoveAvatar bool `json:"remove_avatar"`
}
if !decodeJSONLimit(w, r, maxProfileBody, &request) {
return
}
update := store.ProfileUpdate{}
if request.DisplayName != nil {
name := strings.TrimSpace(*request.DisplayName)
if name == "" || len(name) > maxDisplayName {
writeError(w, http.StatusBadRequest, "display name must be between 1 and 200 characters")
return
}
update.DisplayName = &name
}
switch {
case request.RemoveAvatar && request.Avatar != "":
writeError(w, http.StatusBadRequest, "send either a new picture or remove_avatar, not both")
return
case request.RemoveAvatar:
update.RemoveAvatar = true
case strings.TrimSpace(request.Avatar) != "":
data, contentType, err := avatar.Parse(request.Avatar)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
key, err := avatar.NewKey()
if err != nil {
s.internalError(w, "generate picture key", err)
return
}
update.AvatarKey, update.AvatarData, update.ContentType = key, data, contentType
case update.DisplayName == nil:
writeError(w, http.StatusBadRequest, "a display name or a picture is required")
return
}
if err := s.store.UpdateProfile(r.Context(), claimsFrom(r).UserID, update); err != nil {
s.handleStoreError(w, "update profile", err)
return
}
profile, err := s.store.Profile(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.handleStoreError(w, "reload profile", err)
return
}
writeJSON(w, http.StatusOK, s.profileResponse(profile))
}
// avatar serves a stored profile picture by its unguessable key.
//
// The route is deliberately public. A profile picture is rendered by other
// applications such as go-tell through a plain image tag, which cannot carry
// the authentication cookie, and the key is 256 bits of randomness rather than
// a user identifier. Nothing else about the user is exposed here.
func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if !avatar.ValidKey(key) {
writeError(w, http.StatusNotFound, "picture not found")
return
}
picture, err := s.store.Picture(r.Context(), key)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "picture not found")
return
}
s.internalError(w, "load profile picture", err)
return
}
w.Header().Set("Content-Type", picture.ContentType)
w.Header().Set("Cache-Control", "public, max-age=300")
w.Header().Set("ETag", avatar.ETag(picture.Data))
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
http.ServeContent(w, r, "", picture.UpdatedAt, bytes.NewReader(picture.Data))
}
func (s *Server) profileResponse(profile store.Profile) profileResponse {
return profileResponse{
ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName,
AvatarURL: s.avatarPath(profile.AvatarKey), Memberships: profile.Memberships,
IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized,
CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt,
ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt,
}
}
// avatarPath returns the same-origin path of a stored profile picture.
//
// The picture is served by this application, so a page of this application must
// reference it by a root-relative path. An absolute URL has to be built from
// cfg.BaseURL, and that value is routinely absent, stale, or aimed at a canonical
// address the browser is not using: it defaults to http://localhost:8080, which a
// browser asked to load it from any other origin refuses, and this application's
// own Content-Security-Policy allows images from 'self' only. A relative path is
// correct by construction in every deployment.
func (s *Server) avatarPath(key *string) string {
if key == nil || *key == "" {
return ""
}
return "/api/v1/avatars/" + *key
}
// avatarURL is the absolute form, for the clients that read a picture from another
// origin: /connect/userinfo is consumed by applications such as go-tell, which
// cannot resolve a relative path against their own host.
func (s *Server) avatarURL(r *http.Request, key *string) string {
path := s.avatarPath(key)
if path == "" {
return ""
}
return s.absoluteURL(r, path)
}