Files
jfa-go/usercache.go
Harvey Tindall 6ebc7d18bf accounts: fix bool queries on (some) string fields
wasn't implemented for things like email on the server side.
also changed text mail variant's footers to all use {{ .footer }} like I
should have before.
2025-09-01 20:44:19 +01:00

602 lines
17 KiB
Go

package main
import (
"cmp"
"encoding/json"
"fmt"
"slices"
"strings"
"sync"
"time"
)
const (
USER_DEFAULT_SORT_FIELD = "name"
USER_DEFAULT_SORT_ASCENDING = true
)
func (app *appContext) InvalidateUserCaches() {
app.InvalidateJellyfinCache()
app.InvalidateWebUserCache()
}
func (app *appContext) InvalidateJellyfinCache() {
app.jf.CacheExpiry = time.Now()
}
func (app *appContext) InvalidateWebUserCache() {
app.userCache.LastSync = time.Time{}
}
// UserCache caches the transport representation of users,
// complementing the built-in cache of the mediabrowser package.
// Synchronisation runs in the background and consumers receive
// old data for responsiveness unless an extended expiry time has passed.
// It also provides methods for sorting, searching and filtering server-side.
type UserCache struct {
Cache []respUser
Ref []*respUser
Sorted bool
LastSync time.Time
// After cache is this old, re-sync, but do it in the background and return the old cache.
SyncTimeout time.Duration
// After cache is this old, re-sync and wait for it and return the new cache.
WaitForSyncTimeout time.Duration
SyncLock sync.Mutex
Syncing bool
SortLock sync.Mutex
Sorting bool
}
func NewUserCache(syncTimeout, waitForSyncTimeout time.Duration) *UserCache {
return &UserCache{
SyncTimeout: syncTimeout,
WaitForSyncTimeout: waitForSyncTimeout,
}
}
// MaybeSync (maybe) syncs the cache, resulting in updated UserCache.Cache/.Ref/.Sorted.
// Only syncs if c.SyncTimeout duration has passed since last one.
// If c.WaitForSyncTimeout duration has passed, this will block until a sync is complete, otherwise it will sync in the background
// (expecting you to use the old cache data). Only one sync will run at a time.
func (c *UserCache) MaybeSync(app *appContext) error {
shouldWaitForSync := time.Now().After(c.LastSync.Add(c.WaitForSyncTimeout)) || c.Ref == nil || len(c.Ref) == 0
shouldSync := time.Now().After(c.LastSync.Add(c.SyncTimeout))
if !shouldSync {
return nil
}
syncStatus := make(chan error)
go func(status chan error, c *UserCache) {
c.SyncLock.Lock()
alreadySyncing := c.Syncing
// We're either already syncing or will be
c.Syncing = true
c.SyncLock.Unlock()
if !alreadySyncing {
users, err := app.jf.GetUsers(false)
if err != nil {
c.SyncLock.Lock()
c.Syncing = false
c.SyncLock.Unlock()
status <- err
return
}
cache := make([]respUser, len(users))
for i, jfUser := range users {
cache[i] = app.userSummary(jfUser)
}
ref := make([]*respUser, len(cache))
for i := range cache {
ref[i] = &(cache[i])
}
c.Cache = cache
c.Ref = ref
c.Sorted = false
c.LastSync = time.Now()
c.SyncLock.Lock()
c.Syncing = false
c.SyncLock.Unlock()
} else {
for c.Syncing {
continue
}
}
status <- nil
}(syncStatus, c)
if shouldWaitForSync {
err := <-syncStatus
return err
}
return nil
}
func (c *UserCache) GetUserDTOs(app *appContext, sorted bool) ([]*respUser, error) {
if err := c.MaybeSync(app); err != nil {
return nil, err
}
if sorted && !c.Sorted {
c.SortLock.Lock()
alreadySorting := c.Sorting
c.Sorting = true
c.SortLock.Unlock()
if !alreadySorting {
c.Sort(c.Ref, USER_DEFAULT_SORT_FIELD, USER_DEFAULT_SORT_ASCENDING)
c.Sorted = true
c.SortLock.Lock()
c.Sorting = false
c.SortLock.Unlock()
} else {
for c.Sorting {
continue
}
}
}
return c.Ref, nil
}
// instead of making a Less for bools, just convert them to integers
// https://0x0f.me/blog/golang-compiler-optimization/
func bool2int(b bool) int {
var i int
if b {
i = 1
} else {
i = 0
}
return i
}
// Sorter compares the given field of two respUsers, returning -1 if a < b, 0 if a == b, 1 if a > b.
type Sorter func(a, b *respUser) int
// Allow sorting by respUser's struct fields (well, it's JSON-representation's fields)
// SortUsersBy returns a Sorter function, which compares the given field of two respUsers, returning -1 if a < b, 0 if a == b, 1 if a > b.
func SortUsersBy(field string) Sorter {
switch field {
case "id":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID))
}
case "name":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
}
case "email":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(b.Email))
}
case "notify_email":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(b.NotifyThroughEmail))
}
case "last_active":
return func(a, b *respUser) int {
return cmp.Compare(a.LastActive, b.LastActive)
}
case "admin":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.Admin), bool2int(b.Admin))
}
case "expiry":
return func(a, b *respUser) int {
return cmp.Compare(a.Expiry, b.Expiry)
}
case "disabled":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.Disabled), bool2int(b.Disabled))
}
case "telegram":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(b.Telegram))
}
case "notify_telegram":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(b.NotifyThroughTelegram))
}
case "discord":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(b.Discord))
}
case "discord_id":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(b.DiscordID))
}
case "notify_discord":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(b.NotifyThroughDiscord))
}
case "matrix":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(b.Matrix))
}
case "notify_matrix":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(b.NotifyThroughMatrix))
}
case "label":
return func(a, b *respUser) int {
return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(b.Label))
}
case "accounts_admin":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(b.AccountsAdmin))
}
case "referrals_enabled":
return func(a, b *respUser) int {
return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(b.ReferralsEnabled))
}
}
panic(fmt.Errorf("got invalid field %s", field))
}
type CompareResult int
const (
Lesser CompareResult = -1
Equal CompareResult = 0
Greater CompareResult = 1
)
// One day i'll figure out Go generics
/*type FilterValue interface {
bool | string | DateAttempt
}*/
type DateAttempt struct {
Year *int `json:"year,omitempty"`
Month *int `json:"month,omitempty"`
Day *int `json:"day,omitempty"`
Hour *int `json:"hour,omitempty"`
Minute *int `json:"minute,omitempty"`
OffsetMinutesFromUTC *int `json:"offsetMinutesFromUTC,omitempty"`
}
// CompareWithOperator roughly compares a time.Time to a DateAttempt according to the given operator.
// **Considers zero-dates as invalid!** (i.e. any comparison to a subject.IsZero() will be false).
func (d DateAttempt) CompareWithOperator(subject time.Time, operator CompareResult) bool {
if subject.IsZero() {
return false
}
return d.Compare(subject) == int(operator)
}
// CompareUnixWithOperator roughly compares a unix timestamp to a DateAttempt according to the given operator.
// **Considers zero-dates as invalid!** (i.e. any comparison to a time.Unix(subject, 0).IsZero() or (subject == 0) will be false).
func (d DateAttempt) CompareUnixWithOperator(subject int64, operator CompareResult) bool {
if subject == 0 {
return false
}
subjectTime := time.Unix(subject, 0)
if subjectTime.IsZero() {
return false
}
return d.Compare(subjectTime) == int(operator)
}
// 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 time.Time) int {
// Remove anything more precise than a second
subject = subject.Truncate(time.Minute)
yy, mo, dd := subject.Date()
hh, mm, _ := subject.Clock()
if d.Year != nil {
yy = *d.Year
}
if d.Month != nil {
// Month in Javascript is zero-based, so we need to increment it
mo = time.Month((*d.Month) + 1)
}
if d.Day != nil {
dd = *d.Day
}
if d.Hour != nil {
hh = *d.Hour
}
if d.Minute != nil {
mm = *d.Minute
}
location := time.UTC
if d.OffsetMinutesFromUTC != nil {
location = time.FixedZone("", 60*(*d.OffsetMinutesFromUTC))
}
// FIXME: Transmit timezone in request maybe?
daAsTime := time.Date(yy, mo, dd, hh, mm, 0, 0, location)
comp := subject.Compare(daAsTime)
return comp
}
// 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.
type Filter func(*respUser) bool
// AsFilter returns a Filter function, which compares the queries value to the corresponding field's value in a passed respUser.
func (q QueryDTO) AsFilter() Filter {
operator := Equal
switch q.Operator {
case LesserOperator:
operator = Lesser
case EqualOperator:
operator = Equal
case GreaterOperator:
operator = Greater
}
switch q.Field {
case "id":
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(q.Value.(string))) == int(operator)
}
case "name":
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(q.Value.(string))) == int(operator)
}
case "email":
switch q.Class {
case BoolQuery:
return func(a *respUser) bool {
if q.Value.(bool) {
return a.Email != ""
}
return a.Email == ""
}
case StringQuery:
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(q.Value.(string))) == int(operator)
}
}
case "notify_email":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(q.Value.(bool))) == int(operator)
}
case "last_active":
switch q.Class {
case DateQuery:
return func(a *respUser) bool {
return q.Value.(DateAttempt).CompareUnixWithOperator(a.LastActive, operator)
}
case BoolQuery:
return func(a *respUser) bool {
val := a.LastActive != 0
if q.Value.(bool) == false {
val = !val
}
return val
}
}
case "admin":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.Admin), bool2int(q.Value.(bool))) == int(operator)
}
case "expiry":
switch q.Class {
case DateQuery:
return func(a *respUser) bool {
return q.Value.(DateAttempt).CompareUnixWithOperator(a.Expiry, operator)
}
case BoolQuery:
return func(a *respUser) bool {
val := a.Expiry != 0
if q.Value.(bool) == false {
val = !val
}
return val
}
}
case "disabled":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.Disabled), bool2int(q.Value.(bool))) == int(operator)
}
case "telegram":
switch q.Class {
case BoolQuery:
return func(a *respUser) bool {
if q.Value.(bool) {
return a.Telegram != ""
}
return a.Telegram == ""
}
case StringQuery:
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(q.Value.(string))) == int(operator)
}
}
case "notify_telegram":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(q.Value.(bool))) == int(operator)
}
case "discord":
switch q.Class {
case BoolQuery:
return func(a *respUser) bool {
if q.Value.(bool) {
return a.Discord != ""
}
return a.Discord == ""
}
case StringQuery:
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(q.Value.(string))) == int(operator)
}
}
case "discord_id":
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(q.Value.(string))) == int(operator)
}
case "notify_discord":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(q.Value.(bool))) == int(operator)
}
case "matrix":
switch q.Class {
case BoolQuery:
return func(a *respUser) bool {
if q.Value.(bool) {
return a.Matrix != ""
}
return a.Matrix == ""
}
case StringQuery:
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(q.Value.(string))) == int(operator)
}
}
case "notify_matrix":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(q.Value.(bool))) == int(operator)
}
case "label":
switch q.Class {
case BoolQuery:
return func(a *respUser) bool {
if q.Value.(bool) {
return a.Label != ""
}
return a.Label == ""
}
case StringQuery:
return func(a *respUser) bool {
return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(q.Value.(string))) == int(operator)
}
}
case "accounts_admin":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(q.Value.(bool))) == int(operator)
}
case "referrals_enabled":
return func(a *respUser) bool {
return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(q.Value.(bool))) == int(operator)
}
}
panic(fmt.Errorf("got invalid q.Field %s", q.Field))
}
// MatchesSearch checks (case-insensitively) if any string field in respUser includes the term string.
func (ru *respUser) MatchesSearch(term string) bool {
return (strings.Contains(ru.ID, term) ||
strings.Contains(strings.ToLower(ru.Name), term) ||
strings.Contains(strings.ToLower(ru.Label), term) ||
strings.Contains(strings.ToLower(ru.Email), term) ||
strings.Contains(strings.ToLower(ru.Discord), term) ||
strings.Contains(strings.ToLower(ru.Matrix), term) ||
strings.Contains(strings.ToLower(ru.Telegram), term))
}
// QueryClass is the class of a query (the datatype), i.e. bool, string or date.
type QueryClass string
const (
BoolQuery QueryClass = "bool"
StringQuery QueryClass = "string"
DateQuery QueryClass = "date"
)
// QueryOperator is the operator used for comparison in a filter, i.e. <, = or >.
type QueryOperator string
const (
LesserOperator QueryOperator = "<"
EqualOperator QueryOperator = "="
GreaterOperator QueryOperator = ">"
)
// QueryDTO is the transport representation of a Query, sent from the web app.
type QueryDTO struct {
Class QueryClass `json:"class"`
Field string `json:"field"`
Operator QueryOperator `json:"operator"`
// string | bool | DateAttempt
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
ServerFilterReqDTO
}
// ServerFilterReqDTO provides search terms and queries to a search or count route.
type ServerFilterReqDTO struct {
SearchTerms []string `json:"searchTerms"`
Queries []QueryDTO `json:"queries"`
}
// Filter reduces the passed slice of *respUsers
// by searching for each term of terms[] with respUser.MatchesSearch,
// and by evaluating Queries with Query.AsFilter().
func (c *UserCache) Filter(users []*respUser, terms []string, queries []QueryDTO) []*respUser {
filters := make([]Filter, len(queries))
for i, q := range queries {
filters[i] = q.AsFilter()
}
// FIXME: Properly consider pre-allocation size
out := make([]*respUser, 0, len(users)/4)
for i := range users {
match := true
for _, term := range terms {
if !users[i].MatchesSearch(term) {
match = false
break
}
}
if !match {
continue
}
for _, filter := range filters {
if filter == nil || !filter(users[i]) {
match = false
break
}
}
if match {
out = append(out, users[i])
}
}
return out
}
// Sort sorts the given slice of of *respUsers in-place by the field name given, in ascending or descending order.
func (c *UserCache) Sort(users []*respUser, field string, ascending bool) {
slices.SortFunc(users, SortUsersBy(field))
if !ascending {
slices.Reverse(users)
}
}