357 lines
11 KiB
Go
357 lines
11 KiB
Go
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
|
|
}
|