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
}
@@ -0,0 +1,25 @@
package mailer
import (
domain "RenewCMS/internal/domain/mail"
"log"
)
type AsyncDispatcher struct{}
func NewAsyncDispatcher() *AsyncDispatcher {
return &AsyncDispatcher{}
}
func (a *AsyncDispatcher) Dispatch(job func()) {
go func() {
defer func() {
if recovered := recover(); recovered != nil {
log.Println("panic in a background mail job:", recovered)
}
}()
job()
}()
}
var _ domain.Dispatcher = &AsyncDispatcher{}
@@ -62,6 +62,12 @@ func subjectFor(templateName string, data any) string {
return "Verify your RenewCMS account"
case "password-reset":
return "Reset your RenewCMS password"
case "verify-new-email":
return "Confirm your new RenewCMS email address"
case "email-change-notice":
return "Your RenewCMS email address change was requested"
case "account-deleted":
return "Your RenewCMS account has been deleted"
}
return defaultSubject
}
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Your account has been deleted</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Your RenewCMS account was deleted and your personal data removed.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your account has been deleted</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Your RenewCMS account at {{.Host}} has been deleted.</p>
<p class="rc-text rc-rule" style="margin:0;padding-top:0;border-top:0;font-size:14px;line-height:22px;color:#222222;">If you didn't ask for this, contact an administrator of {{.Host}}.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Your email address change was requested</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">A change of email address was requested for your RenewCMS account. Nothing has changed yet.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Your email address change was requested</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">A request was made to change the email address of the RenewCMS account {{.Username}} at {{.Host}} from this address to {{.NewEmail}}. Nothing has changed yet: you keep logging in with this address until the new one is confirmed.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/profile" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Review my profile</a>
</td>
</tr>
</table>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this was you, no action is needed. If it wasn't, log in, cancel the change from your profile and change your password.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Confirm your new email address</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Confirm this address to finish changing your RenewCMS email. The link expires in 2 hours.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Confirm your new email address</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone, hopefully you, asked to change the email address of the RenewCMS account {{.Username}} at {{.Host}} to this address. Confirm it to finish the change.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/account/email/confirm?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Confirm new email address</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 2 hours.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/account/email/confirm?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -47,6 +47,9 @@ func UserToDomain(m models.User) user.User {
VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration,
PasswordResetExpiration: m.PasswordResetExpiration,
PendingEmail: nullableStringToDomain(m.PendingEmail),
EmailChangeCode: nullableStringToDomain(m.EmailChangeCode),
EmailChangeExpiration: m.EmailChangeExpiration,
TokenVersion: m.TokenVersion,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
@@ -66,6 +69,9 @@ func UserToModel(a user.User) models.User {
VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration,
PendingEmail: DomainToNullableString(a.PendingEmail),
EmailChangeCode: DomainToNullableString(a.EmailChangeCode),
EmailChangeExpiration: a.EmailChangeExpiration,
TokenVersion: a.TokenVersion,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
@@ -10,8 +10,8 @@ type AuditEvent struct {
ActorType string `gorm:"not null;size:16;index:idx_audit_events_actor,priority:1"`
ActorID *uint32 `gorm:"index:idx_audit_events_actor,priority:2"`
ActorName string `gorm:"not null;size:255"`
TargetType string `gorm:"not null;size:32"`
TargetID string `gorm:"not null;size:64"`
TargetType string `gorm:"not null;size:32;index:idx_audit_events_target,priority:1"`
TargetID string `gorm:"not null;size:64;index:idx_audit_events_target,priority:2"`
TargetName string `gorm:"not null;size:255"`
Outcome string `gorm:"not null;size:16"`
FailureReason *string `gorm:"size:255"`
@@ -17,6 +17,9 @@ type User struct {
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
PendingEmail *string `gorm:"size:255"`
EmailChangeCode *string `gorm:"size:255"`
EmailChangeExpiration time.Time
TokenVersion uint32 `gorm:"default:0;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -0,0 +1,145 @@
package persistence
import (
"RenewCMS/internal/domain/author"
domain "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
emptySocialLinks = "[]"
sqliteDialect = "sqlite"
)
var contentionMarkers = []string{
"database is locked",
"sqlite_busy",
"deadlock",
"could not serialize",
}
type AccountEraser struct {
db *gorm.DB
}
var _ domain.Eraser = &AccountEraser{}
func NewAccountEraser(db *gorm.DB) *AccountEraser {
return &AccountEraser{db}
}
func (e *AccountEraser) Erase(erasure domain.Erasure) error {
err := e.db.Transaction(func(tx *gorm.DB) error {
if err := e.recheckHolders(tx, erasure); err != nil {
return err
}
if err := tx.Where("user_id = ?", erasure.UserID).Delete(&entity.APIKey{}).Error; err != nil {
return err
}
ownAuthorID, err := e.releaseAuthor(tx, erasure.UserID)
if err != nil {
return err
}
if erasure.AvatarImageID != nil {
if err := tx.Delete(&entity.Image{}, *erasure.AvatarImageID).Error; err != nil {
return err
}
}
if err := NewAuditEventRepository(tx).AnonymiseUser(erasure.UserID, ownAuthorID, domain.DeletedUserName); err != nil {
return err
}
result := tx.Delete(&entity.User{}, erasure.UserID)
return deleteOrNotFound(result, domain.ErrUserNotFound)
})
if err != nil && len(erasure.GuardRoleIDs) > 0 && isContention(err) {
return domain.ErrAccountNotDeletable
}
return err
}
func isContention(err error) bool {
message := strings.ToLower(err.Error())
for _, marker := range contentionMarkers {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func (e *AccountEraser) recheckHolders(tx *gorm.DB, erasure domain.Erasure) error {
if len(erasure.GuardRoleIDs) == 0 {
return nil
}
if err := lockRoles(tx, erasure.GuardRoleIDs); err != nil {
return err
}
var holders []entity.User
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").
Where("role_id IN ? AND is_verified = ? AND id <> ?", erasure.GuardRoleIDs, true, erasure.UserID).
Order("id ASC").
Find(&holders).Error
if err != nil {
return err
}
if len(holders) == 0 {
return domain.ErrAccountNotDeletable
}
return nil
}
func lockRoles(tx *gorm.DB, roleIDs []uint32) error {
if tx.Dialector.Name() == sqliteDialect {
return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("id", gorm.Expr("id")).Error
}
var locked []entity.Role
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", roleIDs).Order("id ASC").Find(&locked).Error
}
func (e *AccountEraser) releaseAuthor(tx *gorm.DB, userID uint32) (*uint32, error) {
var linked entity.Author
found := tx.Where("user_id = ?", userID).Limit(1).Find(&linked)
if found.Error != nil {
return nil, found.Error
}
if found.RowsAffected == 0 {
return nil, nil
}
ownID := linked.ID
var credits int64
if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", linked.ID).Count(&credits).Error; err != nil {
return nil, err
}
if credits == 0 {
return &ownID, tx.Delete(&entity.Author{}, linked.ID).Error
}
slug, err := e.freeDeletedSlug(tx, linked.ID)
if err != nil {
return nil, err
}
return &ownID, tx.Model(&entity.Author{}).Where("id = ?", linked.ID).Updates(map[string]any{
"name": author.DeletedName,
"slug": slug,
"bio": "",
"avatar_id": nil,
"website": nil,
"social_links": emptySocialLinks,
"user_id": nil,
}).Error
}
func (e *AccountEraser) freeDeletedSlug(tx *gorm.DB, authorID uint32) (string, error) {
for attempt := 1; ; attempt++ {
candidate := author.DeletedSlugAttempt(authorID, attempt)
var taken int64
err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", candidate, authorID).Count(&taken).Error
if err != nil || taken == 0 {
return candidate, err
}
}
}
@@ -583,3 +583,18 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
}
var _ domain.Repository = &ArticleRepository{}
func (a *ArticleRepository) ListForAccount(creatorID uint32, authorID *uint32) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{})
if authorID == nil {
query = query.Where("created_by = ?", creatorID)
} else {
credited := a.db.Model(&entity.ArticleAuthor{}).Select("article_id").Where("author_id = ?", *authorID)
query = query.Where("created_by = ? OR (is_online = ? AND id IN (?))", creatorID, true, credited)
}
var entityArticles []entity.Article
if err := query.Order("id asc").Find(&entityArticles).Error; err != nil {
return nil, err
}
return a.withAuthors(a.db, toDomainArticles(entityArticles))
}
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"strconv"
"strings"
"time"
@@ -233,3 +234,38 @@ func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, e
}
return removed, nil
}
const (
erasedParams = "{}"
erasedChanges = "[]"
authorActions = "author.%"
)
func (a *AuditEventRepository) AnonymiseUser(userID uint32, ownAuthorID *uint32, name string) error {
userTargetID := strconv.FormatUint(uint64(userID), 10)
involved := "(actor_type = ? AND actor_id = ?) OR (target_type = ? AND target_id = ?)"
asActor := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND actor_id = ?", string(audit.ActorUser), userID).
Updates(map[string]any{"actor_name": name, "ip": nil, "user_agent": nil})
if asActor.Error != nil {
return asActor.Error
}
asTarget := a.db.Model(&entity.AuditEvent{}).
Where("target_type = ? AND target_id = ?", audit.TargetUser, userTargetID).
Update("target_name", name)
if asTarget.Error != nil {
return asTarget.Error
}
if ownAuthorID != nil {
ownProfile := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND actor_id = ? AND action LIKE ? AND target_type = ? AND target_id = ?",
string(audit.ActorUser), userID, authorActions, audit.TargetAuthor, strconv.FormatUint(uint64(*ownAuthorID), 10)).
Update("target_name", name)
if ownProfile.Error != nil {
return ownProfile.Error
}
}
return a.db.Model(&entity.AuditEvent{}).
Where(involved, string(audit.ActorUser), userID, audit.TargetUser, userTargetID).
Updates(map[string]any{"params": erasedParams, "changes": erasedChanges}).Error
}
@@ -154,3 +154,11 @@ func entriesToDomain(models []entity.Entry) ([]domain.Entry, error) {
}
return items, nil
}
func (e *EntryRepository) ListByCreator(creatorID uint32) ([]domain.Entry, error) {
var models []entity.Entry
if err := e.db.Where("created_by = ?", creatorID).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return entriesToDomain(models)
}
@@ -160,3 +160,11 @@ func (i ImageRepository) UpdateMetadata(id uint32, metadata domain.Metadata) err
}
var _ domain.Repository = &ImageRepository{}
func (i ImageRepository) ListByUploader(userID uint32) ([]domain.Image, error) {
var models []entity.Image
if err := i.db.Where("uploaded_by_id = ?", userID).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
@@ -242,6 +242,80 @@ func (u *UserRepository) UpdateDisplayName(userId uint32, displayName string) (d
return mappers.UserToDomain(localUser), nil
}
func (u *UserRepository) IsEmailInUse(email string, exceptUserId uint32) (bool, error) {
return isEmailInUse(u.db, email, exceptUserId)
}
func isEmailInUse(db *gorm.DB, email string, exceptUserId uint32) (bool, error) {
var holders int64
err := db.Model(&entity.User{}).Where("LOWER(email) = LOWER(?) AND id <> ?", email, exceptUserId).Count(&holders).Error
return holders > 0, err
}
func (u *UserRepository) SetPendingEmailChange(userId uint32, pendingEmail string, code string, expiration time.Time) (domainUser.User, error) {
return u.updatePendingEmailChange(userId, map[string]any{
"pending_email": mappers.DomainToNullableString(pendingEmail),
"email_change_code": mappers.DomainToNullableString(code),
"email_change_expiration": expiration,
})
}
func (u *UserRepository) ClearPendingEmailChange(userId uint32) (domainUser.User, error) {
return u.updatePendingEmailChange(userId, clearedEmailChange())
}
func clearedEmailChange() map[string]any {
return map[string]any{
"pending_email": nil,
"email_change_code": nil,
"email_change_expiration": time.Time{},
}
}
func (u *UserRepository) updatePendingEmailChange(userId uint32, columns map[string]any) (domainUser.User, error) {
result := u.db.Model(&entity.User{}).Where("id = ?", userId).Updates(columns)
if result.Error != nil {
return domainUser.User{}, result.Error
}
return u.Get(userId)
}
func (u *UserRepository) ConfirmEmailChange(userId uint32, pendingEmail string, code string, now time.Time) (domainUser.User, error) {
taken, err := isEmailInUse(u.db, pendingEmail, userId)
if err != nil {
return domainUser.User{}, err
}
columns := clearedEmailChange()
if !taken {
columns["email"] = pendingEmail
}
result := pendingChangeOf(u.db, userId, pendingEmail, code).Where("email_change_expiration > ?", now).Updates(columns)
if result.Error != nil && isUniqueViolation(result.Error) {
return domainUser.User{}, u.refuseTakenAddress(userId, pendingEmail, code)
}
if result.Error != nil {
return domainUser.User{}, result.Error
}
if result.RowsAffected == 0 {
return domainUser.User{}, domainUser.ErrInvalidEmailChangeCode
}
if taken {
return domainUser.User{}, domainUser.ErrEmailUnavailable
}
return u.Get(userId)
}
func (u *UserRepository) refuseTakenAddress(userId uint32, pendingEmail string, code string) error {
if err := pendingChangeOf(u.db, userId, pendingEmail, code).Updates(clearedEmailChange()).Error; err != nil {
return err
}
return domainUser.ErrEmailUnavailable
}
func pendingChangeOf(db *gorm.DB, userId uint32, pendingEmail string, code string) *gorm.DB {
return db.Model(&entity.User{}).Where("id = ? AND pending_email = ? AND email_change_code = ?", userId, pendingEmail, code)
}
func (u *UserRepository) UpdateRole(id uint32, from uint32, to uint32, assignedBy uint32) (domainUser.User, error) {
err := u.db.Transaction(func(tx *gorm.DB) error {
query := tx.Model(&entity.User{}).Where("id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", id, to)
@@ -0,0 +1,34 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"context"
)
type CancelEmailChangeUseCase struct {
userRepository user.Repository
recorder audit.Recorder
}
func NewCancelEmailChangeUseCase(userRepository user.Repository, recorder audit.Recorder) *CancelEmailChangeUseCase {
return &CancelEmailChangeUseCase{userRepository, recorder}
}
func (u *CancelEmailChangeUseCase) CancelEmailChange(ctx context.Context, userId uint32) error {
account, err := u.userRepository.Get(userId)
if err != nil {
return err
}
if account.PendingEmail == "" {
return nil
}
event := audit.NewEvent(audit.ActionUserCancelEmail, audit.CategoryUsers)
event.Target = userTarget(account)
if _, err := u.userRepository.ClearPendingEmailChange(userId); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
@@ -0,0 +1,52 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type ConfirmEmailChangeUseCase struct {
userRepository user.Repository
hasher hash.Hasher
now func() time.Time
recorder audit.Recorder
}
func NewConfirmEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, now func() time.Time, recorder audit.Recorder) *ConfirmEmailChangeUseCase {
return &ConfirmEmailChangeUseCase{userRepository, hasher, now, recorder}
}
func (u *ConfirmEmailChangeUseCase) ConfirmEmailChange(ctx context.Context, userId uint32, code string) (string, error) {
account, err := u.userRepository.Get(userId)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserConfirmEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if !u.accepts(account, code) {
recordFailure(ctx, u.recorder, event, user.ErrInvalidEmailChangeCode)
return "", user.ErrInvalidEmailChangeCode
}
updated, err := u.userRepository.ConfirmEmailChange(userId, account.PendingEmail, account.EmailChangeCode, u.now())
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.recorder.Record(ctx, event)
return updated.Email, nil
}
func (u *ConfirmEmailChangeUseCase) accepts(account user.User, code string) bool {
if account.PendingEmail == "" || account.EmailChangeCode == "" {
return false
}
if u.now().After(account.EmailChangeExpiration) {
return false
}
return u.hasher.Compare(account.EmailChangeCode, code) == nil
}
@@ -0,0 +1,199 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
"log"
"slices"
"time"
)
const accountDeletedTemplateName = "account-deleted"
type MailAccountDeletedData struct {
Host string
}
type sessionRevoker interface {
Revoke(id string, expiresAt time.Time) error
}
type DeleteOwnAccountUseCase struct {
users user.Repository
roles role.Repository
hasher hash.Hasher
eraser user.Eraser
sessions sessionRevoker
mailRepository mail.Repository
dispatcher mail.Dispatcher
publicURL string
recorder audit.Recorder
authors author.Repository
images image.Repository
usage *GetMediaUsageUseCase
avatars mediaRemover
}
type DeleteOwnAccountCommand struct {
UserID uint32
CurrentPassword string
SessionID string
SessionExpiresAt time.Time
}
func NewDeleteOwnAccountUseCase(users user.Repository, roles role.Repository, hasher hash.Hasher, eraser user.Eraser, sessions sessionRevoker, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, recorder audit.Recorder, authors author.Repository, images image.Repository, usage *GetMediaUsageUseCase, storage image.Storage, variantFiles image.VariantFileRepository) *DeleteOwnAccountUseCase {
return &DeleteOwnAccountUseCase{users, roles, hasher, eraser, sessions, mailRepository, dispatcher, publicURL, recorder, authors, images, usage, newMediaRemover(images, storage, variantFiles)}
}
func (u *DeleteOwnAccountUseCase) Delete(ctx context.Context, command DeleteOwnAccountCommand) error {
account, err := u.users.Get(command.UserID)
if err != nil {
return err
}
event := audit.NewEvent(audit.ActionUserDeleteAccount, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
guard, err := u.check(account, command)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
avatar, err := u.removableAvatar(account)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
erasure := user.Erasure{UserID: account.ID, GuardRoleIDs: guard}
if avatar != nil {
erasure.AvatarImageID = &avatar.ID
}
if err := u.eraser.Erase(erasure); err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
u.discardAvatar(avatar)
u.revoke(command)
u.notify(account)
u.recordSuccess(ctx, account)
return nil
}
func (u *DeleteOwnAccountUseCase) check(account user.User, command DeleteOwnAccountCommand) ([]uint32, error) {
if u.hasher.Compare(account.Password, command.CurrentPassword) != nil {
return nil, user.ErrWrongPassword
}
guard, label, err := u.guardedRoles(account)
if err != nil || len(guard) == 0 {
return nil, err
}
hasOther, err := u.hasOtherHolder(account.ID, guard)
if err != nil {
return nil, err
}
if !hasOther {
return nil, user.LastAdminError{Role: label}
}
return guard, nil
}
func (u *DeleteOwnAccountUseCase) guardedRoles(account user.User) ([]uint32, string, error) {
owner, err := builtInRole(u.roles, role.OwnerName)
if err != nil {
return nil, "", err
}
if account.RoleID == owner.ID {
return []uint32{owner.ID}, user.LastRoleOwner, nil
}
admin, err := builtInRole(u.roles, role.AdminName)
if err != nil {
return nil, "", err
}
if account.RoleID == admin.ID {
return []uint32{owner.ID, admin.ID}, user.LastRoleAdmin, nil
}
return nil, "", nil
}
func (u *DeleteOwnAccountUseCase) hasOtherHolder(exceptUserID uint32, roleIDs []uint32) (bool, error) {
everyone, err := u.users.GetAll()
if err != nil {
return false, err
}
for _, candidate := range everyone {
if candidate.ID != exceptUserID && candidate.IsVerified && slices.Contains(roleIDs, candidate.RoleID) {
return true, nil
}
}
return false, nil
}
func (u *DeleteOwnAccountUseCase) removableAvatar(account user.User) (*image.Image, error) {
own, err := u.authors.GetByUserID(account.ID)
if errors.Is(err, author.ErrAuthorNotFound) || err == nil && own.AvatarID == nil {
return nil, nil
}
if err != nil {
return nil, err
}
avatar, err := u.images.Get(*own.AvatarID)
if errors.Is(err, image.ErrImageNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
usages, err := u.usage.Usage(avatar.ID)
if err != nil {
return nil, err
}
for _, found := range usages {
if found.Type != UsageTypeAuthor || found.ID != own.ID {
return nil, nil
}
}
return &avatar, nil
}
func (u *DeleteOwnAccountUseCase) discardAvatar(avatar *image.Image) {
if avatar == nil {
return
}
if err := u.avatars.discardFiles(*avatar); err != nil {
log.Println("error when deleting the stored avatar of a deleted account:", err)
}
}
func (u *DeleteOwnAccountUseCase) revoke(command DeleteOwnAccountCommand) {
if command.SessionID == "" {
return
}
if err := u.sessions.Revoke(command.SessionID, command.SessionExpiresAt); err != nil {
log.Println("error when revoking the session of a deleted account:", err)
}
}
func (u *DeleteOwnAccountUseCase) notify(account user.User) {
address := account.Email
u.dispatcher.Dispatch(func() {
if err := u.mailRepository.Send(address, accountDeletedTemplateName, MailAccountDeletedData{Host: u.publicURL}); err != nil {
log.Println("error when Send-ing the account deleted mail:", err)
}
})
}
func (u *DeleteOwnAccountUseCase) recordSuccess(ctx context.Context, account user.User) {
event := audit.NewEvent(audit.ActionUserDeleteAccount, audit.CategoryUsers)
id := account.ID
event.Actor = audit.Actor{Type: audit.ActorUser, ID: &id, Name: user.DeletedUserName}
event.Target = audit.NewTarget(audit.TargetUser, account.ID, user.DeletedUserName)
event.Security = true
u.recorder.Record(ctx, event)
}
@@ -0,0 +1,356 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
)
const (
exportProfileFile = "profile.json"
exportAuthoredFile = "authored-content.json"
exportMediaFile = "uploaded-media.json"
exportActivityFile = "account-activity.json"
exportIndent = " "
exportFilenameDate = "2006-01-02"
exportFallbackName = "user"
)
type ExportUserDataUseCase struct {
users user.Repository
roles role.Repository
authors author.Repository
keys apikey.Repository
articles article.Repository
entries entry.Repository
contentTypes contenttype.Repository
images image.Repository
events audit.Repository
now func() time.Time
recorder audit.Recorder
slots chan struct{}
}
type UserDataArchive struct {
Filename string
Content []byte
}
type exportRole struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type exportAPIKey struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
type exportProfile struct {
ID uint32 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
PendingEmail string `json:"pending_email"`
Role exportRole `json:"role"`
IsVerified bool `json:"is_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Author *author.Byline `json:"author"`
APIKeys []exportAPIKey `json:"api_keys"`
}
type exportArticle struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Slug string `json:"slug"`
SiteID uint32 `json:"site_id"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Authors []exportCredit `json:"authors"`
}
type exportCredit struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type exportEntry struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
Data map[string]any `json:"data"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type exportAuthored struct {
Articles []exportArticle `json:"articles"`
Entries []exportEntry `json:"entries"`
}
type exportImage struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Hash string `json:"hash"`
UploadedAt time.Time `json:"uploaded_at"`
}
type exportMedia struct {
Images []exportImage `json:"images"`
}
type exportEvent struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category audit.Category `json:"category"`
Target audit.Target `json:"target"`
Outcome audit.Outcome `json:"outcome"`
Security bool `json:"security"`
Source audit.Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
}
type exportActivity struct {
Events []exportEvent `json:"events"`
}
func NewExportUserDataUseCase(users user.Repository, roles role.Repository, authors author.Repository, keys apikey.Repository, articles article.Repository, entries entry.Repository, contentTypes contenttype.Repository, images image.Repository, events audit.Repository, now func() time.Time, recorder audit.Recorder, maxConcurrent int) *ExportUserDataUseCase {
return &ExportUserDataUseCase{users, roles, authors, keys, articles, entries, contentTypes, images, events, now, recorder, make(chan struct{}, max(1, maxConcurrent))}
}
func (u *ExportUserDataUseCase) Export(ctx context.Context, userID uint32) (UserDataArchive, error) {
select {
case u.slots <- struct{}{}:
defer func() { <-u.slots }()
case <-ctx.Done():
return UserDataArchive{}, ctx.Err()
}
account, err := u.users.Get(userID)
if err != nil {
return UserDataArchive{}, err
}
profile, linked, err := u.profile(account)
if err != nil {
return UserDataArchive{}, err
}
authored, err := u.authored(account, linked)
if err != nil {
return UserDataArchive{}, err
}
media, err := u.media(account)
if err != nil {
return UserDataArchive{}, err
}
activity, err := u.activity(account, linked)
if err != nil {
return UserDataArchive{}, err
}
content, err := zipJSON([]namedDocument{
{exportProfileFile, profile},
{exportAuthoredFile, authored},
{exportMediaFile, media},
{exportActivityFile, activity},
})
if err != nil {
return UserDataArchive{}, err
}
event := audit.NewEvent(audit.ActionUserExportData, audit.CategoryUsers)
event.Target = userTarget(account)
u.recorder.Record(ctx, event)
return UserDataArchive{Filename: u.filename(account), Content: content}, nil
}
func (u *ExportUserDataUseCase) filename(account user.User) string {
return "renewcms-export-" + exportUsername(account.Username) + "-" + u.now().UTC().Format(exportFilenameDate) + ".zip"
}
func exportUsername(username string) string {
var reduced strings.Builder
for _, char := range strings.ToLower(username) {
if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' {
reduced.WriteRune(char)
} else if !strings.HasSuffix(reduced.String(), "-") {
reduced.WriteRune('-')
}
}
name := strings.Trim(reduced.String(), "-")
if name == "" {
return exportFallbackName
}
return name
}
func (u *ExportUserDataUseCase) profile(account user.User) (exportProfile, *author.Author, error) {
assigned, err := u.roles.Get(account.RoleID)
if err != nil {
return exportProfile{}, nil, err
}
linked, err := u.linkedAuthor(account.ID)
if err != nil {
return exportProfile{}, nil, err
}
keys, err := u.keys.ListByUser(account.ID)
if err != nil {
return exportProfile{}, nil, err
}
profile := exportProfile{
ID: account.ID, Username: account.Username, DisplayName: account.DisplayName, Email: account.Email,
PendingEmail: account.PendingEmail, Role: exportRole{ID: assigned.ID, Name: assigned.Name},
IsVerified: account.IsVerified, CreatedAt: account.CreatedAt, UpdatedAt: account.UpdatedAt,
APIKeys: make([]exportAPIKey, 0, len(keys)),
}
if linked != nil {
byline := linked.Byline()
profile.Author = &byline
}
for _, key := range keys {
profile.APIKeys = append(profile.APIKeys, exportAPIKey{ID: key.ID, Name: key.Name, Prefix: key.Prefix, CreatedAt: key.CreatedAt, LastUsedAt: key.LastUsedAt, RevokedAt: key.RevokedAt})
}
return profile, linked, nil
}
func (u *ExportUserDataUseCase) linkedAuthor(userID uint32) (*author.Author, error) {
linked, err := u.authors.GetByUserID(userID)
if errors.Is(err, author.ErrAuthorNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &linked, nil
}
func (u *ExportUserDataUseCase) authored(account user.User, linked *author.Author) (exportAuthored, error) {
var authorID *uint32
if linked != nil {
authorID = &linked.ID
}
articles, err := u.articles.ListForAccount(account.ID, authorID)
if err != nil {
return exportAuthored{}, err
}
entries, err := u.entries.ListByCreator(account.ID)
if err != nil {
return exportAuthored{}, err
}
keys, err := u.contentTypeKeys()
if err != nil {
return exportAuthored{}, err
}
authored := exportAuthored{Articles: make([]exportArticle, 0, len(articles)), Entries: make([]exportEntry, 0, len(entries))}
for _, item := range articles {
credits := make([]exportCredit, 0, len(item.Authors))
for _, byline := range item.Authors {
credits = append(credits, exportCredit{byline.ID, byline.Name, byline.Slug})
}
authored.Articles = append(authored.Articles, exportArticle{item.ID, item.Title, item.Body, item.Slug, item.SiteID, item.IsOnline, item.CreatedAt, item.UpdatedAt, credits})
}
for _, item := range entries {
key, known := keys[item.ContentTypeID]
if !known {
key = strconv.FormatUint(uint64(item.ContentTypeID), 10)
}
authored.Entries = append(authored.Entries, exportEntry{item.ID, key, item.Data, item.CreatedAt, item.UpdatedAt})
}
return authored, nil
}
func (u *ExportUserDataUseCase) contentTypeKeys() (map[uint32]string, error) {
types, err := u.contentTypes.List()
if err != nil {
return nil, err
}
keys := make(map[uint32]string, len(types))
for _, contentType := range types {
keys[contentType.ID] = contentType.Key
}
return keys, nil
}
func (u *ExportUserDataUseCase) media(account user.User) (exportMedia, error) {
images, err := u.images.ListByUploader(account.ID)
if err != nil {
return exportMedia{}, err
}
media := exportMedia{Images: make([]exportImage, 0, len(images))}
for _, item := range images {
media.Images = append(media.Images, exportImage{item.ID, item.Filename, item.ContentType, item.Size, item.SHA256, item.CreatedAt})
}
return media, nil
}
func (u *ExportUserDataUseCase) activity(account user.User, linked *author.Author) (exportActivity, error) {
id := account.ID
var ownAuthorID *uint32
if linked != nil {
ownAuthorID = &linked.ID
}
page, err := u.events.List(audit.Filter{Actor: &audit.ActorRef{Type: audit.ActorUser, ID: &id}})
if err != nil {
return exportActivity{}, err
}
activity := exportActivity{Events: make([]exportEvent, 0, len(page.Items))}
for _, item := range page.Items {
if item.ConcernsOtherPerson(account.ID, ownAuthorID) {
continue
}
params := item.Params
if params == nil {
params = map[string]any{}
}
activity.Events = append(activity.Events, exportEvent{item.ID, item.CreatedAt, item.Action, item.Category, item.Target, item.Outcome, item.Security, item.Source, item.IP, item.UserAgent, params})
}
return activity, nil
}
type namedDocument struct {
name string
value any
}
func zipJSON(documents []namedDocument) ([]byte, error) {
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
for _, document := range documents {
file, err := archive.Create(document.name)
if err != nil {
return nil, err
}
encoder := json.NewEncoder(file)
encoder.SetIndent("", exportIndent)
if err := encoder.Encode(document.value); err != nil {
return nil, err
}
}
if err := archive.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -0,0 +1,60 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"context"
"strings"
"time"
)
type RequestEmailChangeUseCase struct {
userRepository user.Repository
hasher hash.Hasher
issuer emailChangeIssuer
recorder audit.Recorder
}
type RequestEmailChangeCommand struct {
UserID uint32
NewEmail string
CurrentPassword string
}
func NewRequestEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, now func() time.Time, recorder audit.Recorder) *RequestEmailChangeUseCase {
return &RequestEmailChangeUseCase{userRepository, hasher, emailChangeIssuer{userRepository, hasher, mailRepository, dispatcher, publicURL, now}, recorder}
}
func (u *RequestEmailChangeUseCase) RequestEmailChange(ctx context.Context, command RequestEmailChangeCommand) (string, error) {
account, err := u.userRepository.Get(command.UserID)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserRequestEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if err := u.refuse(account, command); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
if _, err := u.issuer.issue(account, command.NewEmail); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.issuer.sendNotice(account, command.NewEmail)
u.recorder.Record(ctx, event)
return command.NewEmail, nil
}
func (u *RequestEmailChangeUseCase) refuse(account user.User, command RequestEmailChangeCommand) error {
if u.hasher.Compare(account.Password, command.CurrentPassword) != nil {
return user.ErrWrongPassword
}
if strings.EqualFold(strings.TrimSpace(command.NewEmail), account.Email) {
return user.ErrSameEmail
}
return nil
}
@@ -0,0 +1,41 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type ResendEmailChangeUseCase struct {
userRepository user.Repository
issuer emailChangeIssuer
recorder audit.Recorder
}
func NewResendEmailChangeUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, dispatcher mail.Dispatcher, publicURL string, now func() time.Time, recorder audit.Recorder) *ResendEmailChangeUseCase {
return &ResendEmailChangeUseCase{userRepository, emailChangeIssuer{userRepository, hasher, mailRepository, dispatcher, publicURL, now}, recorder}
}
func (u *ResendEmailChangeUseCase) ResendEmailChange(ctx context.Context, userId uint32) (string, error) {
account, err := u.userRepository.Get(userId)
if err != nil {
return "", err
}
event := audit.NewEvent(audit.ActionUserResendEmail, audit.CategoryUsers)
event.Target = userTarget(account)
event.Security = true
if account.PendingEmail == "" {
recordFailure(ctx, u.recorder, event, user.ErrNoPendingEmailChange)
return "", user.ErrNoPendingEmailChange
}
if _, err := u.issuer.issue(account, account.PendingEmail); err != nil {
recordFailure(ctx, u.recorder, event, err)
return "", err
}
u.recorder.Record(ctx, event)
return account.PendingEmail, nil
}
@@ -44,6 +44,14 @@ var failureReasons = []failureReason{
{user.ErrVerificationCodeExpired, "The verification code has expired."},
{user.ErrInvalidDisplayName, "That display name is too long."},
{user.ErrUsernameTaken, "That username is already taken."},
{user.ErrWrongPassword, audit.ReasonWrongPassword},
{user.ErrLastOwner, "Last Owner."},
{user.ErrLastAdmin, "Last administrator."},
{user.ErrAccountNotDeletable, "The account can't be deleted right now."},
{user.ErrSameEmail, "The new address is already the account's email address."},
{user.ErrNoPendingEmailChange, "No email change is pending."},
{user.ErrInvalidEmailChangeCode, "The email change link is not valid."},
{user.ErrEmailUnavailable, "The new address can't be used for this account."},
{user.ErrPasswordTooLong, "The password is longer than 72 bytes."},
{invite.ErrEmailHasAccount, "That email already has an account."},
{invite.ErrPendingInvite, "An invitation is already pending for that email."},
@@ -0,0 +1,81 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"log"
"time"
"github.com/google/uuid"
)
const (
verifyNewEmailTemplateName = "verify-new-email"
emailChangeNoticeTemplateName = "email-change-notice"
)
type MailVerifyNewEmailData struct {
Host string
Username string
VerificationCode string
}
type MailEmailChangeNoticeData struct {
Host string
Username string
NewEmail string
}
type emailChangeIssuer struct {
userRepository user.Repository
hasher hash.Hasher
mailRepository mail.Repository
dispatcher mail.Dispatcher
publicURL string
now func() time.Time
}
func (i emailChangeIssuer) issue(account user.User, pendingEmail string) (user.User, error) {
rawCode := uuid.NewString()
hashedCode, err := i.hasher.Hash(rawCode)
if err != nil {
return user.User{}, err
}
updated, err := i.userRepository.SetPendingEmailChange(account.ID, pendingEmail, hashedCode, i.now().Add(user.EmailChangeLifetime))
if err != nil {
return user.User{}, err
}
i.dispatcher.Dispatch(func() { i.sendVerification(account, pendingEmail, rawCode) })
return updated, nil
}
func (i emailChangeIssuer) sendVerification(account user.User, pendingEmail string, rawCode string) {
taken, err := i.userRepository.IsEmailInUse(pendingEmail, account.ID)
if err != nil {
log.Println("error when checking whether the new email address is in use:", err)
return
}
if taken {
return
}
if err := i.mailRepository.Send(pendingEmail, verifyNewEmailTemplateName, MailVerifyNewEmailData{
Host: i.publicURL,
Username: account.Username,
VerificationCode: rawCode,
}); err != nil {
log.Println("error when Send-ing the new email verification mail:", err)
}
}
func (i emailChangeIssuer) sendNotice(account user.User, pendingEmail string) {
i.dispatcher.Dispatch(func() {
if err := i.mailRepository.Send(account.Email, emailChangeNoticeTemplateName, MailEmailChangeNoticeData{
Host: i.publicURL,
Username: account.Username,
NewEmail: pendingEmail,
}); err != nil {
log.Println("error when Send-ing the email change notice mail:", err)
}
})
}