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).
This commit is contained in:
Harvey Tindall
2025-05-26 15:09:40 +01:00
parent 6fff8a887e
commit c922dc5b50
4 changed files with 188 additions and 44 deletions

View File

@@ -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:<fieldname>" 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
}

View File

@@ -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
}
}
}

View File

@@ -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);

View File

@@ -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