feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user