From 6fff8a887e23d2c93b154040ba4795fe8fc47ecd Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 23 May 2025 16:37:42 +0100 Subject: [PATCH] activity: basic query support only supports queries of fields actually in Activity. The web UI only directly queries two of these, ID and Time (mistakenly referneced as date in the web ui previously). Later commits will come up with creative ways of dealing with all the other query types. --- activitysort.go | 137 +++++++++++++++++++++++++++++++++++++++++ api-activities.go | 85 +++++++++++++------------ api-users.go | 4 +- models.go | 10 --- ts/modules/activity.ts | 4 +- 5 files changed, 185 insertions(+), 55 deletions(-) create mode 100644 activitysort.go diff --git a/activitysort.go b/activitysort.go new file mode 100644 index 0000000..7cd8af9 --- /dev/null +++ b/activitysort.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/timshannon/badgerhold/v4" +) + +const ( + ACTIVITY_DEFAULT_SORT_FIELD = "Time" + // This will be default anyway, as the default value of a bool field is false. + // ACTIVITY_DEFAULT_SORT_ASCENDING = false +) + +func activityDTONameToField(field string) string { + // Only "ID" and "Time" of these are actually searched by the UI. + // We support the rest though for other consumers of the API. + switch field { + case "id": + return "ID" + case "type": + return "Type" + case "user_id": + return "UserID" + case "username": + return "Username" + case "source_type": + return "SourceType" + case "source": + return "Source" + case "source_username": + return "SourceUsername" + case "invite_code": + return "InviteCode" + case "value": + return "Value" + case "time": + return "Time" + case "ip": + return "IP" + } + return "unknown" + // Only these query types actually search the ActivityDTO data. +} + +func activityTypeGetterNameToType(getter string) ActivityType { + switch getter { + case "accountCreation": + return ActivityCreation + case "accountDeletion": + return ActivityDeletion + case "accountDisabled": + return ActivityDisabled + case "accountEnabled": + return ActivityEnabled + case "contactLinked": + return ActivityContactLinked + case "contactUnlinked": + return ActivityContactUnlinked + case "passwordChange": + return ActivityChangePassword + case "passwordReset": + return ActivityResetPassword + case "inviteCreated": + return ActivityCreateInvite + case "inviteDeleted": + return ActivityDeleteInvite + } + return ActivityUnknown +} + +// 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") + if q.Operator != EqualOperator { + panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator)) + } + if q.Value.(bool) == true { + query = criterion.Eq(activityType) + } else { + query = criterion.Ne(activityType) + } + return query + } + + fieldName := activityDTONameToField(q.Field) + if fieldName == "unknown" { + panic("FIXME: Support all the weird queries of the web UI!") + } + criterion := query.And(fieldName) + + // FIXME: Deal with dates like we do in usercache.go + switch q.Operator { + case LesserOperator: + query = criterion.Lt(q.Value) + case EqualOperator: + query = criterion.Eq(q.Value) + case GreaterOperator: + query = criterion.Gt(q.Value) + } + return 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{} + // 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"} { + 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) { + field := ra.Field() + // _, ok := field.(string) + // if !ok { + // return false, fmt.Errorf("field not string: %s", fieldName) + // } + lower := strings.ToLower(field.(string)) + for _, term := range terms { + if strings.Contains(lower, term) { + return true, nil + } + } + return false, nil + })) + } + + return subQuery +} diff --git a/api-activities.go b/api-activities.go index d121aa4..eda15a4 100644 --- a/api-activities.go +++ b/api-activities.go @@ -6,32 +6,6 @@ import ( "github.com/timshannon/badgerhold/v4" ) -func stringToActivityType(v string) ActivityType { - switch v { - case "creation": - return ActivityCreation - case "deletion": - return ActivityDeletion - case "disabled": - return ActivityDisabled - case "enabled": - return ActivityEnabled - case "contactLinked": - return ActivityContactLinked - case "contactUnlinked": - return ActivityContactUnlinked - case "changePassword": - return ActivityChangePassword - case "resetPassword": - return ActivityResetPassword - case "createInvite": - return ActivityCreateInvite - case "deleteInvite": - return ActivityDeleteInvite - } - return ActivityUnknown -} - func activityTypeToString(v ActivityType) string { switch v { case ActivityCreation: @@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string { return "unknown" } +func stringToActivityType(v string) ActivityType { + switch v { + case "creation": + return ActivityCreation + case "deletion": + return ActivityDeletion + case "disabled": + return ActivityDisabled + case "enabled": + return ActivityEnabled + case "contactLinked": + return ActivityContactLinked + case "contactUnlinked": + return ActivityContactUnlinked + case "changePassword": + return ActivityChangePassword + case "resetPassword": + return ActivityResetPassword + case "createInvite": + return ActivityCreateInvite + case "deleteInvite": + return ActivityDeleteInvite + } + return ActivityUnknown +} + func stringToActivitySource(v string) ActivitySource { switch v { case "user": @@ -88,33 +88,36 @@ func activitySourceToString(v ActivitySource) string { // @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET. // @Produce json -// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters" +// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters" // @Success 200 {object} GetActivitiesRespDTO // @Router /activity [post] // @Security Bearer // @tags Activity func (app *appContext) GetActivities(gc *gin.Context) { - req := GetActivitiesDTO{} + req := ServerSearchReqDTO{} gc.BindJSON(&req) - query := &badgerhold.Query{} - activityTypes := make([]any, len(req.Type)) - for i, v := range req.Type { - activityTypes[i] = stringToActivityType(v) - } - if len(activityTypes) != 0 { - query = badgerhold.Where("Type").In(activityTypes...) + if req.SortByField == "" { + req.SortByField = USER_DEFAULT_SORT_FIELD + } else { + req.SortByField = activityDTONameToField(req.SortByField) } + var query *badgerhold.Query + if len(req.SearchTerms) != 0 { + query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms) + } else { + query = &badgerhold.Query{} + } + + for _, q := range req.Queries { + query = q.AsDBQuery(query) + } + + query = query.SortBy(req.SortByField) if !req.Ascending { query = query.Reverse() } - query = query.SortBy("Time") - - if req.Limit == 0 { - req.Limit = 10 - } - query = query.Skip(req.Page * req.Limit).Limit(req.Limit) var results []Activity diff --git a/api-users.go b/api-users.go index 0ab670d..c061d01 100644 --- a/api-users.go +++ b/api-users.go @@ -936,14 +936,14 @@ func (app *appContext) GetUsers(gc *gin.Context) { // @Summary Get a paginated, searchable list of Jellyfin users. // @Produce json -// @Param getUsersReqDTO body getUsersReqDTO true "search / pagination parameters" +// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters" // @Success 200 {object} getUsersDTO // @Failure 500 {object} stringResponse // @Router /users [post] // @Security Bearer // @tags Users func (app *appContext) SearchUsers(gc *gin.Context) { - req := getUsersReqDTO{} + req := ServerSearchReqDTO{} gc.BindJSON(&req) if req.SortByField == "" { req.SortByField = USER_DEFAULT_SORT_FIELD diff --git a/models.go b/models.go index 7e6b31c..144bfcf 100644 --- a/models.go +++ b/models.go @@ -175,10 +175,6 @@ type PaginatedReqDTO struct { Ascending bool `json:"ascending"` } -type getUsersReqDTO struct { - ServerSearchReqDTO -} - type getUsersDTO struct { UserList []*respUser `json:"users"` LastPage bool `json:"last_page"` @@ -445,12 +441,6 @@ type ActivityDTO struct { IP string `json:"ip"` } -type GetActivitiesDTO struct { - // "SortByField" ignores, it's always time. - PaginatedReqDTO - Type []string `json:"type"` // Types of activity to get. Leave blank for all. -} - type GetActivitiesRespDTO struct { PaginatedDTO Activities []ActivityDTO `json:"activities"` diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 26d8a93..7716f8c 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -74,9 +74,9 @@ const queries = (): { [field: string]: QueryType } => { return { string: true, date: false }, - "date": { + "time": { name: window.lang.strings("date"), - getter: "date", + getter: "time", bool: false, string: false, date: true