feat: change email, export my data, delete account (with data anonymization)

This commit is contained in:
Florian Sylvain committed 2026-10-30 20:19:07 +01:00
1 parent bb191c389a
commit 10a66cd979
123 files changed
+10789 -76

No files matched your search

@@ -94,6 +94,7 @@ type Repository interface {
Get(id uint32) (Article, error)
GetByName(name string) (Article, error)
GetAll() ([]Article, error)
ListForAccount(creatorID uint32, authorID *uint32) ([]Article, error)
Create(post Article) (Article, error)
UpdateBody(id uint32, body string) (Article, error)
Update(id uint32, update Update) (Article, error)
+7
View File
@@ -106,6 +106,12 @@ const (
ActionUserChangeRole = "user.change_role"
ActionUserTransferOwnership = "user.transfer_ownership"
ActionUserRemove = "user.remove"
ActionUserRequestEmail = "user.request_email_change"
ActionUserResendEmail = "user.resend_email_change"
ActionUserCancelEmail = "user.cancel_email_change"
ActionUserConfirmEmail = "user.confirm_email_change"
ActionUserExportData = "user.export_data"
ActionUserDeleteAccount = "user.delete_account"
ActionInviteCreate = "invite.create"
ActionInviteCancel = "invite.cancel"
ActionInviteRenew = "invite.renew"
@@ -132,6 +138,7 @@ const (
const (
SystemActorName = "System"
ReasonWrongLogin = "Wrong username or password."
ReasonWrongPassword = "Wrong password."
MaxUserAgentLength = 255
ParamRemoved = "removed"
ParamFrom = "from"
+19
View File
@@ -0,0 +1,19 @@
package audit
import "strconv"
func (e Event) ConcernsOtherPerson(userID uint32, ownAuthorID *uint32) bool {
switch e.Target.Type {
case TargetUser:
return e.Target.ID != formatID(userID)
case TargetInvite:
return true
case TargetAuthor:
return ownAuthorID == nil || e.Target.ID != formatID(*ownAuthorID)
}
return false
}
func formatID(id uint32) string {
return strconv.FormatUint(uint64(id), 10)
}
@@ -9,6 +9,7 @@ type Repository interface {
DeleteOlderThan(cutoff time.Time) (int, error)
DeleteOldest(limit int, before time.Time) (int, error)
SizeBytes() (int64, error)
AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error
}
type SettingsRepository interface {
+29
View File
@@ -0,0 +1,29 @@
package author
import "strconv"
const DeletedName = "Deleted user"
const DeletedSlugPrefix = "deleted-user-"
func DeletedSlug(id uint32) string {
return DeletedSlugPrefix + strconv.FormatUint(uint64(id), 10)
}
func DeletedSlugAttempt(id uint32, attempt int) string {
if attempt <= 1 {
return DeletedSlug(id)
}
return DeletedSlug(id) + "-" + strconv.Itoa(attempt)
}
func IsReservedSlug(candidate string) bool {
return len(candidate) >= len(DeletedSlugPrefix) && candidate[:len(DeletedSlugPrefix)] == DeletedSlugPrefix
}
func avoidReserved(candidate string) string {
if IsReservedSlug(candidate) {
return FallbackSlug + "-" + candidate
}
return candidate
}
+3 -3
View File
@@ -112,7 +112,7 @@ type Page struct {
}
func IsValidSlug(candidate string) bool {
return slug.IsValid(candidate)
return slug.IsValid(candidate) && !IsReservedSlug(candidate)
}
func IsValidName(name string) bool {
@@ -172,9 +172,9 @@ func ValidateLinks(links []SocialLink) error {
}
func GenerateSlug(name string) string {
return slug.Generate(name, FallbackSlug)
return avoidReserved(slug.Generate(name, FallbackSlug))
}
func SlugWithSuffix(base string, attempt int) string {
return slug.WithSuffix(base, attempt)
return avoidReserved(slug.WithSuffix(base, attempt))
}
@@ -64,6 +64,7 @@ type Repository interface {
Create(entry Entry) (Entry, error)
Get(contentTypeID uint32, id uint32) (Entry, error)
ListPage(filters Filters) (PageResult, error)
ListByCreator(creatorID uint32) ([]Entry, error)
Update(entry Entry) (Entry, error)
Delete(contentTypeID uint32, id uint32) error
DeleteByContentType(contentTypeID uint32) error
@@ -10,6 +10,7 @@ type Repository interface {
Get(id uint32) (Image, error)
Delete(id uint32) error
List(query SearchQuery) ([]Image, error)
ListByUploader(userID uint32) ([]Image, error)
FindByHash(hash string) ([]Image, error)
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
@@ -0,0 +1,5 @@
package mail
type Dispatcher interface {
Dispatch(job func())
}
+10
View File
@@ -22,9 +22,16 @@ var ErrPasswordTooLong = errors.New("password too long")
var ErrRoleChanged = errors.New("user role changed concurrently")
var ErrInvalidTransfer = errors.New("invalid ownership transfer")
var ErrConfirmationMismatch = errors.New("confirmation email does not match")
var ErrWrongPassword = errors.New("wrong password")
var ErrSameEmail = errors.New("new email is the current email")
var ErrNoPendingEmailChange = errors.New("no pending email change")
var ErrInvalidEmailChangeCode = errors.New("invalid email change code")
var ErrEmailUnavailable = errors.New("email unavailable")
const MaxLookupIDs = 100
const EmailChangeLifetime = 2 * time.Hour
const MaxPasswordBytes = 72
func ValidatePassword(password string) error {
@@ -47,6 +54,9 @@ type User struct {
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
PendingEmail string `json:"pending_email"`
EmailChangeCode string `json:"email_change_code"`
EmailChangeExpiration time.Time `json:"email_change_expiration"`
TokenVersion uint32 `json:"token_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+42
View File
@@ -0,0 +1,42 @@
package user
import "errors"
const DeletedUserName = "Deleted user"
const (
LastRoleOwner = "Owner"
LastRoleAdmin = "Admin"
)
var ErrLastOwner = errors.New("last owner")
var ErrLastAdmin = errors.New("last administrator")
var ErrAccountNotDeletable = errors.New("account not deletable")
type LastAdminError struct {
Role string
}
func (e LastAdminError) Error() string {
if e.Role == LastRoleOwner {
return ErrLastOwner.Error()
}
return ErrLastAdmin.Error()
}
func (e LastAdminError) Is(target error) bool {
if target == ErrLastAdmin {
return true
}
return target == ErrLastOwner && e.Role == LastRoleOwner
}
type Erasure struct {
UserID uint32
GuardRoleIDs []uint32
AvatarImageID *uint32
}
type Eraser interface {
Erase(erasure Erasure) error
}
@@ -17,6 +17,10 @@ type Repository interface {
ClearVerificationCode(userId uint32) (User, error)
ResetPassword(userId uint32, hashedPassword string) (User, error)
UpdateDisplayName(userId uint32, displayName string) (User, error)
IsEmailInUse(email string, exceptUserId uint32) (bool, error)
SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (User, error)
ClearPendingEmailChange(userId uint32) (User, error)
ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (User, error)
UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (User, error)
TransferOwnership(from uint32, to uint32, ownerRoleID uint32, formerRoleID uint32) error
}