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 }