2026-10-04 12:24:13 +02:00
|
|
|
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,
|
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
|
|
|
AvatarURL: s.avatarPath(profile.AvatarKey), Memberships: profile.Memberships,
|
2026-10-04 12:24:13 +02:00
|
|
|
IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized,
|
|
|
|
|
CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt,
|
|
|
|
|
ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// 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 {
|
2026-10-04 12:24:13 +02:00
|
|
|
if key == nil || *key == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
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
|
|
|
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)
|
2026-10-04 12:24:13 +02:00
|
|
|
}
|