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:
+124
-13
@@ -3,7 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hrfee/mediabrowser"
|
||||||
"github.com/timshannon/badgerhold/v4"
|
"github.com/timshannon/badgerhold/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,17 +72,22 @@ func activityTypeGetterNameToType(getter string) ActivityType {
|
|||||||
return ActivityUnknown
|
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".
|
// AsDBQuery returns a mutated "query" filtering for the conditions in "q".
|
||||||
func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
||||||
if query == nil {
|
|
||||||
query = &badgerhold.Query{}
|
|
||||||
}
|
|
||||||
// Special case for activity type:
|
// Special case for activity type:
|
||||||
// In the app, there isn't an "activity:<fieldname>" query, but rather "<~fieldname>:true/false" queries.
|
// 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.
|
// For other API consumers, we also handle the former later.
|
||||||
activityType := activityTypeGetterNameToType(q.Field)
|
activityType := activityTypeGetterNameToType(q.Field)
|
||||||
if activityType != ActivityUnknown {
|
if activityType != ActivityUnknown {
|
||||||
criterion := query.And("Type")
|
criterion := andField(query, "Type")
|
||||||
if q.Operator != EqualOperator {
|
if q.Operator != EqualOperator {
|
||||||
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
|
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)
|
fieldName := activityDTONameToField(q.Field)
|
||||||
if fieldName == "unknown" {
|
// Fail if unrecognized, or recognized as time (we handle this with DateAttempt.Compare separately).
|
||||||
panic("FIXME: Support all the weird queries of the web UI!")
|
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 {
|
switch q.Operator {
|
||||||
case LesserOperator:
|
case LesserOperator:
|
||||||
query = criterion.Lt(q.Value)
|
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.
|
// 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 {
|
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.
|
// 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)
|
criterion := badgerhold.Where(fieldName)
|
||||||
// No case-insentive Contains method, so we use MatchFunc instead
|
// 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()
|
field := ra.Field()
|
||||||
// _, ok := field.(string)
|
// _, ok := field.(string)
|
||||||
// if !ok {
|
// if !ok {
|
||||||
@@ -130,8 +139,110 @@ func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-21
@@ -106,11 +106,19 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
|||||||
if len(req.SearchTerms) != 0 {
|
if len(req.SearchTerms) != 0 {
|
||||||
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
|
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
|
||||||
} else {
|
} else {
|
||||||
query = &badgerhold.Query{}
|
query = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, q := range req.Queries {
|
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)
|
query = query.SortBy(req.SortByField)
|
||||||
@@ -132,28 +140,21 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
|||||||
resp.LastPage = len(results) != req.Limit
|
resp.LastPage = len(results) != req.Limit
|
||||||
for i, act := range results {
|
for i, act := range results {
|
||||||
resp.Activities[i] = ActivityDTO{
|
resp.Activities[i] = ActivityDTO{
|
||||||
ID: act.ID,
|
ID: act.ID,
|
||||||
Type: activityTypeToString(act.Type),
|
Type: activityTypeToString(act.Type),
|
||||||
UserID: act.UserID,
|
UserID: act.UserID,
|
||||||
SourceType: activitySourceToString(act.SourceType),
|
SourceType: activitySourceToString(act.SourceType),
|
||||||
Source: act.Source,
|
Source: act.Source,
|
||||||
InviteCode: act.InviteCode,
|
InviteCode: act.InviteCode,
|
||||||
Value: act.Value,
|
Value: act.Value,
|
||||||
Time: act.Time.Unix(),
|
Time: act.Time.Unix(),
|
||||||
IP: act.IP,
|
IP: act.IP,
|
||||||
|
Username: act.MustGetUsername(app.jf),
|
||||||
|
SourceUsername: act.MustGetSourceUsername(app.jf),
|
||||||
}
|
}
|
||||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
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 = ""
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,8 @@ export class Search {
|
|||||||
this._c.search.oninput((null as Event));
|
this._c.search.oninput((null as Event));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (queryFormat.string) {
|
}
|
||||||
|
if (queryFormat.string) {
|
||||||
q = new StringQuery(queryFormat, split[1]);
|
q = new StringQuery(queryFormat, split[1]);
|
||||||
|
|
||||||
q.onclick = () => {
|
q.onclick = () => {
|
||||||
@@ -383,7 +384,8 @@ export class Search {
|
|||||||
}
|
}
|
||||||
this._c.search.oninput((null as Event));
|
this._c.search.oninput((null as Event));
|
||||||
}
|
}
|
||||||
} else if (queryFormat.date) {
|
}
|
||||||
|
if (queryFormat.date) {
|
||||||
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
||||||
if (!isDate) continue;
|
if (!isDate) continue;
|
||||||
q = new DateQuery(queryFormat, op, parsedDate);
|
q = new DateQuery(queryFormat, op, parsedDate);
|
||||||
|
|||||||
+38
-8
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -241,13 +242,12 @@ type DateAttempt struct {
|
|||||||
Minute *int `json:"minute,omitempty"`
|
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,
|
// We want to compare only the fields given in DateAttempt,
|
||||||
// so we copy subjectDate and apply on those fields from this._value.
|
// so we copy subjectDate and apply on those fields from this._value.
|
||||||
func (d DateAttempt) Compare(subject int64) int {
|
func (d DateAttempt) Compare(subject time.Time) int {
|
||||||
subjectTime := time.Unix(subject, 0)
|
yy, mo, dd := subject.Date()
|
||||||
yy, mo, dd := subjectTime.Date()
|
hh, mm, _ := subject.Clock()
|
||||||
hh, mm, _ := subjectTime.Clock()
|
|
||||||
if d.Year != nil {
|
if d.Year != nil {
|
||||||
yy = *d.Year
|
yy = *d.Year
|
||||||
}
|
}
|
||||||
@@ -264,7 +264,13 @@ func (d DateAttempt) Compare(subject int64) int {
|
|||||||
if d.Minute != nil {
|
if d.Minute != nil {
|
||||||
mm = *d.Minute
|
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.
|
// 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":
|
case "last_active":
|
||||||
return func(a *respUser) bool {
|
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":
|
case "admin":
|
||||||
return func(a *respUser) bool {
|
return func(a *respUser) bool {
|
||||||
@@ -309,7 +315,7 @@ func (q QueryDTO) AsFilter() Filter {
|
|||||||
}
|
}
|
||||||
case "expiry":
|
case "expiry":
|
||||||
return func(a *respUser) bool {
|
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":
|
case "disabled":
|
||||||
return func(a *respUser) bool {
|
return func(a *respUser) bool {
|
||||||
@@ -397,6 +403,30 @@ type QueryDTO struct {
|
|||||||
Value any `json:"value"`
|
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.
|
// ServerSearchReqDTO is a usual PaginatedReqDTO with added fields for searching and filtering.
|
||||||
type ServerSearchReqDTO struct {
|
type ServerSearchReqDTO struct {
|
||||||
PaginatedReqDTO
|
PaginatedReqDTO
|
||||||
|
|||||||
Reference in New Issue
Block a user