diff --git a/api-activities.go b/api-activities.go index f4b2581..5ff3b76 100644 --- a/api-activities.go +++ b/api-activities.go @@ -126,8 +126,8 @@ func (app *appContext) GetActivities(gc *gin.Context) { resp := GetActivitiesRespDTO{ Activities: make([]ActivityDTO, len(results)), - LastPage: len(results) != req.Limit, } + resp.LastPage = len(results) != req.Limit for i, act := range results { resp.Activities[i] = ActivityDTO{ @@ -173,12 +173,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) { // @Summary Returns the total number of activities stored in the database. // @Produce json -// @Success 200 {object} GetActivityCountDTO +// @Success 200 {object} PageCountDTO // @Router /activity/count [get] // @Security Bearer // @tags Activity func (app *appContext) GetActivityCount(gc *gin.Context) { - resp := GetActivityCountDTO{} + resp := PageCountDTO{} var err error resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{}) if err != nil { diff --git a/api-users.go b/api-users.go index 849bdfe..60934b1 100644 --- a/api-users.go +++ b/api-users.go @@ -337,8 +337,8 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey // FIXME: figure these out in a nicer way? this relies on the current ordering, // which may not be fixed. if discordEnabled { - if req.completeContactMethods[0].User != nil { - discordUser = req.completeContactMethods[0].User.(*DiscordUser) + if req.completeContactMethods[0].User != nil { + discordUser = req.completeContactMethods[0].User.(*DiscordUser) } if telegramEnabled && req.completeContactMethods[1].User != nil { telegramUser = req.completeContactMethods[1].User.(*TelegramUser) @@ -894,7 +894,25 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { } -// @Summary Get a list of Jellyfin users. +// @Summary Returns the total number of Jellyfin users. +// @Produce json +// @Success 200 {object} PageCountDTO +// @Router /users/count [get] +// @Security Bearer +// @tags Activity +func (app *appContext) GetUserCount(gc *gin.Context) { + resp := PageCountDTO{} + err := app.userCache.Gen(app) + if err != nil { + app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) + respond(500, "Couldn't get users", gc) + return + } + resp.Count = uint64(len(app.userCache.Cache)) + gc.JSON(200, resp) +} + +// @Summary Get a list of -all- Jellyfin users. // @Produce json // @Success 200 {object} getUsersDTO // @Failure 500 {object} stringResponse @@ -903,19 +921,43 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { // @tags Users func (app *appContext) GetUsers(gc *gin.Context) { var resp getUsersDTO - users, err := app.jf.GetUsers(false) - resp.UserList = make([]respUser, len(users)) + // We're sending all users, so this is always true + resp.LastPage = true + err := app.userCache.Gen(app) if err != nil { app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) respond(500, "Couldn't get users", gc) return } - i := 0 - for _, jfUser := range users { - user := app.userSummary(jfUser) - resp.UserList[i] = user - i++ + resp.UserList = app.userCache.Cache + gc.JSON(200, resp) +} + +// @Summary Get a paginated, searchable list of Jellyfin users. +// @Produce json +// @Param getUsersReqDTO body getUsersReqDTO 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{} + gc.BindJSON(&req) + + // FIXME: Figure out how to search, sort and paginate []mediabrowser.User! + // Expr! + + var resp getUsersDTO + // We're sending all users, so this is always true + resp.LastPage = true + err := app.userCache.Gen(app) + if err != nil { + app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) + respond(500, "Couldn't get users", gc) + return } + resp.UserList = app.userCache.Cache gc.JSON(200, resp) } diff --git a/go.mod b/go.mod index 6b17a34..eb0fcb0 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgraph-io/ristretto v1.0.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/expr-lang/expr v1.17.3 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/errors v1.0.4 // indirect diff --git a/go.sum b/go.sum index c9c85b6..a12698d 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/expr-lang/expr v1.17.3 h1:myeTTuDFz7k6eFe/JPlep/UsiIjVhG61FMHFu63U7j0= +github.com/expr-lang/expr v1.17.3/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= diff --git a/html/admin.html b/html/admin.html index 25630bd..72953a0 100644 --- a/html/admin.html +++ b/html/admin.html @@ -738,6 +738,7 @@ +
{{ .strings.actions }}
{{ .quantityStrings.addUser.Singular }} @@ -838,11 +839,7 @@
-
- - - -
+
diff --git a/main.go b/main.go index 7d1de5a..cb6d2c4 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,7 @@ type appContext struct { pwrCaptchas map[string]Captcha ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request confirmationKeysLock sync.Mutex + userCache UserCache } func generateSecret(length int) (string, error) { diff --git a/models.go b/models.go index 686effd..360a2e2 100644 --- a/models.go +++ b/models.go @@ -164,8 +164,18 @@ type respUser struct { ReferralsEnabled bool `json:"referrals_enabled"` } +type PaginatedDTO struct { + LastPage bool `json:"last_page"` +} + +type getUsersReqDTO struct { + Limit int `json:"limit"` + Page int `json:"page"` // zero-indexed +} + type getUsersDTO struct { UserList []respUser `json:"users"` + LastPage bool `json:"last_page"` } type ombiUser struct { @@ -437,11 +447,11 @@ type GetActivitiesDTO struct { } type GetActivitiesRespDTO struct { + PaginatedDTO Activities []ActivityDTO `json:"activities"` - LastPage bool `json:"last_page"` } -type GetActivityCountDTO struct { +type PageCountDTO struct { Count uint64 `json:"count"` } diff --git a/router.go b/router.go index 9ab3530..4fa3952 100644 --- a/router.go +++ b/router.go @@ -183,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.POST(p+"/logout", app.Logout) api.DELETE(p+"/users", app.DeleteUsers) api.GET(p+"/users", app.GetUsers) + api.GET(p+"/users/count", app.GetUserCount) + api.POST(p+"/users", app.SearchUsers) api.POST(p+"/user", app.NewUserFromAdmin) api.POST(p+"/users/extend", app.ExtendExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) diff --git a/scripts/account-gen/main.go b/scripts/account-gen/main.go index bc1c528..47faa6f 100644 --- a/scripts/account-gen/main.go +++ b/scripts/account-gen/main.go @@ -15,11 +15,11 @@ import ( var ( names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab"} + COUNT = 3000 ) const ( PASSWORD = "test" - COUNT = 10 ) func main() { @@ -57,6 +57,12 @@ func main() { password = strings.TrimSuffix(password, "\n") } + if countEnv := os.Getenv("COUNT"); countEnv != "" { + COUNT, _ = strconv.Atoi(countEnv) + } + + fmt.Printf("Will generate %d users\n", COUNT) + jf, err := mediabrowser.NewServer( mediabrowser.JellyfinServer, server, @@ -99,7 +105,7 @@ func main() { user, status, err := jf.NewUser(name, PASSWORD) if (status != 200 && status != 201 && status != 204) || err != nil { - log.Fatalf("Failed to create user \"%s\" (%d): %+v\n", name, status, err) + log.Fatalf("Acc no %d: Failed to create user \"%s\" (%d): %+v\n", i, name, status, err) } if rand.Intn(100) > 65 { @@ -112,7 +118,7 @@ func main() { status, err = jf.SetPolicy(user.ID, user.Policy) if (status != 200 && status != 201 && status != 204) || err != nil { - log.Fatalf("Failed to set policy for user \"%s\" (%d): %+v\n", name, status, err) + log.Fatalf("Acc no %d: Failed to set policy for user \"%s\" (%d): %+v\n", i, name, status, err) } if rand.Intn(100) > 20 { diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index c338bfc..2bc902d 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -5,6 +5,7 @@ import { stripMarkdown } from "../modules/stripmd.js"; import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; import { HiddenInputField } from "./ui.js"; +import { RecordCounter } from "./activity.js"; declare var window: GlobalWindow; @@ -702,7 +703,11 @@ class user implements User, SearchableItem { } this._row.remove(); } -} +} + +interface UsersDTO extends paginatedDTO { + users: User[]; +} export class accountsList { private _table = document.getElementById("accounts-list") as HTMLTableSectionElement; @@ -771,6 +776,8 @@ export class accountsList { private _filterArea = document.getElementById("accounts-filter-area"); private _searchOptionsHeader = document.getElementById("accounts-search-options-header"); + private _counter: RecordCounter; + // Whether the "Extend expiry" is extending or setting an expiry. private _settingExpiry = false; @@ -1779,6 +1786,9 @@ export class accountsList { constructor() { this._populateNumbers(); + + this._counter = new RecordCounter(document.getElementById("accounts-record-counter")); + this._users = {}; this._selectAll.checked = false; this._selectAll.onchange = () => { @@ -2035,12 +2045,16 @@ export class accountsList { } reload = (callback?: () => void) => { + this._counter.reset() + this._counter.getTotal("/users/count"); + _get("/users", null, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 200) { + let resp = req.response as UsersDTO; // same method as inviteList.reload() let accountsOnDOM: { [id: string]: boolean } = {}; for (let id in this._users) { accountsOnDOM[id] = true; } - for (let u of (req.response["users"] as User[])) { + for (let u of resp.users) { if (u.id in this._users) { this._users[u.id].update(u); delete accountsOnDOM[u.id]; @@ -2055,10 +2069,10 @@ export class accountsList { // console.log("reload, so sorting by", this._activeSortColumn); this._ordering = this._columns[this._activeSortColumn].sort(this._users); this._search.ordering = this._ordering; - if (!(this._search.inSearch)) { - this.setVisibility(this._ordering, true); - this._notFoundPanel.classList.add("unfocused"); - } else { + + this._counter.loaded = this._ordering.length; + + if (this._search.inSearch) { const results = this._search.search(this._searchBox.value); if (results.length == 0) { this._notFoundPanel.classList.remove("unfocused"); @@ -2066,6 +2080,10 @@ export class accountsList { this._notFoundPanel.classList.add("unfocused"); } this.setVisibility(results, true); + } else { + this._counter.shown = this._counter.loaded; + this.setVisibility(this._ordering, true); + this._notFoundPanel.classList.add("unfocused"); } this._checkCheckCount(); diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 135d50b..59ed865 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -346,9 +346,64 @@ export class Activity implements activity, SearchableItem { asElement = () => { return this._card; }; } -interface ActivitiesDTO { +export class RecordCounter { + private _container: HTMLElement; + private _totalRecords: HTMLElement; + private _loadedRecords: HTMLElement; + private _shownRecords: HTMLElement; + private _total: number; + private _loaded: number; + private _shown: number; + constructor(container: HTMLElement) { + this._container = container; + this._container.innerHTML = ` + + + + `; + this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement; + this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement; + this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement; + this.total = 0; + this.loaded = 0; + this.shown = 0; + } + + reset() { + this.total = 0; + this.loaded = 0; + this.shown = 0; + } + + // Sets the total using a PageCountDTO-returning API endpoint. + getTotal(endpoint: string) { + _get(endpoint, null, (req: XMLHttpRequest) => { + if (req.readyState != 4 || req.status != 200) return; + this.total = req.response["count"] as number; + }); + } + + get total(): number { return this._total; } + set total(v: number) { + this._total = v; + this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); + } + + get loaded(): number { return this._loaded; } + set loaded(v: number) { + this._loaded = v; + this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); + } + + get shown(): number { return this._shown; } + set shown(v: number) { + this._shown = v; + this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); + } +} + +interface ActivitiesDTO extends paginatedDTO { activities: activity[]; - last_page: boolean; } export class activityList { @@ -368,31 +423,7 @@ export class activityList { private _keepSearchingDescription = document.getElementById("activity-keep-searching-description"); private _keepSearchingButton = document.getElementById("activity-keep-searching"); - private _totalRecords = document.getElementById("activity-total-records"); - private _loadedRecords = document.getElementById("activity-loaded-records"); - private _shownRecords = document.getElementById("activity-shown-records"); - - private _total: number; - private _loaded: number; - private _shown: number; - - get total(): number { return this._total; } - set total(v: number) { - this._total = v; - this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); - } - - get loaded(): number { return this._loaded; } - set loaded(v: number) { - this._loaded = v; - this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); - } - - get shown(): number { return this._shown; } - set shown(v: number) { - this._shown = v; - this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); - } + private _counter: RecordCounter; private _search: Search; private _ascending: boolean; @@ -421,9 +452,8 @@ export class activityList { this._loadAllButton.classList.remove("unfocused"); this._loadAllButton.disabled = false; - this.total = 0; - this.loaded = 0; - this.shown = 0; + this._counter.reset(); + this._counter.getTotal("/activity/count"); // this._page = 0; let limit = 10; @@ -438,10 +468,6 @@ export class activityList { "ascending": this.ascending } - _get("/activity/count", null, (req: XMLHttpRequest) => { - if (req.readyState != 4 || req.status != 200) return; - this.total = req.response["count"] as number; - }); _post("/activity", send, (req: XMLHttpRequest) => { if (req.readyState != 4) return; @@ -467,13 +493,13 @@ export class activityList { this._search.items = this._activities; this._search.ordering = this._ordering; - this.loaded = this._ordering.length; + this._counter.loaded = this._ordering.length; if (this._search.inSearch) { this._search.onSearchBoxChange(true); this._loadAllButton.classList.remove("unfocused"); } else { - this.shown = this.loaded; + this._counter.shown = this._counter.loaded; this.setVisibility(this._ordering, true); this._loadAllButton.classList.add("unfocused"); this._notFoundPanel.classList.add("unfocused"); @@ -526,7 +552,7 @@ export class activityList { // this._search.items = this._activities; // this._search.ordering = this._ordering; - this.loaded = this._ordering.length; + this._counter.loaded = this._ordering.length; if (this._search.inSearch || loadAll) { if (this._lastPage) { @@ -699,6 +725,8 @@ export class activityList { this._activityList = document.getElementById("activity-card-list"); document.addEventListener("activity-reload", this.reload); + this._counter = new RecordCounter(document.getElementById("activity-record-counter")); + let conf: SearchConfiguration = { filterArea: this._filterArea, sortingByButton: this._sortingByButton, @@ -711,7 +739,7 @@ export class activityList { filterList: document.getElementById("activity-filter-list"), // notFoundCallback: this._notFoundCallback, onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => { - this.shown = visibleCount; + this._counter.shown = visibleCount; if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused"); else this._loadAllButton.classList.add("unfocused"); diff --git a/ts/typings/d.ts b/ts/typings/d.ts index b19a1c7..a4f7f80 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -155,5 +155,9 @@ interface inviteList { // submitter: HTMLInputElement; // } +interface paginatedDTO { + last_page: boolean; +} + declare var config: Object; declare var modifiedConfig: Object; diff --git a/usercache.go b/usercache.go new file mode 100644 index 0000000..68af907 --- /dev/null +++ b/usercache.go @@ -0,0 +1,35 @@ +package main + +import ( + "sync" + "time" +) + +const ( + // FIXME: Follow mediabrowser, or make tuneable, or both + WEB_USER_CACHE_SYNC = 30 * time.Second +) + +type UserCache struct { + Cache []respUser + LastSync time.Time + Lock sync.Mutex +} + +func (c *UserCache) Gen(app *appContext) error { + if !time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) { + return nil + } + users, err := app.jf.GetUsers(false) + if err != nil { + return err + } + c.Lock.Lock() + c.Cache = make([]respUser, len(users)) + for i, jfUser := range users { + c.Cache[i] = app.userSummary(jfUser) + } + c.LastSync = time.Now() + c.Lock.Unlock() + return nil +}