From 9715f90a48fd2913af6e3e0e6da98a4ad54b2c75 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 26 May 2025 15:09:40 +0100 Subject: [PATCH] search: fix server-side dates, add mentionedUsers, referrer, time QueryDTO.Value being classed as "any" meant DateAttempts would be unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data type and unmarshals into a DateAttempt if needed. mentionedUers, referrer and time matching implemented for activity search. Also, fixed multi-class queries (e.g. date -and- bool for last-active). --- activitysort.go | 137 +++++++++++++++++++++++++++++++++++++++---- api-activities.go | 43 +++++++------- ts/modules/search.ts | 6 +- usercache.go | 46 ++++++++++++--- 4 files changed, 188 insertions(+), 44 deletions(-) diff --git a/activitysort.go b/activitysort.go index 7cd8af9..8a020f6 100644 --- a/activitysort.go +++ b/activitysort.go @@ -3,7 +3,9 @@ package main import ( "fmt" "strings" + "time" + "github.com/hrfee/mediabrowser" "github.com/timshannon/badgerhold/v4" ) @@ -70,17 +72,22 @@ func activityTypeGetterNameToType(getter string) ActivityType { return ActivityUnknown } +// andField appends to the existing query if not nil, and otherwise creates a new one. +func andField(q *badgerhold.Query, field string) *badgerhold.Criterion { + if q == nil { + return badgerhold.Where(field) + } + return q.And(field) +} + // AsDBQuery returns a mutated "query" filtering for the conditions in "q". func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query { - if query == nil { - query = &badgerhold.Query{} - } // Special case for activity type: // In the app, there isn't an "activity:" query, but rather "<~fieldname>:true/false" queries. // For other API consumers, we also handle the former later. activityType := activityTypeGetterNameToType(q.Field) if activityType != ActivityUnknown { - criterion := query.And("Type") + criterion := andField(query, "Type") if q.Operator != EqualOperator { panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator)) } @@ -93,12 +100,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query { } fieldName := activityDTONameToField(q.Field) - if fieldName == "unknown" { - panic("FIXME: Support all the weird queries of the web UI!") + // Fail if unrecognized, or recognized as time (we handle this with DateAttempt.Compare separately). + if fieldName == "unknown" || fieldName == "Time" { + // Caller is expected to fall back to ActivityDBQueryFromSpecialField after this. + return nil } - criterion := query.And(fieldName) + criterion := andField(query, fieldName) - // FIXME: Deal with dates like we do in usercache.go switch q.Operator { case LesserOperator: query = criterion.Lt(q.Value) @@ -112,12 +120,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query { // ActivityMatchesSearchAsDBBaseQuery returns a base query (which you should then apply other mutations to) matching the search "term" to Activities by searching all fields. Does not search the generated title like the web app. func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query { - subQuery := &badgerhold.Query{} + var baseQuery *badgerhold.Query = nil // I don't believe you can just do Where("*"), so instead run for each field. - for _, fieldName := range []string{"ID", "Type", "UserID", "Username", "SourceType", "Source", "SourceUsername", "InviteCode", "Value", "IP"} { + // FIXME: Match username and source_username and source_type and type + for _, fieldName := range []string{"ID", "UserID", "Source", "InviteCode", "Value", "IP"} { criterion := badgerhold.Where(fieldName) // No case-insentive Contains method, so we use MatchFunc instead - subQuery = subQuery.Or(criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { field := ra.Field() // _, ok := field.(string) // if !ok { @@ -130,8 +139,110 @@ func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query { } } return false, nil - })) + }) + if baseQuery == nil { + baseQuery = f + } else { + baseQuery = baseQuery.Or(f) + } } - return subQuery + return baseQuery +} + +func (act Activity) SourceIsUser() bool { + return (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" +} + +func (act Activity) MustGetUsername(jf *mediabrowser.MediaBrowser) string { + if act.Type == ActivityDeletion || act.Type == ActivityCreation { + return act.Value + } + if act.UserID == "" { + return "" + } + // Don't care abt errors, user.Name will be blank in that case anyway + user, _ := jf.UserByID(act.UserID, false) + return user.Name +} + +func (act Activity) MustGetSourceUsername(jf *mediabrowser.MediaBrowser) string { + if !act.SourceIsUser() { + return "" + } + // Don't care abt errors, user.Name will be blank in that case anyway + user, _ := jf.UserByID(act.Source, false) + return user.Name +} + +func ActivityDBQueryFromSpecialField(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + switch q.Field { + case "mentionedUsers": + query = matchMentionedUsersAsQuery(jf, query, q) + case "actor": + query = matchActorAsQuery(jf, query, q) + case "referrer": + query = matchReferrerAsQuery(jf, query, q) + case "time": + query = matchTimeAsQuery(query, q) + default: + panic(fmt.Errorf("unknown activity query field %s", q.Field)) + } + return query +} + +// matchMentionedUsersAsQuery is a custom match function for the "mentionedUsers" getter/query type. +func matchMentionedUsersAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + criterion := andField(query, "UserID") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + act := ra.Record().(*Activity) + usernames := act.MustGetUsername(jf) + " " + act.MustGetSourceUsername(jf) + return strings.Contains(strings.ToLower(usernames), strings.ToLower(q.Value.(string))), nil + }) + return query +} + +// matchActorAsQuery is a custom match function for the "actor" getter/query type. +func matchActorAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + criterion := andField(query, "SourceType") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + act := ra.Record().(*Activity) + matchString := activitySourceToString(act.SourceType) + if act.SourceType == ActivityAdmin || act.SourceType == ActivityUser && act.SourceIsUser() { + matchString += " " + act.MustGetSourceUsername(jf) + } + return strings.Contains(strings.ToLower(matchString), strings.ToLower(q.Value.(string))), nil + }) + return query +} + +// matchReferrerAsQuery is a custom match function for the "referrer" getter/query type. +func matchReferrerAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + criterion := andField(query, "Type") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + act := ra.Record().(*Activity) + if act.Type == ActivityCreation || act.SourceType == ActivityUser || !act.SourceIsUser() { + return false, nil + } + return strings.Contains(strings.ToLower(act.MustGetSourceUsername(jf)), strings.ToLower(q.Value.(string))), nil + }) + return query +} + +// mathcTimeAsQuery is a custom match function for the "time" getter/query type. Roughly matches the same way as the web app, and in usercache.go. +func matchTimeAsQuery(query *badgerhold.Query, q QueryDTO) *badgerhold.Query { + operator := Equal + switch q.Operator { + case LesserOperator: + operator = Lesser + case EqualOperator: + operator = Equal + case GreaterOperator: + operator = Greater + } + criterion := andField(query, "Time") + query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) { + return q.Value.(DateAttempt).Compare(ra.Field().(time.Time)) == int(operator), nil + }) + return query } diff --git a/api-activities.go b/api-activities.go index eda15a4..3f2e6c6 100644 --- a/api-activities.go +++ b/api-activities.go @@ -106,11 +106,19 @@ func (app *appContext) GetActivities(gc *gin.Context) { if len(req.SearchTerms) != 0 { query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms) } else { - query = &badgerhold.Query{} + query = nil } for _, q := range req.Queries { - query = q.AsDBQuery(query) + nq := q.AsDBQuery(query) + if nq == nil { + nq = ActivityDBQueryFromSpecialField(app.jf, query, q) + } + query = nq + } + + if query == nil { + query = &badgerhold.Query{} } query = query.SortBy(req.SortByField) @@ -132,28 +140,21 @@ func (app *appContext) GetActivities(gc *gin.Context) { resp.LastPage = len(results) != req.Limit for i, act := range results { resp.Activities[i] = ActivityDTO{ - ID: act.ID, - Type: activityTypeToString(act.Type), - UserID: act.UserID, - SourceType: activitySourceToString(act.SourceType), - Source: act.Source, - InviteCode: act.InviteCode, - Value: act.Value, - Time: act.Time.Unix(), - IP: act.IP, + ID: act.ID, + Type: activityTypeToString(act.Type), + UserID: act.UserID, + SourceType: activitySourceToString(act.SourceType), + Source: act.Source, + InviteCode: act.InviteCode, + Value: act.Value, + Time: act.Time.Unix(), + IP: act.IP, + Username: act.MustGetUsername(app.jf), + SourceUsername: act.MustGetSourceUsername(app.jf), } if act.Type == ActivityDeletion || act.Type == ActivityCreation { - resp.Activities[i].Username = act.Value + // Username would've been in here, clear it to avoid confusion to the consumer resp.Activities[i].Value = "" - } else if user, err := app.jf.UserByID(act.UserID, false); err == nil { - resp.Activities[i].Username = user.Name - } - - if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" { - user, err := app.jf.UserByID(act.Source, false) - if err == nil { - resp.Activities[i].SourceUsername = user.Name - } } } diff --git a/ts/modules/search.ts b/ts/modules/search.ts index 4c77717..0370ca0 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -373,7 +373,8 @@ export class Search { this._c.search.oninput((null as Event)); }; } - } else if (queryFormat.string) { + } + if (queryFormat.string) { q = new StringQuery(queryFormat, split[1]); q.onclick = () => { @@ -383,7 +384,8 @@ export class Search { } this._c.search.oninput((null as Event)); } - } else if (queryFormat.date) { + } + if (queryFormat.date) { let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]); if (!isDate) continue; q = new DateQuery(queryFormat, op, parsedDate); diff --git a/usercache.go b/usercache.go index 5d52542..b9dc246 100644 --- a/usercache.go +++ b/usercache.go @@ -2,6 +2,7 @@ package main import ( "cmp" + "encoding/json" "fmt" "slices" "strings" @@ -241,13 +242,12 @@ type DateAttempt struct { Minute *int `json:"minute,omitempty"` } -// Compares a Unix timestamp. +// Compare roughly compares a time.Time to a DateAttempt. // We want to compare only the fields given in DateAttempt, // so we copy subjectDate and apply on those fields from this._value. -func (d DateAttempt) Compare(subject int64) int { - subjectTime := time.Unix(subject, 0) - yy, mo, dd := subjectTime.Date() - hh, mm, _ := subjectTime.Clock() +func (d DateAttempt) Compare(subject time.Time) int { + yy, mo, dd := subject.Date() + hh, mm, _ := subject.Clock() if d.Year != nil { yy = *d.Year } @@ -264,7 +264,13 @@ func (d DateAttempt) Compare(subject int64) int { if d.Minute != nil { mm = *d.Minute } - return subjectTime.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, nil)) + // FIXME: Transmit timezone in request maybe? + return subject.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, time.UTC)) +} + +// CompareUnix roughly compares a unix timestamp to a DateAttempt. +func (d DateAttempt) CompareUnix(subject int64) int { + return d.Compare(time.Unix(subject, 0)) } // Filter returns true if a specific field in the passed respUser matches some internally defined value. @@ -301,7 +307,7 @@ func (q QueryDTO) AsFilter() Filter { } case "last_active": return func(a *respUser) bool { - return q.Value.(DateAttempt).Compare(a.LastActive) == int(operator) + return q.Value.(DateAttempt).CompareUnix(a.LastActive) == int(operator) } case "admin": return func(a *respUser) bool { @@ -309,7 +315,7 @@ func (q QueryDTO) AsFilter() Filter { } case "expiry": return func(a *respUser) bool { - return q.Value.(DateAttempt).Compare(a.Expiry) == int(operator) + return q.Value.(DateAttempt).CompareUnix(a.Expiry) == int(operator) } case "disabled": return func(a *respUser) bool { @@ -397,6 +403,30 @@ type QueryDTO struct { Value any `json:"value"` } +// UnmarshalJSON allows unmarshaling QueryDTO.Value into a DateAttempt type, rather than just a map. +func (q *QueryDTO) UnmarshalJSON(data []byte) error { + type _QueryDTO QueryDTO + var temp _QueryDTO + if err := json.Unmarshal(data, &temp); err != nil { + return err + } + *q = QueryDTO(temp) + switch q.Value.(type) { + case string: + case bool: + return nil + case map[string]any: + var do struct { + Value DateAttempt `json:"value"` + } + if err := json.Unmarshal(data, &do); err != nil { + return err + } + q.Value = do.Value + } + return nil +} + // ServerSearchReqDTO is a usual PaginatedReqDTO with added fields for searching and filtering. type ServerSearchReqDTO struct { PaginatedReqDTO