mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Bound free-text profile fields by raw byte length before cleanText's quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into cleanTextBounded and applying it to HandlePresenceUpdate's custom_status, SetCustomStatus, and group DM names.
347 lines
15 KiB
Go
347 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/syncutil"
|
|
"github.com/owncord/server/telemetry"
|
|
)
|
|
|
|
// UserService handles user profile and session operations.
|
|
type UserService struct {
|
|
st Store
|
|
profileLocks keyedMutex
|
|
}
|
|
|
|
// NewUserService creates a UserService.
|
|
func NewUserService(st Store) *UserService {
|
|
return &UserService{st: st}
|
|
}
|
|
|
|
// keyedMutex hands out a per-key lock so unrelated keys never contend, while
|
|
// operations on the same key serialize. UpdateProfile uses one keyed by user
|
|
// ID: it is an unsynchronized read-merge-write (GetUserByID, merge the
|
|
// patch, UpdateUserProfile), and PATCH /users/me can race POST
|
|
// /users/me/avatar for the same user — without serialization, the loser's
|
|
// write commits columns merged against a pre-race snapshot, silently
|
|
// reverting whatever the winner just changed. Entries are never removed;
|
|
// the key space is bounded by distinct user IDs, not by request rate.
|
|
type keyedMutex struct {
|
|
mu syncutil.Mutex
|
|
locks map[int64]*syncutil.Mutex
|
|
}
|
|
|
|
// lock acquires the per-key lock and returns a func to release it.
|
|
func (k *keyedMutex) lock(key int64) func() {
|
|
k.mu.Lock()
|
|
if k.locks == nil {
|
|
k.locks = make(map[int64]*syncutil.Mutex)
|
|
}
|
|
l, ok := k.locks[key]
|
|
if !ok {
|
|
l = &syncutil.Mutex{}
|
|
k.locks[key] = l
|
|
}
|
|
k.mu.Unlock()
|
|
|
|
l.Lock()
|
|
return l.Unlock
|
|
}
|
|
|
|
// AvatarFileURL is the server-relative path an uploaded avatar is served from.
|
|
// It is the ordinary attachment route: the upload handler writes an attachment
|
|
// row and points users.avatar here, and handleServeFile admits an unlinked
|
|
// attachment that some user's avatar names. Defined once because three places
|
|
// have to agree on the spelling — the upload response, the stored column, and
|
|
// the file route's authorization probe, which matches the column *by string*.
|
|
func AvatarFileURL(fileID string) string {
|
|
return "/api/v1/files/" + fileID
|
|
}
|
|
|
|
// ─── Profile field bounds ───────────────────────────────────────────────────
|
|
|
|
const (
|
|
// MaxDisplayNameLen bounds users.display_name. 32 matches the username
|
|
// cap: a nickname that could not fit where a username fits would render
|
|
// clipped in exactly the places the fallback puts the username.
|
|
MaxDisplayNameLen = 32
|
|
// MaxAboutLen bounds users.about — long enough for a paragraph, short
|
|
// enough that the popup's two-line section stays a section.
|
|
MaxAboutLen = 300
|
|
// MaxCustomStatusLen bounds users.custom_status: one line under a name.
|
|
MaxCustomStatusLen = 128
|
|
)
|
|
|
|
// ProfilePatch is a partial update to a user's profile. A nil field means
|
|
// "leave unchanged"; a non-nil pointer to the empty string clears the nullable
|
|
// fields (display name, about). The free-text fields are sanitized and
|
|
// length-checked *here* rather than in the handler, so every transport that
|
|
// can reach a profile gets the same rules.
|
|
type ProfilePatch struct {
|
|
Username string
|
|
Avatar *string
|
|
DisplayName *string
|
|
About *string
|
|
}
|
|
|
|
// nullable turns a sanitized, trimmed patch value into the column value:
|
|
// empty string clears the column, anything else is stored as-is.
|
|
func nullable(v string) *string {
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// cleanText strips HTML and trims a free-text profile field. Both the profile
|
|
// PATCH and the presence path run values through it before any bound check, so
|
|
// a payload cannot buy length with markup that is about to be stripped anyway.
|
|
//
|
|
// Uses sanitizeToFixpoint (message.go), not a bare sanitizer.Sanitize call:
|
|
// display name, about, custom status, and DM group names all render through
|
|
// the client's textContent-only path (same as message content), so a plain
|
|
// sanitizer.Sanitize call would persist and display literal '/>/&
|
|
// entities for ordinary punctuation instead of the characters typed — the
|
|
// exact bug sanitizeToFixpoint fixes for message content.
|
|
func cleanText(v string) string {
|
|
return strings.TrimSpace(sanitizeToFixpoint(v))
|
|
}
|
|
|
|
// cleanTextBounded is cleanText plus the raw-byte guard OC-0192 established
|
|
// for UpdateProfile's DisplayName/About fields, generalized for every other
|
|
// free-text field that runs through cleanText: SetCustomStatus,
|
|
// HandlePresenceUpdate's custom_status, and group DM names (OC-0195).
|
|
//
|
|
// cleanText's sanitizeToFixpoint pass is quadratic in input length, so a
|
|
// bound applied only to its *output* (a plain rune-count check on the
|
|
// cleaned string) still lets an adversarial nested-entity payload pay the
|
|
// full sanitize cost first — it can even sanitize down to something well
|
|
// under maxRunes and be silently accepted, having spent seconds of CPU to
|
|
// get there. The byte-length pre-check runs before cleanText ever does, on
|
|
// the untouched input, so the cost of rejecting an oversized value is
|
|
// O(len(v)) instead of the sanitizer's cost. *4 is deliberately looser than
|
|
// maxRunes — it exists only to keep the sanitizer from ever seeing a
|
|
// pathological payload, not to duplicate the real (rune-count) bound, which
|
|
// still runs afterward on the cleaned, trimmed value.
|
|
func cleanTextBounded(v string, maxRunes int, fieldName string) (string, error) {
|
|
if len(v) > maxRunes*4 {
|
|
return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes)
|
|
}
|
|
cleaned := cleanText(v)
|
|
if utf8.RuneCountInString(cleaned) > maxRunes {
|
|
return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
// resolveOptional picks the column value for one nullable text field: the
|
|
// sanitized patch when it was supplied, the existing row otherwise.
|
|
func resolveOptional(patch *string, existing *string) *string {
|
|
if patch == nil {
|
|
return existing
|
|
}
|
|
return nullable(cleanText(*patch))
|
|
}
|
|
|
|
// UpdateProfile applies a ProfilePatch: username and avatar as before, plus
|
|
// the nullable display name and about text. Returns the updated user for
|
|
// response building.
|
|
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch ProfilePatch) (*db.User, error) {
|
|
ctx, span := telemetry.GlobalTracer("service/user").Start(ctx, "UserService.UpdateProfile",
|
|
telemetry.Int64("user_id", userID),
|
|
)
|
|
start := time.Now()
|
|
defer func() {
|
|
telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start,
|
|
telemetry.String("method", "UpdateProfile"))
|
|
span.End()
|
|
}()
|
|
|
|
// OC-0192: bound the raw bytes before either reaches cleanText
|
|
// (sanitizeToFixpoint) below — its cost is quadratic in input length,
|
|
// and an adversarial nested-entity payload can sanitize down to
|
|
// something well under the rune-count bound while still costing seconds
|
|
// of CPU to get there, so the rune-count check alone never rejects it
|
|
// early. This is the same cheap byte-length pre-check the handler uses
|
|
// for username/avatar (profile_handler.go); *4 still admits any
|
|
// legitimate UTF-8 value at the rune bound. UpdateProfile is the one
|
|
// function every transport reaches (see ProfilePatch's doc comment), so
|
|
// the guard belongs here rather than only in the REST handler.
|
|
if patch.DisplayName != nil && len(*patch.DisplayName) > MaxDisplayNameLen*4 {
|
|
return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen)
|
|
}
|
|
if patch.About != nil && len(*patch.About) > MaxAboutLen*4 {
|
|
return nil, fmt.Errorf("%w: about must be at most %d characters", ErrBadRequest, MaxAboutLen)
|
|
}
|
|
|
|
if patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen {
|
|
return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen)
|
|
}
|
|
if patch.About != nil && utf8.RuneCountInString(cleanText(*patch.About)) > MaxAboutLen {
|
|
return nil, fmt.Errorf("%w: about must be at most %d characters", ErrBadRequest, MaxAboutLen)
|
|
}
|
|
|
|
// The update writes every column, so a partial patch has to be merged
|
|
// against the current row first — otherwise setting only a display name
|
|
// would silently clear the about text. That read-merge-write must be
|
|
// serialized per user: PATCH /users/me and POST /users/me/avatar both
|
|
// land here for the same account, and without a lock the second call's
|
|
// read can land between the first call's read and write, so its merge
|
|
// (built from the pre-race row) silently reverts the first call's change
|
|
// when it writes.
|
|
unlock := s.profileLocks.lock(userID)
|
|
defer unlock()
|
|
|
|
current, err := s.st.GetUserByID(ctx, userID)
|
|
if err != nil || current == nil {
|
|
return nil, fmt.Errorf("%w: user not found", ErrNotFound)
|
|
}
|
|
// An empty Username means "unspecified", the same as a nil
|
|
// DisplayName/About pointer — merged against the current row rather
|
|
// than written verbatim. This is what lets an avatar-only caller
|
|
// (handleUploadAvatar) leave username alone without handing over a
|
|
// snapshot that could be stale by the time this call lands: PATCH
|
|
// /users/me always validates and rejects an empty username before
|
|
// calling in, so "" never reaches here as a real rename request.
|
|
username := patch.Username
|
|
if username == "" {
|
|
username = current.Username
|
|
}
|
|
avatar := current.Avatar
|
|
if patch.Avatar != nil {
|
|
avatar = nullable(*patch.Avatar)
|
|
}
|
|
displayName := resolveOptional(patch.DisplayName, current.DisplayName)
|
|
about := resolveOptional(patch.About, current.About)
|
|
|
|
if err := s.st.UpdateUserProfile(ctx, userID, username, avatar, displayName, about); err != nil {
|
|
if db.IsUniqueConstraintError(err) {
|
|
return nil, fmt.Errorf("%w: username is already taken", ErrConflict)
|
|
}
|
|
return nil, fmt.Errorf("%w: failed to update profile: %v", ErrInternal, err)
|
|
}
|
|
user, err := s.st.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to fetch updated user: %v", ErrInternal, err)
|
|
}
|
|
// Audit rows must survive a request canceled after the write committed.
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "profile_update", "user", userID,
|
|
fmt.Sprintf("username=%s", username))
|
|
slog.Info("profile updated", "user_id", userID, "username", username)
|
|
return user, nil
|
|
}
|
|
|
|
// SetCustomStatus stores (or clears, with an empty string) the user's custom
|
|
// status line. It is the presence path's counterpart to UpdateProfile: the
|
|
// value persists across reconnects and is cleared explicitly on logout, which
|
|
// is why it is stored rather than held on the connection.
|
|
func (s *UserService) SetCustomStatus(ctx context.Context, userID int64, text string) error {
|
|
cleaned, err := cleanTextBounded(text, MaxCustomStatusLen, "custom_status")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.st.UpdateUserCustomStatus(ctx, userID, nullable(cleaned)); err != nil {
|
|
return fmt.Errorf("%w: failed to update custom status: %v", ErrInternal, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearCustomStatus wipes the custom status line. Called on logout: the text
|
|
// is a "what I am doing right now" note, and leaving it standing after the
|
|
// user signed out states something about them that is no longer true.
|
|
func (s *UserService) ClearCustomStatus(ctx context.Context, userID int64) error {
|
|
if err := s.st.UpdateUserCustomStatus(ctx, userID, nil); err != nil {
|
|
return fmt.Errorf("%w: failed to clear custom status: %v", ErrInternal, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateIdentityKey publishes the user's long-term E2EE identity public key
|
|
// (F3 voice E2EE TOFU). Last write wins; every write is audited so a key
|
|
// rotation — which peers surface as a TOFU mismatch — leaves a trail.
|
|
// Returns the updated user for response building.
|
|
func (s *UserService) UpdateIdentityKey(ctx context.Context, userID int64, key string) (*db.User, error) {
|
|
if err := s.st.UpdateUserIdentityKey(ctx, userID, &key); err != nil {
|
|
return nil, fmt.Errorf("%w: failed to update identity key", ErrInternal)
|
|
}
|
|
user, err := s.st.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to fetch updated user", ErrInternal)
|
|
}
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "identity_key_update", "user", userID, "")
|
|
slog.Info("identity key published", "user_id", userID)
|
|
return user, nil
|
|
}
|
|
|
|
// ChangePasswordResult reports a completed password change. RevokeFailed is
|
|
// set when the password committed but other sessions could not be revoked —
|
|
// a partial success the caller must surface as a warning, never as a 5xx:
|
|
// the old password is already unusable, so telling the user the change
|
|
// "failed" walks them into retrying with a dead password and tripping the
|
|
// password-confirm lockout.
|
|
type ChangePasswordResult struct {
|
|
SessionsRevoked int64
|
|
RevokeFailed bool
|
|
}
|
|
|
|
// ChangePassword updates the user's password and revokes other sessions.
|
|
func (s *UserService) ChangePassword(ctx context.Context, userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) {
|
|
if err := s.st.UpdateUserPassword(ctx, userID, newPasswordHash); err != nil {
|
|
return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password: %v", ErrInternal, err)
|
|
}
|
|
|
|
// The password is committed from here on: every path below reports
|
|
// success and writes the audit row — even if the request ctx has been
|
|
// canceled, revocation and audit are the security tail of the change.
|
|
tailCtx := context.WithoutCancel(ctx)
|
|
|
|
var res ChangePasswordResult
|
|
revoked, err := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID)
|
|
res.SessionsRevoked = revoked
|
|
if err != nil {
|
|
slog.Error("UserService.ChangePassword DeleteOtherSessions", "err", err, "user_id", userID)
|
|
// One bounded compensating retry: revocation is the security tail of
|
|
// the change and a single immediate retry covers transient write-lock
|
|
// contention. ponytail: one retry, add backoff only if logs show it.
|
|
if revokedRetry, retryErr := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID); retryErr == nil {
|
|
res.SessionsRevoked += revokedRetry
|
|
} else {
|
|
res.RevokeFailed = true
|
|
}
|
|
}
|
|
db.WriteAudit(tailCtx, s.st, userID, "password_change", "user", userID, "password changed")
|
|
slog.Info("password changed", "user_id", userID,
|
|
"sessions_revoked", res.SessionsRevoked, "revoke_failed", res.RevokeFailed)
|
|
return res, nil
|
|
}
|
|
|
|
// ListSessions returns all active sessions for a user.
|
|
func (s *UserService) ListSessions(ctx context.Context, userID int64) ([]db.Session, error) {
|
|
sessions, err := s.st.ListUserSessions(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to list sessions: %v", ErrInternal, err)
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
// RevokeSession deletes a specific session owned by the user.
|
|
func (s *UserService) RevokeSession(ctx context.Context, userID, sessionID int64) error {
|
|
if err := s.st.DeleteSessionByID(ctx, sessionID, userID); err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
return fmt.Errorf("%w: session not found", ErrNotFound)
|
|
}
|
|
return fmt.Errorf("%w: failed to revoke session: %v", ErrInternal, err)
|
|
}
|
|
// Audit rows must survive a request canceled after the delete committed.
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "session_revoke", "session", sessionID, "session revoked")
|
|
slog.Info("session revoked", "user_id", userID, "session_id", sessionID)
|
|
return nil
|
|
}
|