mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 1 defect(s) (OC-0151)
* fix(ws): 1 defect(s) (OC-0152)
* fix(admin): 1 defect(s) (OC-0153)
* fix(admin): 1 defect(s) (OC-0154)
* fix(voice): 2 defect(s) (OC-0155, OC-0167)
Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.
- OC-0155: back-to-back rotations (the second run immediately by
drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
entirely, letting a key holder joining a large ongoing call burst every
queued announce's offer unpaced.
The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).
* fix(client): 1 defect(s) (OC-0156)
createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.
* fix(client): 2 defect(s) (OC-0160, OC-0163)
OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.
OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.
* fix(voice): 1 defect(s) (OC-0162)
updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.
* fix(client): 1 defect(s) (OC-0164)
* fix(plugin): 1 defect(s) (OC-0165)
scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.
* fix(ws): 1 defect(s) (OC-0166)
Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.
* fix(db): 1 defect(s) (OC-0168)
* fix(client): 1 defect(s) (OC-0169)
* fix(client): 1 defect(s) (OC-0171)
addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.
* fix(voice): 1 defect(s) (OC-0172)
* fix(client): 1 defect(s) (OC-0174)
* fix(ws): 1 defect(s) (OC-0175)
* fix(client): 1 defect(s) (OC-0177)
* fix(client): 1 defect(s) (OC-0178)
* fix(voice): 1 defect(s) (OC-0179)
Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.
* fix(client): 1 defect(s) (OC-0182)
* fix(plugin): 1 defect(s) (OC-0183)
* fix(client): 1 defect(s) (OC-0184)
Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.
* fix(client): 1 defect(s) (OC-0185)
Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): 1 defect(s) (OC-0186)
* fix(client): 1 defect(s) (OC-0187)
The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.
* fix(client): 1 defect(s) (OC-0189)
DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.
* fix(client): 1 defect(s) (OC-0190)
* fix(client): 1 defect(s) (OC-0191)
* fix(client): 2 defect(s) (OC-0157, OC-0176)
* fix(client): 1 defect(s) (OC-0161)
confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.
* fix(admin): 1 defect(s) (OC-0173)
* fix(identity): 1 defect(s) (OC-0180)
* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)
handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.
Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.
Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(admin): role changes commit with no client ever notified (OC-0170)
broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.
Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.
Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): username rename stomps the profile card header (OC-0188)
The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.
Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): settings overlay never focuses when mounted already-open (OC-0181)
mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.
Attach root before syncing initial state so focusDialog() runs against a
connected subtree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore: satisfy the CI gates for this fix batch
The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.
golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
and OC-0151 the last use of api's sanitizer, leaving both package-level
bluemonday vars unused. Remove them along with the now-unused imports,
and reword the comments that named them so they still explain why the
fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.
tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
must supply typingUsers.
prettier: reformat the five files this batch touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore(ledger): record the 2026-08-19 hunt and its fixes
Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".
"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.
OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* test(e2e): make the voice-roster join fixture self-consistent
The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.
The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.
Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.
Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
---------
Co-authored-by: Claude <noreply@anthropic.com>
647 lines
23 KiB
Go
647 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/service"
|
|
"github.com/owncord/server/ws"
|
|
)
|
|
|
|
// ─── Request / Response types ────────────────────────────────────────────────
|
|
|
|
// updateProfileRequest is the JSON body for PATCH /api/v1/users/me.
|
|
// identity_public_key, when present, publishes the client's long-term E2EE
|
|
// identity public key (F3 voice E2EE TOFU); omitted = leave unchanged.
|
|
type updateProfileRequest struct {
|
|
Username string `json:"username"`
|
|
Avatar *string `json:"avatar"`
|
|
IdentityPublicKey *string `json:"identity_public_key"`
|
|
// DisplayName and About are omitted = unchanged, "" = cleared. Both are
|
|
// sanitized and length-checked in UserService, which is also the path a
|
|
// non-REST caller would take.
|
|
DisplayName *string `json:"display_name"`
|
|
About *string `json:"about"`
|
|
}
|
|
|
|
// changePasswordRequest is the JSON body for PUT /api/v1/users/me/password.
|
|
type changePasswordRequest struct {
|
|
OldPassword string `json:"old_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
// sessionResponse is the JSON shape for a single session in list responses.
|
|
type sessionResponse struct {
|
|
ID int64 `json:"id"`
|
|
Device string `json:"device"`
|
|
IP string `json:"ip"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastUsed string `json:"last_used"`
|
|
IsCurrent bool `json:"is_current"`
|
|
}
|
|
|
|
// sessionsListResponse is the JSON envelope for GET /api/v1/users/me/sessions.
|
|
type sessionsListResponse struct {
|
|
Sessions []sessionResponse `json:"sessions"`
|
|
}
|
|
|
|
// ─── Route mounting ──────────────────────────────────────────────────────────
|
|
|
|
// ProfileBroadcaster is the interface the profile handler uses to notify
|
|
// connected WebSocket clients about profile changes.
|
|
type ProfileBroadcaster interface {
|
|
BroadcastUserUpdate(u ws.UserUpdate)
|
|
}
|
|
|
|
// MountProfileRoutes registers user profile management endpoints.
|
|
// All routes require authentication. trustedProxies is used for rate limiting.
|
|
//
|
|
// store may be nil, in which case the avatar-upload route is not registered —
|
|
// a server with no storage backend has nowhere to put the bytes, and a route
|
|
// that 500s on every call is worse than one that 404s.
|
|
func MountProfileRoutes(r chi.Router, database *db.DB, svc *service.Services, store FileStore, limiter *auth.RateLimiter, trustedProxies []string, broadcaster ProfileBroadcaster) {
|
|
r.Route("/api/v1/users/me", func(r chi.Router) {
|
|
r.Use(AuthMiddleware(database))
|
|
|
|
r.With(RateLimitMiddleware(limiter, "profile:", profileUpdateRateLimitPerMinute, time.Minute, trustedProxies)).
|
|
Patch("/", handleUpdateProfile(svc, broadcaster))
|
|
|
|
r.With(RateLimitMiddleware(limiter, "pw:", profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)).
|
|
Put("/password", handleChangePassword(svc, limiter))
|
|
|
|
if store != nil {
|
|
r.With(MaxBodySize(avatarMaxBodySize)).
|
|
Post("/avatar", handleUploadAvatar(database, svc, store, limiter, broadcaster))
|
|
}
|
|
|
|
r.Get("/sessions", handleListSessions(svc))
|
|
r.Delete("/sessions/{id}", handleRevokeSession(svc))
|
|
})
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
// validateIdentityKey checks that key is non-empty, at most 128 characters and
|
|
// valid standard-alphabet base64 (padded or unpadded) — the same posture as
|
|
// the WS voice_e2ee_announce public_key validation.
|
|
func validateIdentityKey(key string) error {
|
|
if key == "" {
|
|
return fmt.Errorf("identity_public_key must not be empty")
|
|
}
|
|
if len(key) > 128 {
|
|
return fmt.Errorf("identity_public_key too large (max 128 characters)")
|
|
}
|
|
if _, err := base64.StdEncoding.DecodeString(key); err == nil {
|
|
return nil
|
|
}
|
|
if _, err := base64.RawStdEncoding.DecodeString(key); err != nil {
|
|
return fmt.Errorf("identity_public_key is not valid base64")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateAvatarURL checks that avatar is either empty or a valid https:// URL
|
|
// no longer than maxAvatarURLLen characters.
|
|
func validateAvatarURL(avatar string) error {
|
|
if avatar == "" {
|
|
return nil
|
|
}
|
|
if len(avatar) > maxAvatarURLLen {
|
|
return fmt.Errorf("avatar URL too long (max %d characters)", maxAvatarURLLen)
|
|
}
|
|
parsed, err := url.Parse(avatar)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
|
return fmt.Errorf("avatar URL must use https://")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateDisplayName rejects a nickname that would render as something other
|
|
// than what it says. Length and emptiness are the service's job (empty clears
|
|
// the field); this is the character-class check auth.ValidateUsername applies
|
|
// for the same reason — a display name stands in for a username on every
|
|
// message row, so a bidi override or a control character in one is a spoof.
|
|
func validateDisplayName(name string) error {
|
|
for _, r := range name {
|
|
if unicode.IsControl(r) || unicode.In(r, unicode.Cf) {
|
|
return fmt.Errorf("display_name must not contain control or invisible characters")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// allowedAvatarMIME is the set of image types an avatar may be, matched against
|
|
// the type sniffed from the file's own bytes. GIF is absent (an animated
|
|
// avatar in every message row is a distraction the renderer cannot opt out of)
|
|
// and so is SVG, for the same reason emoji refuse it: it is markup with script
|
|
// and external-fetch capability, and an avatar is rendered inline by
|
|
// definition.
|
|
var allowedAvatarMIME = map[string]bool{
|
|
"image/png": true,
|
|
"image/jpeg": true,
|
|
"image/webp": true,
|
|
}
|
|
|
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
|
|
|
// handleUpdateProfile processes PATCH /api/v1/users/me.
|
|
func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := r.Context().Value(UserKey).(*db.User)
|
|
if !ok || user == nil {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED", Message: "not authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
var req updateProfileRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: "malformed request body",
|
|
})
|
|
return
|
|
}
|
|
|
|
// OC-0151: bound the raw field before it ever reaches the fixpoint
|
|
// sanitizer below, for the same reason as the register path
|
|
// (auth_handler.go's registerReadRequest) — sanitizeToFixpoint's
|
|
// cost is quadratic in input length, and nothing bounds this field
|
|
// before it runs. This is a cheap byte-length pre-check — *4 still
|
|
// admits any legitimate 32-rune UTF-8 username.
|
|
if len(req.Username) > maxLoginUsernameLen*4 {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: "username is too long",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Use the fixpoint sanitizer (service.SanitizeText), not a bare
|
|
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always
|
|
// HTML-escaped, so a plain apostrophe would be persisted as '
|
|
// and login (which never re-escapes) would look the account up
|
|
// under a name that no longer matches. See service.SanitizeText's
|
|
// doc comment and the register path (auth_handler.go), which
|
|
// already canonicalizes the same way.
|
|
req.Username = strings.TrimSpace(service.SanitizeText(req.Username))
|
|
if req.Username == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: "username is required",
|
|
})
|
|
return
|
|
}
|
|
if err := auth.ValidateUsername(req.Username); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Sanitize and validate avatar if provided. Use the fixpoint
|
|
// sanitizer (service.SanitizeText), not a bare
|
|
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always HTML-escaped, so a URL with more
|
|
// than one query parameter would have its "&" separators rewritten
|
|
// to "&" and be persisted (and served) broken. Same reasoning as
|
|
// the username path above.
|
|
if req.Avatar != nil {
|
|
trimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar))
|
|
if err := validateAvatarURL(trimmed); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
req.Avatar = &trimmed
|
|
}
|
|
|
|
// display_name gets the same username-shaped scrutiny beyond length:
|
|
// it is rendered wherever a username is, so control characters and
|
|
// bidi overrides are exactly as unwelcome here. Length, sanitization
|
|
// and the empty-clears-it rule live in UserService.
|
|
if req.DisplayName != nil {
|
|
if err := validateDisplayName(*req.DisplayName); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate the identity key before any write so the request is
|
|
// all-or-nothing.
|
|
if req.IdentityPublicKey != nil {
|
|
trimmed := strings.TrimSpace(*req.IdentityPublicKey)
|
|
if err := validateIdentityKey(trimmed); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
req.IdentityPublicKey = &trimmed
|
|
}
|
|
|
|
updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{
|
|
Username: req.Username,
|
|
Avatar: req.Avatar,
|
|
DisplayName: req.DisplayName,
|
|
About: req.About,
|
|
})
|
|
if err != nil {
|
|
writeServiceError(r.Context(), w, err)
|
|
return
|
|
}
|
|
|
|
if req.IdentityPublicKey != nil {
|
|
// Captured into a separate variable rather than reassigned into
|
|
// updated: on failure below, updated still holds the profile
|
|
// snapshot that DID commit, so it can still be broadcast instead
|
|
// of discarded.
|
|
withKey, keyErr := svc.Users.UpdateIdentityKey(r.Context(), user.ID, *req.IdentityPublicKey)
|
|
if keyErr != nil {
|
|
// The username/avatar/display_name/about write above already
|
|
// committed — only the identity key failed. Broadcasting the
|
|
// committed half keeps every other connected client in sync
|
|
// even though this request reports failure; leaving it
|
|
// unbroadcast would strand them on the old profile until
|
|
// their next ready.
|
|
broadcastUserUpdate(broadcaster, updated)
|
|
writeServiceError(r.Context(), w, keyErr)
|
|
return
|
|
}
|
|
updated = withKey
|
|
}
|
|
|
|
broadcastUserUpdate(broadcaster, updated)
|
|
|
|
writeJSON(w, http.StatusOK, toUserResponse(updated))
|
|
}
|
|
}
|
|
|
|
// broadcastUserUpdate pushes a profile snapshot to every connected client.
|
|
// Every profile mutation goes through it so a new one cannot ship half the
|
|
// fields — user_update replaces the client's copy wholesale.
|
|
func broadcastUserUpdate(broadcaster ProfileBroadcaster, u *db.User) {
|
|
if broadcaster == nil || u == nil {
|
|
return
|
|
}
|
|
broadcaster.BroadcastUserUpdate(ws.UserUpdate{
|
|
UserID: u.ID,
|
|
Username: u.Username,
|
|
Avatar: u.Avatar,
|
|
DisplayName: u.DisplayName,
|
|
About: u.About,
|
|
IdentityPublicKey: u.IdentityPublicKey,
|
|
})
|
|
}
|
|
|
|
// handleChangePassword processes PUT /api/v1/users/me/password.
|
|
func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := r.Context().Value(UserKey).(*db.User)
|
|
if !ok || user == nil {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED", Message: "not authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
// BUG-111: Per-user lockout to prevent password brute-force via stolen session.
|
|
lockKey := auth.Key("pw_confirm_lock", user.ID)
|
|
if limiter.IsLockedOut(lockKey) {
|
|
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
|
Error: "RATE_LIMITED", Message: "too many failed attempts, try again later",
|
|
})
|
|
return
|
|
}
|
|
|
|
var req changePasswordRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: "malformed request body",
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.OldPassword == "" || req.NewPassword == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: "old_password and new_password are required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Verify old password using constant-time bcrypt comparison.
|
|
failKey := auth.Key("pw_confirm_fail", user.ID)
|
|
if !auth.CheckPassword(user.PasswordHash, req.OldPassword) {
|
|
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
|
|
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
|
|
}
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN", Message: "incorrect password",
|
|
})
|
|
return
|
|
}
|
|
limiter.Reset(r.Context(), failKey)
|
|
|
|
// Reject same old/new password.
|
|
if req.OldPassword == req.NewPassword {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: "new password must be different from old password",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate new password strength.
|
|
if err := auth.ValidatePasswordStrength(req.NewPassword); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "INVALID_INPUT", Message: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Hash new password.
|
|
hash, err := auth.HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR", Message: "failed to process password change",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Delegate to service for password update + session revocation.
|
|
sess, _ := r.Context().Value(SessionKey).(*db.Session)
|
|
keepSessionID := int64(0)
|
|
if sess != nil {
|
|
keepSessionID = sess.ID
|
|
}
|
|
|
|
res, err := svc.Users.ChangePassword(r.Context(), user.ID, hash, keepSessionID)
|
|
if err != nil {
|
|
// Only reachable when the password itself failed to commit.
|
|
writeServiceError(r.Context(), w, err)
|
|
return
|
|
}
|
|
if res.RevokeFailed {
|
|
// Partial success: the password IS changed; only revoking the
|
|
// other sessions failed. A 5xx here would tell the user to retry
|
|
// with a password that no longer works.
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"warning": "password changed, but other sessions could not be revoked; revoke them from the sessions list",
|
|
"sessions_revoked": res.SessionsRevoked,
|
|
})
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleListSessions processes GET /api/v1/users/me/sessions.
|
|
func handleListSessions(svc *service.Services) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := r.Context().Value(UserKey).(*db.User)
|
|
if !ok || user == nil {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED", Message: "not authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
// An API-token principal has a nil session (middleware.go); the list
|
|
// still works — no row is marked current. Only IsCurrent needs it.
|
|
sess, _ := r.Context().Value(SessionKey).(*db.Session)
|
|
|
|
sessions, err := svc.Users.ListSessions(r.Context(), user.ID)
|
|
if err != nil {
|
|
writeServiceError(r.Context(), w, err)
|
|
return
|
|
}
|
|
|
|
resp := sessionsListResponse{
|
|
Sessions: make([]sessionResponse, 0, len(sessions)),
|
|
}
|
|
for _, s := range sessions {
|
|
resp.Sessions = append(resp.Sessions, sessionResponse{
|
|
ID: s.ID,
|
|
Device: s.Device,
|
|
IP: s.IP,
|
|
CreatedAt: s.CreatedAt,
|
|
LastUsed: s.LastUsed,
|
|
IsCurrent: sess != nil && s.ID == sess.ID,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
// handleRevokeSession processes DELETE /api/v1/users/me/sessions/{id}.
|
|
func handleRevokeSession(svc *service.Services) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := r.Context().Value(UserKey).(*db.User)
|
|
if !ok || user == nil {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED", Message: "not authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
sessionID, ok := parseIDParam(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := svc.Users.RevokeSession(r.Context(), user.ID, sessionID); err != nil {
|
|
writeServiceError(r.Context(), w, err)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleUploadAvatar processes POST /api/v1/users/me/avatar (multipart: `file`).
|
|
//
|
|
// The bytes land in the ordinary attachments table with no channel, and the
|
|
// user's avatar column is pointed at /api/v1/files/{id}. That is what makes
|
|
// the picture readable: an unlinked attachment is private to its uploader, and
|
|
// handleServeFile additionally admits one that some user's avatar currently
|
|
// points at — so an avatar is public exactly while it is in use and stops
|
|
// being readable the moment it is replaced.
|
|
//
|
|
// PATCH /users/me still takes an https:// URL; this route is the other way to
|
|
// set the same field, and both end at the same column.
|
|
func handleUploadAvatar(
|
|
database *db.DB,
|
|
svc *service.Services,
|
|
store FileStore,
|
|
limiter *auth.RateLimiter,
|
|
broadcaster ProfileBroadcaster,
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := r.Context().Value(UserKey).(*db.User)
|
|
if !ok || user == nil {
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
Error: "UNAUTHORIZED", Message: "not authenticated",
|
|
})
|
|
return
|
|
}
|
|
|
|
if limiter != nil && !limiter.Allow(auth.Key("avatar_upload", user.ID), avatarUploadRateLimitPerMinute, time.Minute) {
|
|
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
|
Error: "RATE_LIMITED", Message: "avatar upload rate limit exceeded, try again later",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Bound the body before the multipart parser touches it: the route
|
|
// carries MaxBodySize too, but the parser is what turns an unbounded
|
|
// body into heap, so the handler states its own limit.
|
|
r.Body = http.MaxBytesReader(w, r.Body, avatarMaxBodySize)
|
|
if err := r.ParseMultipartForm(avatarMultipartMemoryLimit); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST", Message: "invalid multipart form",
|
|
})
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST", Message: "missing file field",
|
|
})
|
|
return
|
|
}
|
|
defer file.Close() //nolint:errcheck
|
|
|
|
raw, mimeType, width, height, ok := avatarUploadReadImage(w, file)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
fileID := uuid.New().String()
|
|
written, saveErr := store.Save(fileID, bytes.NewReader(raw))
|
|
if saveErr != nil {
|
|
writeStorageSaveError(w, saveErr, "avatar upload")
|
|
return
|
|
}
|
|
|
|
filename := sanitizeUploadFilename(header.Filename)
|
|
if err := database.CreateAttachment(r.Context(), fileID, user.ID, filename, fileID, mimeType, written, &width, &height); err != nil {
|
|
if delErr := store.Delete(fileID); delErr != nil {
|
|
slog.Error("failed to clean up orphaned avatar file", "stored_as", fileID, "error", delErr)
|
|
}
|
|
slog.Error("failed to create avatar attachment record", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
Error: "INTERNAL_ERROR", Message: "failed to save avatar",
|
|
})
|
|
return
|
|
}
|
|
|
|
avatarURL := service.AvatarFileURL(fileID)
|
|
// Username is deliberately omitted (left at its zero value): user
|
|
// here is a snapshot AuthMiddleware read at the start of the
|
|
// request, before the multipart parse / image decode / disk write
|
|
// above — all of which take long enough for a concurrent
|
|
// PATCH /users/me rename to land first. Sending that stale value
|
|
// would revert the rename; UpdateProfile treats an empty Username
|
|
// as "leave it alone", the same contract DisplayName/About already
|
|
// have via nil.
|
|
updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{
|
|
Avatar: &avatarURL,
|
|
})
|
|
if err != nil {
|
|
// The column never moved, so the file and its row are orphans.
|
|
if delErr := store.Delete(fileID); delErr != nil {
|
|
slog.Error("failed to clean up orphaned avatar file", "stored_as", fileID, "error", delErr)
|
|
}
|
|
writeServiceError(r.Context(), w, err)
|
|
return
|
|
}
|
|
|
|
// The previous avatar's bytes are deliberately left on disk: a message
|
|
// that was rendered with it may still be cached client-side, and a
|
|
// blind delete here would race any request already in flight for it.
|
|
// Reclaiming them is an operator-side sweep, not a request-path action.
|
|
broadcastUserUpdate(broadcaster, updated)
|
|
|
|
slog.Info("avatar uploaded", "user_id", user.ID, "id", fileID, "size", written, "mime", mimeType)
|
|
writeJSON(w, http.StatusCreated, uploadResponse{
|
|
ID: fileID,
|
|
Filename: filename,
|
|
Size: written,
|
|
Mime: mimeType,
|
|
URL: avatarURL,
|
|
Width: &width,
|
|
Height: &height,
|
|
})
|
|
}
|
|
}
|
|
|
|
// avatarUploadReadImage is the bytes stage of handleUploadAvatar: read the
|
|
// uploaded file under its cap, sniff its type and measure it. It writes its own
|
|
// 400 and reports ok=false when the upload is not an acceptable avatar, so the
|
|
// caller only has to return. Deliberately not shared with the emoji route: the
|
|
// two carry different caps and a different allowed MIME set.
|
|
func avatarUploadReadImage(w http.ResponseWriter, file io.Reader) (raw []byte, mimeType string, width, height int, ok bool) {
|
|
// Read one byte past the cap so "exactly at the limit" passes and "one
|
|
// byte over" is caught, without buffering an unbounded body.
|
|
raw, err := io.ReadAll(io.LimitReader(file, maxAvatarFileBytes+1))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST", Message: "failed to read uploaded file",
|
|
})
|
|
return nil, "", 0, 0, false
|
|
}
|
|
if int64(len(raw)) > maxAvatarFileBytes {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST",
|
|
Message: fmt.Sprintf("avatar must be at most %d KB", maxAvatarFileBytes>>10),
|
|
})
|
|
return nil, "", 0, 0, false
|
|
}
|
|
|
|
// Never trust the client's Content-Type — sniff the bytes.
|
|
mimeType = http.DetectContentType(raw)
|
|
if !allowedAvatarMIME[mimeType] {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST", Message: "avatar must be a PNG, JPEG or WebP image",
|
|
})
|
|
return nil, "", 0, 0, false
|
|
}
|
|
|
|
width, height, err = imageDimensions(raw, mimeType)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST", Message: "could not read image dimensions",
|
|
})
|
|
return nil, "", 0, 0, false
|
|
}
|
|
// Measured from the sniffed image, not from anything the client said.
|
|
// The client crops to a square before uploading; the server does not
|
|
// re-encode (that would mean decoding and re-compressing every upload
|
|
// to change nothing a CSS circle mask does not already do), it just
|
|
// refuses a picture too big to be an avatar.
|
|
if width <= 0 || height <= 0 || width > maxAvatarDimension || height > maxAvatarDimension {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "BAD_REQUEST",
|
|
Message: fmt.Sprintf("avatar must be at most %dx%d pixels (got %dx%d)", maxAvatarDimension, maxAvatarDimension, width, height),
|
|
})
|
|
return nil, "", 0, 0, false
|
|
}
|
|
|
|
return raw, mimeType, width, height, true
|
|
}
|