mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020) * fix(db): 1 defect(s) (OC-0096) * fix(admin): 1 defect(s) (OC-0097) * fix(auth): 2 defect(s) (OC-0099, OC-0021) * fix(voice): 1 defect(s) (OC-0018) * fix(admin): 1 defect(s) (OC-0045) * fix(api): 1 defect(s) (OC-0103) * fix(client): 1 defect(s) (OC-0105) * fix(client): 1 defect(s) (OC-0107) * fix(api): 1 defect(s) (OC-0109) * fix(api): 1 defect(s) (OC-0112) * test(admin): compare restore bytes with bytes.Equal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0095, OC-0014) OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext. OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token. * fix(profile): 2 defect(s) (OC-0100, OC-0102) * fix(service): 1 defect(s) (OC-0022) Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate. * fix(api): 1 defect(s) (OC-0048) * chore(workflows): correct stale model labels in bughunt-fix phase details Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(client): 1 defect(s) (OC-0015) * fix(voice): 1 defect(s) (OC-0002) * test: fix two CI-only failures in the batch-4 test suite The delete-account broadcast test now observes member_ban on a second client's socket: the hub broadcasts and then force-disconnects the target, so on a slow runner the close could beat the target's own copy of the frame. The observer is also the party the event exists for. The voice e2e mock now echoes the real joined channel id on voice_leave (it hardcoded channel_id 0, which the dispatcher's channel-matched self-leave teardown correctly ignores), and the rejoin test waits for the mock's delayed echoes to settle before clicking the row again — clicking inside the echo window toggled a leave instead of a join. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
303 lines
12 KiB
Go
303 lines
12 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))
|
|
}
|
|
|
|
// 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()
|
|
}()
|
|
|
|
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 := cleanText(text)
|
|
if utf8.RuneCountInString(cleaned) > MaxCustomStatusLen {
|
|
return fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen)
|
|
}
|
|
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
|
|
}
|