feat: render a link to poster profile next to the ID within shadow copy details (#10194)

Closes #10078 and includes another small improvement (for comments and issues/PRs the title from report/s details page already included the poster name; now it will clickable, opening the poster profile page).

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10194
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: floss4good <floss4good@disroot.org>
Co-committed-by: floss4good <floss4good@disroot.org>
This commit is contained in:
floss4good 2025-12-09 15:19:10 +01:00 committed by Gusted
parent 610e143e9b
commit 590104b5ca
13 changed files with 262 additions and 20 deletions

View file

@ -27,19 +27,24 @@ type IssueData struct {
// Implements GetFieldsMap() from ShadowCopyData interface, returning a list of <key, value> pairs
// to be used when rendering the shadow copy for admins reviewing the corresponding abuse report(s).
func (cd IssueData) GetFieldsMap() []moderation.ShadowCopyField {
func (id IssueData) GetFieldsMap() []moderation.ShadowCopyField {
return []moderation.ShadowCopyField{
{Key: "RepoID", Value: strconv.FormatInt(cd.RepoID, 10)},
{Key: "Index", Value: strconv.FormatInt(cd.Index, 10)},
{Key: "PosterID", Value: strconv.FormatInt(cd.PosterID, 10)},
{Key: "Title", Value: cd.Title},
{Key: "Content", Value: cd.Content},
{Key: "ContentVersion", Value: strconv.Itoa(cd.ContentVersion)},
{Key: "CreatedUnix", Value: cd.CreatedUnix.AsLocalTime().String()},
{Key: "UpdatedUnix", Value: cd.UpdatedUnix.AsLocalTime().String()},
{Key: "RepoID", Value: strconv.FormatInt(id.RepoID, 10)},
{Key: "Index", Value: strconv.FormatInt(id.Index, 10)},
{Key: "Poster", Value: strconv.FormatInt(id.PosterID, 10)},
{Key: "Title", Value: id.Title},
{Key: "Content", Value: id.Content},
{Key: "ContentVersion", Value: strconv.Itoa(id.ContentVersion)},
{Key: "CreatedUnix", Value: id.CreatedUnix.AsLocalTime().String()},
{Key: "UpdatedUnix", Value: id.UpdatedUnix.AsLocalTime().String()},
}
}
// Implements GetAbuserID() from ShadowCopyData interface, returning the value of PosterID field.
func (id *IssueData) GetAbuserID() (int64, bool) {
return id.PosterID, true
}
// newIssueData creates a trimmed down issue to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newIssueData(issue *Issue) IssueData {
@ -70,7 +75,7 @@ type CommentData struct {
// to be used when rendering the shadow copy for admins reviewing the corresponding abuse report(s).
func (cd CommentData) GetFieldsMap() []moderation.ShadowCopyField {
return []moderation.ShadowCopyField{
{Key: "PosterID", Value: strconv.FormatInt(cd.PosterID, 10)},
{Key: "Poster", Value: strconv.FormatInt(cd.PosterID, 10)},
{Key: "IssueID", Value: strconv.FormatInt(cd.IssueID, 10)},
{Key: "Content", Value: cd.Content},
{Key: "ContentVersion", Value: strconv.Itoa(cd.ContentVersion)},
@ -79,6 +84,11 @@ func (cd CommentData) GetFieldsMap() []moderation.ShadowCopyField {
}
}
// Implements GetAbuserID() from ShadowCopyData interface, returning the value of PosterID field.
func (cd *CommentData) GetAbuserID() (int64, bool) {
return cd.PosterID, true
}
// newCommentData creates a trimmed down comment to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newCommentData(comment *Comment) CommentData {

View file

@ -39,7 +39,7 @@ func TestIssueDataGetFieldsMap(t *testing.T) {
if assert.Len(t, scFields, 8) {
testShadowCopyField(t, scFields[0], "RepoID", "2001")
testShadowCopyField(t, scFields[1], "Index", "2")
testShadowCopyField(t, scFields[2], "PosterID", "1002")
testShadowCopyField(t, scFields[2], "Poster", "1002")
testShadowCopyField(t, scFields[3], "Title", "Professional marketing services")
testShadowCopyField(t, scFields[4], "Content", "Visit my website at promote-your-business.biz for a list of available services.")
testShadowCopyField(t, scFields[5], "ContentVersion", "0")
@ -60,7 +60,7 @@ func TestCommentDataGetFieldsMap(t *testing.T) {
scFields := cd.GetFieldsMap()
if assert.Len(t, scFields, 6) {
testShadowCopyField(t, scFields[0], "PosterID", "1002")
testShadowCopyField(t, scFields[0], "Poster", "1002")
testShadowCopyField(t, scFields[1], "IssueID", "3001")
testShadowCopyField(t, scFields[2], "Content", "Check out [alexsmith/website](/alexsmith/website)")
testShadowCopyField(t, scFields[3], "ContentVersion", "0")

View file

@ -22,6 +22,11 @@ type AbuseReportDetailed struct {
ContentReference string
ShadowCopyDate timeutil.TimeStamp // only for details
ShadowCopyRawValue string // only for details
// In case the reported content was deleted before the report was handled, this flag can be set
// in order to try to determine the abuser/poster ID (and load their details) based on the fields
// that might have been saved within the shadow copy (for comments and issues/PRs).
ShouldGetAbuserFromShadowCopy bool `xorm:"-"`
}
func (ard AbuseReportDetailed) ContentTypeIconName() string {

View file

@ -40,6 +40,13 @@ type ShadowCopyData interface {
// GetFieldsMap returns a list of <key, value> pairs with the fields stored within shadow copies
// of content reported as abusive, to be used when rendering a shadow copy in the admin UI.
GetFieldsMap() []ShadowCopyField
// GetAbuserID returns the ID of the user who posted the reported content when this info in available within
// the shadow copy (i.e. the PosterID field for comments and issues/PRs or the OwnerID field for repositories),
// together with a boolean value indicating whether the ID is considered valid or not.
// This is used to retrieve the abuser/poster in case the reported content was deleted before an admin managed
// to review the report, allowing them to easily access the abuser profile (if this was not also deleted).
GetAbuserID() (int64, bool)
}
func init() {

View file

@ -43,6 +43,11 @@ func (rd RepositoryData) GetFieldsMap() []moderation.ShadowCopyField {
}
}
// Implements GetAbuserID() from ShadowCopyData interface, returning the value of OwnerID field.
func (rd *RepositoryData) GetAbuserID() (int64, bool) {
return rd.OwnerID, true
}
// newRepositoryData creates a trimmed down repository to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newRepositoryData(repo *Repository) RepositoryData {

View file

@ -57,6 +57,12 @@ func (ud UserData) GetFieldsMap() []moderation.ShadowCopyField {
}
}
// Implements GetAbuserID() from ShadowCopyData interface, returning (GhostUserID, false), since for users/organizations
// the ID is not saved within the shadow copy (because it is already stored as ContentID in the abuse report).
func (ud *UserData) GetAbuserID() (int64, bool) {
return GhostUserID, false
}
// newUserData creates a trimmed down user to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newUserData(user *User) UserData {

View file

@ -93,6 +93,9 @@ func AbuseReportDetails(ctx *context.Context) {
if err = setReportedContentDetails(ctx, reports[0]); err != nil {
if user.IsErrUserNotExist(err) || issues.IsErrCommentNotExist(err) || issues.IsErrIssueNotExist(err) || repo_model.IsErrRepoNotExist(err) {
ctx.Data["ContentReference"] = ctx.Tr("admin.moderation.deleted_content_ref", reports[0].ContentType, reports[0].ContentID)
if contentType == moderation.ReportedContentTypeComment || contentType == moderation.ReportedContentTypeIssue {
reports[0].ShouldGetAbuserFromShadowCopy = true
}
} else {
ctx.ServerError("Failed to load reported content details", err)
return
@ -103,11 +106,12 @@ func AbuseReportDetails(ctx *context.Context) {
}
// setReportedContentDetails adds some values into context data for the given report
// (icon name, a reference, the URL and in case of issues and comments also the poster name).
// (icon name, a reference, the URL and in case of issues and comments also the poster name and URL).
func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseReportDetailed) error {
contentReference := ""
var contentURL string
var poster string
var posterURL string
contentType := report.ContentType
contentID := report.ContentID
@ -143,6 +147,7 @@ func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseRep
}
if issue.Poster != nil {
poster = issue.Poster.Name
posterURL = issue.Poster.HomeLink()
}
contentReference = fmt.Sprintf("%s#%d", issue.Repo.FullName(), issue.Index)
@ -163,6 +168,7 @@ func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseRep
}
if comment.Poster != nil {
poster = comment.Poster.Name
posterURL = comment.Poster.HomeLink()
}
contentURL = comment.Link(ctx)
@ -172,6 +178,7 @@ func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseRep
ctx.Data["ContentReference"] = contentReference
ctx.Data["ContentURL"] = contentURL
ctx.Data["Poster"] = poster
ctx.Data["PosterURL"] = posterURL
return nil
}

View file

@ -61,6 +61,9 @@ func (ca ContentAction) IsValid() bool {
// GetShadowCopyMap unmarshals the shadow copy raw value of the given abuse report and returns a list of <key, value> pairs
// (to be rendered when the report is reviewed by an admin).
// It also checks whether the ShouldGetAbuserFromShadowCopy runtime flag is set on the report and if so will try to
// retrieve the abusive user (when their ID can be found within the shadow copy) in order to set some details
// (name and profile link) as context data.
// If the report does not have a shadow copy ID or the raw value is empty, returns nil.
// If the unmarshal fails a warning is added in the logs and returns nil.
func GetShadowCopyMap(ctx *context.Context, ard *moderation.AbuseReportDetailed) []moderation.ShadowCopyField {
@ -81,7 +84,25 @@ func GetShadowCopyMap(ctx *context.Context, ard *moderation.AbuseReportDetailed)
log.Warn("Unmarshal failed for shadow copy #%d. %v", ard.ShadowCopyID.Int64, err)
return nil
}
if ard.ShouldGetAbuserFromShadowCopy {
abuserID, isValidID := data.GetAbuserID()
if isValidID {
setAbuserDetails(ctx, abuserID)
}
}
return data.GetFieldsMap()
}
return nil
}
// setAbuserDetails tries to retrieve a user with the given ID and in case
// a user is found it will set their name and profile URL into ctx.Data.
func setAbuserDetails(ctx *context.Context, abuserID int64) {
abuser, err := user.GetPossibleUserByID(ctx, abuserID)
if err == nil {
ctx.Data["AbuserName"] = abuser.Name
ctx.Data["AbuserURL"] = abuser.HomeLink()
}
}

View file

@ -12,7 +12,7 @@
<div class="flex-item-main">
<div class="flex-item-title">
{{if .ContentURL}}<a href="{{.ContentURL}}">{{.ContentReference}}</a>{{else}}<em>{{.ContentReference}}</em>{{end}}
{{if .Poster}}<span>{{.Poster}}</span>{{end}}
{{if .Poster}}<span>{{if .PosterURL}}<a href="{{.PosterURL}}">{{.Poster}}</a>{{else}}<em>{{.Poster}}</em>{{end}}</span>{{end}}
</div>
</div>
</div>
@ -47,7 +47,7 @@
{{range $scField := (call $.GetShadowCopyMap $.Context .)}}
<tr>
<td>{{$scField.Key}}</td>
<td>{{$scField.Value}}</td>
<td>{{$scField.Value}}{{if and $.AbuserURL (eq $scField.Key "Poster")}} (<a href="{{$.AbuserURL}}">{{$.AbuserName}}</a>){{end}}</td>
</tr>
{{end}}
</table>

View file

@ -87,9 +87,9 @@ func TestAdminModerationViewReports(t *testing.T) {
htmlDoc := NewHTMLParser(t, resp.Body)
// Check how many reports are being displayed.
// Reports linked to the same content (type and id) should be grouped; therefore we should see only 8 instead of 11.
// Reports linked to the same content (type and id) should be grouped; therefore we should see only 11 instead of 14.
reports := htmlDoc.Find(".admin-setting-content .flex-list .flex-item.report")
assert.Equal(t, 8, reports.Length())
assert.Equal(t, 11, reports.Length())
// Check details for shown reports.
testReportDetails(t, htmlDoc, "1", "octicon-person", "@SPAM-services", "/SPAM-services", "Illegal content", "1")
@ -102,17 +102,24 @@ func TestAdminModerationViewReports(t *testing.T) {
testReportDetails(t, htmlDoc, "8", "octicon-issue-opened", "contributor/first#1", "/contributor/first/issues/1", "Other violations of platform rules", "1")
// #10 is for a Ghost user
testReportDetails(t, htmlDoc, "10", "octicon-person", "Reported content with type 1 and id 9999 no longer exists", "", "Other violations of platform rules", "1")
// #11 if for a comment who's poster was deleted
// #11 is for a comment who's poster was deleted
testReportDetails(t, htmlDoc, "11", "octicon-comment", "contributor/first/issues/1#issuecomment-1003", "/contributor/first/issues/1#issuecomment-1003", "Spam", "1")
// #12 is for a comment that was deleted but its poster still exists
testReportDetails(t, htmlDoc, "12", "octicon-comment", "Reported content with type 4 and id 9991 no longer exists", "", "Other violations of platform rules", "1")
// #13 is for a comment that was deleted and the poster was also deleted
testReportDetails(t, htmlDoc, "13", "octicon-comment", "Reported content with type 4 and id 9992 no longer exists", "", "Spam", "1")
// #14 is for a issue that was deleted but its poster still exists
testReportDetails(t, htmlDoc, "14", "octicon-issue-opened", "Reported content with type 3 and id 9991 no longer exists", "", "Spam", "1")
t.Run("reports details page", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// ## There are 3 open reports linked to user 1002.
req = NewRequest(t, "GET", "/admin/moderation/reports/type/1/id/1002")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
// Check the title (content reference) and corresponding URL
// Check the title (content reference) and corresponding URL.
title := htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title a")
assert.Equal(t, 1, title.Length())
assert.Equal(t, "spammer01", title.Text())
@ -124,7 +131,7 @@ func TestAdminModerationViewReports(t *testing.T) {
reports = htmlDoc.Find(".admin-setting-content .flex-list .flex-item")
assert.Equal(t, 3, reports.Length())
// Poster of comment 1003 was deleted; make sure the details page is still rendered correctly.
// ## Poster of comment 1003 was deleted; make sure the details page is still rendered correctly.
req = NewRequest(t, "GET", "/admin/moderation/reports/type/4/id/1003")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
@ -136,6 +143,92 @@ func TestAdminModerationViewReports(t *testing.T) {
href, exists = title.Attr("href")
assert.True(t, exists)
assert.Equal(t, "/contributor/first/issues/1#issuecomment-1003", href)
// ## Comment 9991 was deleted but its poster still exists.
req = NewRequest(t, "GET", "/admin/moderation/reports/type/4/id/9991")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
// Check the title (content reference); no URL in case of deleted content.
title = htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title a")
assert.Equal(t, 0, title.Length()) // no anchor because the reported content was deleted
title = htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title em")
assert.Equal(t, 1, title.Length()) // a generic emphasised title is shown for deleted content
assert.Equal(t, "Reported content with type 4 and id 9991 no longer exists", title.Text())
// Check shadow copies for deleted comment when poster still exists.
reports := htmlDoc.Find(".admin-setting-content .flex-list .flex-item")
assert.Equal(t, 1, reports.Length()) // there is only one report for this content
shadowCopyRows := htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr")
assert.Equal(t, 6, shadowCopyRows.Length()) // a shadow copy was created and it has 6 fields
posterRowCells := htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr:first-of-type td")
assert.Equal(t, 2, posterRowCells.Length())
assert.Equal(t, "Poster", posterRowCells.Nodes[0].FirstChild.Data) // in case of comment shadow copies, the first field should be 'Poster'
// For reports of deleted comments, in case the poster still exists
// the value of 'Poster' field should contain a link to the poster profile.
poster := htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr:first-of-type td:last-of-type a")
assert.Equal(t, 1, poster.Length())
assert.Equal(t, "grace", poster.Text())
href, exists = poster.Attr("href")
assert.True(t, exists)
assert.Equal(t, "/grace", href)
// ## Comment 9992 was deleted and its poster was also deleted.
req = NewRequest(t, "GET", "/admin/moderation/reports/type/4/id/9992")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
// Check the title (content reference); no URL in case of deleted content.
title = htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title a")
assert.Equal(t, 0, title.Length()) // no anchor because the reported content was deleted
title = htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title em")
assert.Equal(t, 1, title.Length()) // a generic emphasised title is shown for deleted content
assert.Equal(t, "Reported content with type 4 and id 9992 no longer exists", title.Text())
// Check shadow copies for deleted comment when poster was also deleted.
reports = htmlDoc.Find(".admin-setting-content .flex-list .flex-item")
assert.Equal(t, 1, reports.Length()) // there is only one report for this content
shadowCopyRows = htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr")
assert.Equal(t, 6, shadowCopyRows.Length()) // a shadow copy was created and it has 6 fields
posterRowCells = htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr:first-of-type td")
assert.Equal(t, 2, posterRowCells.Length())
assert.Equal(t, "Poster", posterRowCells.Nodes[0].FirstChild.Data) // in case of comment shadow copies, the first field should be 'Poster'
// For reports of deleted comments, in case the poster was also deleted
// the value of 'Poster' field cannot contain a link to the poster profile but only their ID.
poster = htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr:first-of-type td:last-of-type a")
assert.Equal(t, 0, poster.Length())
poster = htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr:first-of-type td:last-of-type")
assert.Equal(t, 1, poster.Length())
assert.Equal(t, "9999", poster.Text())
// ## Issue 9991 was deleted but its poster still exists.
req = NewRequest(t, "GET", "/admin/moderation/reports/type/3/id/9991")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
// Check the title (content reference); no URL in case of deleted content.
title = htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title a")
assert.Equal(t, 0, title.Length()) // no anchor because the reported content was deleted
title = htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title em")
assert.Equal(t, 1, title.Length()) // a generic emphasised title is shown for deleted content
assert.Equal(t, "Reported content with type 3 and id 9991 no longer exists", title.Text())
// Check shadow copies for deleted issue when poster still exists.
reports = htmlDoc.Find(".admin-setting-content .flex-list .flex-item")
assert.Equal(t, 1, reports.Length()) // there is only one report for this content
shadowCopyRows = htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr")
assert.Equal(t, 8, shadowCopyRows.Length()) // a shadow copy was created and it has 8 fields
posterRowCells = htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr:nth-of-type(3) td")
assert.Equal(t, 2, posterRowCells.Length())
assert.Equal(t, "Poster", posterRowCells.Nodes[0].FirstChild.Data) // in case of issue shadow copies, the third field should be 'Poster'
// For reports of deleted issues, in case the poster still exists
// the value of 'Poster' field should contain a link to the poster profile.
poster = htmlDoc.Find(".admin-setting-content .flex-list .flex-item:first-of-type details table tr:nth-of-type(3) td:last-of-type a")
assert.Equal(t, 1, poster.Length())
assert.Equal(t, "frank", poster.Text())
href, exists = poster.Attr("href")
assert.True(t, exists)
assert.Equal(t, "/frank", href)
})
})
})

View file

@ -120,3 +120,36 @@
remarks: This user just posted a spammy comment on my issue.
shadow_copy_id: null
created_unix: 1752697820 # 2025-07-16 20:30:20
- # Report of a comment that was afterwards deleted (and a shadow copy was created) but the poster still exists.
id: 12
status: 1
reporter_id: 1001 # @contributor
content_type: 4 # Comment
content_id: 9991 # deleted comment
category: 1 # Other
remarks: Accuses me of being a frog.
shadow_copy_id: 1201
created_unix: 1752748200 # 2005-07-17 10:30:00
- # Report of a comment that was afterwards deleted (and a shadow copy was created) and the poster was also deleted.
id: 13
status: 1
reporter_id: 1004 # @reporter1
content_type: 4 # Comment
content_id: 9992 # deleted comment
category: 2 # Spam
remarks: This comment advertises a product.
shadow_copy_id: 1301
created_unix: 1752751260 # 2005-07-17 11:21:00
- # Report of an issue that was afterwards deleted (and a shadow copy was created) but the poster still exists.
id: 14
status: 1
reporter_id: 1004 # @reporter1
content_type: 3 # Issue (issues or pull requests)
content_id: 9991 # deleted issue
category: 2 # Spam
remarks: This issue was created for advertising a business.
shadow_copy_id: 1401
created_unix: 1752751320 # 2005-07-17 11:22:00

View file

@ -0,0 +1,14 @@
-
id: 1201
raw_value: '{"PosterID":1102,"IssueID":1001,"Content":"You are just a frog.","ContentVersion":0,"CreatedUnix":1752747600,"UpdatedUnix":1752747600}'
created_unix: 1752748260 # 2025-07-17 10:31:00
-
id: 1301
raw_value: '{"PosterID":9999,"IssueID":1001,"Content":"Best cleaning products on http://cleaning.test","ContentVersion":0,"CreatedUnix":1752750660,"UpdatedUnix":1752750660}'
created_unix: 1752751860 # 2025-07-17 11:31:00
-
id: 1401
raw_value: '{"RepoID":1001,"Index":9,"PosterID":1101,"Title":"Professional cleaning products","Content":"We provide a wide range of cleaning products. Visit [our website](https://doubtfulshops.test/cleaning) for more details.","ContentVersion":0,"CreatedUnix":1752750720,"UpdatedUnix":1752750720}'
created_unix: 1752751920 # 2025-07-17 11:32:00

View file

@ -106,3 +106,44 @@
avatar: avatar-hash-1005
avatar_email: reporter2@example.org
use_custom_avatar: false
- # This is an abusive user (a spammer).
id: 1101
lower_name: frank
name: frank
full_name: Frank
email: frank@doubtfulshops.test
keep_email_private: false
passwd: passwdSalt:password
passwd_hash_algo: dummy
type: 0
salt: passwdSalt
website: https://doubtfulshops.test
description: 'We are a startup company which developed a novel cleaning product. Check out our website for latest offers. https://doubtfulshops.test/cleaning'
created_unix: 1752747600 # 2025-07-17 10:20:00
is_active: true
is_admin: false
is_restricted: false
avatar: avatar-hash-1101
avatar_email: frank@doubtfulshops.test
use_custom_avatar: false
- # This is an abusive user (an offender).
id: 1102
lower_name: grace
name: grace
full_name: Grace
email: grace@example.org
keep_email_private: false
passwd: passwdSalt:password
passwd_hash_algo: dummy
type: 0
salt: passwdSalt
description: ''
created_unix: 1752747630 # 2025-07-17 10:20:30
is_active: true
is_admin: false
is_restricted: false
avatar: avatar-hash-1102
avatar_email: grace@example.org
use_custom_avatar: false