diff --git a/backend/gochat.db b/backend/gochat.db deleted file mode 100644 index 80b0c45..0000000 Binary files a/backend/gochat.db and /dev/null differ diff --git a/backend/internal/rest/handler_auth.go b/backend/internal/rest/handler_auth.go index d525906..65534b9 100644 --- a/backend/internal/rest/handler_auth.go +++ b/backend/internal/rest/handler_auth.go @@ -50,7 +50,7 @@ func (h *Handler) signup(w http.ResponseWriter, r *http.Request) { auth.SigninSetup(w, h.cfg.Cookies.SessionName, session.Token, session.ExpiresAt, h.cfg.Cookies.Secure) h.refreshCsrf(w) - writeJson(w, http.StatusCreated, newUserResponse(u)) + h.respondUser(w, r, u, http.StatusCreated) } func (h *Handler) signin(w http.ResponseWriter, r *http.Request) { @@ -79,7 +79,7 @@ func (h *Handler) signin(w http.ResponseWriter, r *http.Request) { auth.SigninSetup(w, h.cfg.Cookies.SessionName, session.Token, session.ExpiresAt, h.cfg.Cookies.Secure) h.refreshCsrf(w) - writeJson(w, http.StatusOK, newUserResponse(u)) + h.respondUser(w, r, u, http.StatusOK) } func (h *Handler) signout(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/rest/handler_profile.go b/backend/internal/rest/handler_profile.go index 46c26c1..bb5f58c 100644 --- a/backend/internal/rest/handler_profile.go +++ b/backend/internal/rest/handler_profile.go @@ -20,7 +20,7 @@ type updatePasswordRequest struct { func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) { u := userFromRequest(r) - writeJson(w, http.StatusOK, newUserResponse(u)) + h.respondUser(w, r, u, http.StatusOK) } func (h *Handler) updateProfile(w http.ResponseWriter, r *http.Request) { @@ -58,7 +58,7 @@ func (h *Handler) updateProfile(w http.ResponseWriter, r *http.Request) { return } - writeJson(w, http.StatusOK, newUserResponse(updated)) + h.respondUser(w, r, updated, http.StatusOK) } func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) { @@ -109,6 +109,19 @@ func (h *Handler) uploadAvatar(w http.ResponseWriter, r *http.Request) { writeNoContent(w) } +// deleteAvatar removes the caller's avatar. It is idempotent: deleting an +// avatar that does not exist still succeeds. +func (h *Handler) deleteAvatar(w http.ResponseWriter, r *http.Request) { + u := userFromRequest(r) + + if err := h.users.DeleteAvatar(r.Context(), u); err != nil { + h.fail(w, err) + return + } + + writeNoContent(w) +} + func (h *Handler) getAvatar(w http.ResponseWriter, r *http.Request) { u := userFromRequest(r) diff --git a/backend/internal/rest/handler_users.go b/backend/internal/rest/handler_users.go index 049e29c..34015b6 100644 --- a/backend/internal/rest/handler_users.go +++ b/backend/internal/rest/handler_users.go @@ -20,6 +20,7 @@ type userResponse struct { FullName *string `json:"fullName"` Email *string `json:"email"` IsAdmin bool `json:"isAdmin"` + HasAvatar bool `json:"hasAvatar"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` ActivatedAt *time.Time `json:"activatedAt"` @@ -38,7 +39,12 @@ func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { result := make([]userResponse, 0, len(users)) for _, u := range users { - result = append(result, newUserResponse(u)) + hasAvatar, err := h.users.HasAvatar(r.Context(), u) + if err != nil { + h.fail(w, err) + return + } + result = append(result, newUserResponse(u, hasAvatar)) } writeJson(w, http.StatusOK, result) @@ -131,7 +137,18 @@ func (h *Handler) getUserAvatar(w http.ResponseWriter, r *http.Request) { // avatarURL builds the absolute URL of a user's avatar, preferring the // configured PublicBaseURL and falling back to the request's scheme and Host. +// It returns an empty string when the user has no avatar. func (h *Handler) avatarURL(r *http.Request, id int64) string { + user, err := h.users.UserById(r.Context(), id) + if err != nil { + return "" + } + + hasAvatar, err := h.users.HasAvatar(r.Context(), user) + if err != nil || !hasAvatar { + return "" + } + base := h.cfg.PublicBaseURL if base == "" { scheme := "http" @@ -146,13 +163,25 @@ func (h *Handler) avatarURL(r *http.Request, id int64) string { return fmt.Sprintf("%s/api/users/%d/avatar", base, id) } -func newUserResponse(u *user.User) userResponse { +// respondUser writes a user response that includes whether the user has an +// avatar stored. +func (h *Handler) respondUser(w http.ResponseWriter, r *http.Request, u *user.User, status int) { + hasAvatar, err := h.users.HasAvatar(r.Context(), u) + if err != nil { + h.fail(w, err) + return + } + writeJson(w, status, newUserResponse(u, hasAvatar)) +} + +func newUserResponse(u *user.User, hasAvatar bool) userResponse { return userResponse{ Id: u.Id, Login: u.Login, FullName: u.FullName, Email: u.Email, IsAdmin: u.IsAdmin, + HasAvatar: hasAvatar, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, ActivatedAt: u.ActivatedAt, diff --git a/backend/internal/rest/routes.go b/backend/internal/rest/routes.go index fe3597e..fb5500a 100644 --- a/backend/internal/rest/routes.go +++ b/backend/internal/rest/routes.go @@ -27,6 +27,7 @@ func (h *Handler) registerRoutes(mux *http.ServeMux) { mux.Handle("PUT /api/profile/password", userChain.ThenFunc(h.updatePassword)) mux.Handle("POST /api/profile/avatar", userChain.ThenFunc(h.uploadAvatar)) mux.Handle("PUT /api/profile/avatar", userChain.ThenFunc(h.uploadAvatar)) + mux.Handle("DELETE /api/profile/avatar", userChain.ThenFunc(h.deleteAvatar)) mux.Handle("GET /api/profile/avatar", userChain.ThenFunc(h.getAvatar)) mux.Handle("GET /api/sessions", userChain.ThenFunc(h.listSessions)) diff --git a/backend/internal/user/repository.go b/backend/internal/user/repository.go index 162c7c4..75c21b7 100644 --- a/backend/internal/user/repository.go +++ b/backend/internal/user/repository.go @@ -179,6 +179,28 @@ func (r *Repository) SetAvatar(ctx context.Context, id int64, data []byte) error return nil } +func (r *Repository) DeleteAvatar(ctx context.Context, id int64) error { + query := "DELETE FROM avatars WHERE id = ?" + _, err := r.q.ExecContext(ctx, query, id) + if err != nil { + slog.Error("delete avatar", slog.String("op", "repo.DeleteAvatar"), slog.Int64("user_id", id), slog.Any("error", err)) + return ErrInternal + } + return nil +} + +// HasAvatar reports whether the user currently has an avatar stored. +func (r *Repository) HasAvatar(ctx context.Context, id int64) (bool, error) { + query := "SELECT EXISTS(SELECT 1 FROM avatars WHERE id = ?)" + var exists bool + err := r.q.QueryRowContext(ctx, query, id).Scan(&exists) + if err != nil { + slog.Error("has avatar", slog.String("op", "repo.HasAvatar"), slog.Int64("user_id", id), slog.Any("error", err)) + return false, ErrInternal + } + return exists, nil +} + func (r *Repository) CleanupUsers(ctx context.Context) error { query := "DELETE FROM users WHERE activated_at IS NULL and created_at < ?" _, err := r.q.ExecContext(ctx, query, time.Now().UTC().Add(-5*time.Minute)) diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go index d1bbc5f..0cf3499 100644 --- a/backend/internal/user/service.go +++ b/backend/internal/user/service.go @@ -163,6 +163,17 @@ func (s *Service) Avatar(ctx context.Context, user *User) ([]byte, error) { return s.r.Avatar(ctx, user.Id) } +// DeleteAvatar removes a user's avatar. It is a no-op when no avatar is stored, +// so the operation is idempotent. +func (s *Service) DeleteAvatar(ctx context.Context, user *User) error { + return s.r.DeleteAvatar(ctx, user.Id) +} + +// HasAvatar reports whether the user currently has an avatar stored. +func (s *Service) HasAvatar(ctx context.Context, user *User) (bool, error) { + return s.r.HasAvatar(ctx, user.Id) +} + func (s *Service) ActivateUser(ctx context.Context, user *User) (*User, error) { tx, err := s.r.Db.BeginTx(ctx, nil) if err != nil { diff --git a/frontend/src/api.js b/frontend/src/api.js index 0da52ae..08552ef 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -100,6 +100,7 @@ export const api = { form.append('avatar', file) return request('PUT', '/api/profile/avatar', form) }, + deleteAvatar: () => request('DELETE', '/api/profile/avatar'), avatarUrl: () => '/api/profile/avatar', // sessions diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index aacb8d5..0ac4c54 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -15,6 +15,7 @@ export default function ProfilePage({ user, onUserChange }) { const fileRef = useRef(null) const initials = (user.fullName || user.login || '?').trim().charAt(0).toUpperCase() + const hasAvatar = Boolean(user.hasAvatar) && !avatarError const saveProfile = async (event) => { event.preventDefault() @@ -46,6 +47,8 @@ export default function ProfilePage({ user, onUserChange }) { setAvatarMsg(null) try { await api.uploadAvatar(file) + const profile = await api.getProfile() + onUserChange(profile) setAvatarVersion(Date.now()) setAvatarError(false) setAvatarMsg({ type: 'ok', text: 'Avatar updated' }) @@ -56,22 +59,36 @@ export default function ProfilePage({ user, onUserChange }) { } } + const removeAvatar = async () => { + if (!confirm('Remove your avatar?')) return + setAvatarMsg(null) + try { + await api.deleteAvatar() + const profile = await api.getProfile() + onUserChange(profile) + setAvatarError(false) + setAvatarMsg({ type: 'ok', text: 'Avatar removed' }) + } catch (err) { + setAvatarMsg({ type: 'error', text: err.message }) + } + } + return (
PNG or JPEG.
+ {user.hasAvatar && ( + + )}{avatarMsg.text}
}