Add self-service account deletion
Settings → Delete account removes the signed-in account and everything it owns. DELETE /api/me re-checks the password (guarding an unattended session), then wipes the user's events, config, sessions and user row in one transaction and removes their photos/<user_id>/ directory. The client clears the account's local cache and returns to the login screen. Bumps the service-worker cache so clients pick up the new UI. Verified: wrong password is rejected (401, data intact); correct password returns 204, invalidates the session, drops all rows to zero and removes the photo dir; the email can be re-registered afterwards. Confirmed end to end in a headless-browser run of the Settings → delete flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -381,3 +381,62 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeUser(w, u)
|
||||
}
|
||||
|
||||
// checkPassword reports whether password matches the stored hash for userID.
|
||||
func (a *Auth) checkPassword(userID, password string) bool {
|
||||
var hash string
|
||||
if err := a.db.QueryRow(`SELECT password FROM users WHERE id = ?`, userID).Scan(&hash); err != nil {
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// deleteAccount removes a user and everything owned by them: events, profile,
|
||||
// sessions, the user row, and their photo directory. The table wipes run in one
|
||||
// transaction; photos are best-effort afterwards (orphaned files are harmless).
|
||||
func (a *Auth) deleteAccount(userID string) error {
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, q := range []string{
|
||||
`DELETE FROM events WHERE user_id = ?`,
|
||||
`DELETE FROM config WHERE user_id = ?`,
|
||||
`DELETE FROM sessions WHERE user_id = ?`,
|
||||
`DELETE FROM users WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(q, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(a.photosDir, userID)); err != nil {
|
||||
log.Printf("delete photos for %s: %v", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDeleteAccount deletes the caller's own account after re-checking their
|
||||
// password (guards against an unattended session). Wrapped in requireUser.
|
||||
func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var c credentials
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
uid := userID(r)
|
||||
if !a.checkPassword(uid, c.Password) {
|
||||
http.Error(w, "invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := a.deleteAccount(uid); err != nil {
|
||||
log.Printf("delete account %s: %v", uid, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.clearCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user