1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-09-20 03:10:11 +00:00

jellyseerr: add option to auto-import users

"import_existing" option in settings enables an every 5-minute daemon
which loops through users and imports them to Jellyseerr and copies
contact info, if necessary. Also sets new API client flag
AutoImportUsers, which decides whether to automatically import non-existent users in
it's various methods.

also cleaned up the various daemons in the software, most now using the
GenericDaemon struct and just providing a new constructor.

broken page loop in jellyseerr client also fixed.
This commit is contained in:
Harvey Tindall 2024-07-31 14:24:02 +01:00
parent 2a6937228c
commit 1fa340f096
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
9 changed files with 252 additions and 140 deletions

View File

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@ -529,7 +528,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
} }
if telegramVerified { if telegramVerified {
u, _ := app.storage.GetTelegramKey(user.ID) u, _ := app.storage.GetTelegramKey(user.ID)
contactMethods[jellyseerr.FieldTelegram] = strconv.FormatInt(u.ChatID, 10) contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact contactMethods[jellyseerr.FieldTelegramEnabled] = req.TelegramContact
} }
if emailEnabled || discordVerified || telegramVerified { if emailEnabled || discordVerified || telegramVerified {

View File

@ -161,20 +161,13 @@ func (app *appContext) loadPendingBackup() {
LOADBAK = "" LOADBAK = ""
} }
func newBackupDaemon(app *appContext) *housekeepingDaemon { func newBackupDaemon(app *appContext) *GenericDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
daemon := housekeepingDaemon{ d := NewGenericDaemon(interval, app,
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
func(app *appContext) { func(app *appContext) {
app.debug.Println("Backups: Creating backup") app.debug.Println("Backups: Creating backup")
app.makeBackup() app.makeBackup()
}, },
} )
return &daemon return d
} }

View File

@ -1620,6 +1620,15 @@
"value": "", "value": "",
"depends_true": "enabled", "depends_true": "enabled",
"description": "API Key. Get this from the first tab in Jellyseerr's settings." "description": "API Key. Get this from the first tab in Jellyseerr's settings."
},
"import_existing": {
"name": "Import existing users to Jellyseerr",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"depends_true": "enabled",
"description": "Existing users (and those created outside jfa-go) will have their contact info imported to Jellyseerr."
} }
} }
}, },

View File

@ -116,32 +116,16 @@ func (app *appContext) clearActivities() {
} }
} }
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS func newHousekeepingDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
type housekeepingDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
}
func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemon {
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
func(app *appContext) { func(app *appContext) {
app.debug.Println("Housekeeping: Checking for expired invites") app.debug.Println("Housekeeping: Checking for expired invites")
app.checkInvites() app.checkInvites()
}, },
func(app *appContext) { app.clearActivities() }, func(app *appContext) { app.clearActivities() },
} )
d.Name("Housekeeping daemon")
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false) clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
@ -150,53 +134,24 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false) clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix { if clearEmail || clearDiscord || clearTelegram || clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() }) d.appendJobs(func(app *appContext) { app.jf.CacheExpiry = time.Now() })
} }
if clearEmail { if clearEmail {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearEmails() }) d.appendJobs(func(app *appContext) { app.clearEmails() })
} }
if clearDiscord { if clearDiscord {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearDiscord() }) d.appendJobs(func(app *appContext) { app.clearDiscord() })
} }
if clearTelegram { if clearTelegram {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearTelegram() }) d.appendJobs(func(app *appContext) { app.clearTelegram() })
} }
if clearMatrix { if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() }) d.appendJobs(func(app *appContext) { app.clearMatrix() })
} }
if clearPWR { if clearPWR {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearPWRCaptchas() }) d.appendJobs(func(app *appContext) { app.clearPWRCaptchas() })
} }
return &daemon return d
}
func (rt *housekeepingDaemon) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
for _, job := range rt.jobs {
job(rt.app)
}
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *housekeepingDaemon) Shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
} }

65
genericdaemon.go Normal file
View File

@ -0,0 +1,65 @@
package main
import "time"
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type GenericDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
jobs []func(app *appContext)
app *appContext
name string
}
func (d *GenericDaemon) appendJobs(jobs ...func(app *appContext)) {
d.jobs = append(d.jobs, jobs...)
}
// NewGenericDaemon returns a daemon which can be given jobs that utilize appContext.
func NewGenericDaemon(interval time.Duration, app *appContext, jobs ...func(app *appContext)) *GenericDaemon {
d := GenericDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
name: "Generic Daemon",
}
d.jobs = jobs
return &d
}
func (d *GenericDaemon) Name(name string) { d.name = name }
func (d *GenericDaemon) run() {
d.app.info.Printf("%s started", d.name)
for {
select {
case <-d.ShutdownChannel:
d.ShutdownChannel <- "Down"
return
case <-time.After(d.period):
break
}
started := time.Now()
for _, job := range d.jobs {
job(d.app)
}
finished := time.Now()
duration := finished.Sub(started)
d.period = d.Interval - duration
}
}
func (d *GenericDaemon) Shutdown() {
d.Stopped = true
d.ShutdownChannel <- "Down"
<-d.ShutdownChannel
close(d.ShutdownChannel)
}

View File

@ -30,6 +30,7 @@ type Jellyseerr struct {
cacheLength time.Duration cacheLength time.Duration
timeoutHandler common.TimeoutHandler timeoutHandler common.TimeoutHandler
LogRequestBodies bool LogRequestBodies bool
AutoImportUsers bool
} }
// NewJellyseerr returns an Ombi object. // NewJellyseerr returns an Ombi object.
@ -63,7 +64,7 @@ func (js *Jellyseerr) getJSON(url string, params map[string]string, queryParams
if params != nil { if params != nil {
jsonParams, _ := json.Marshal(params) jsonParams, _ := json.Marshal(params)
if js.LogRequestBodies { if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending Data \"%s\"\n", string(jsonParams)) fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(jsonParams), url)
} }
req, _ = http.NewRequest("GET", url+"?"+queryParams.Encode(), bytes.NewBuffer(jsonParams)) req, _ = http.NewRequest("GET", url+"?"+queryParams.Encode(), bytes.NewBuffer(jsonParams))
} else { } else {
@ -101,7 +102,7 @@ func (js *Jellyseerr) send(mode string, url string, data any, response bool, hea
responseText := "" responseText := ""
params, _ := json.Marshal(data) params, _ := json.Marshal(data)
if js.LogRequestBodies { if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending Data \"%s\"\n", string(params)) fmt.Printf("Jellyseerr API Client: Sending Data \"%s\" to \"%s\"\n", string(params), url)
} }
req, _ := http.NewRequest(mode, url, bytes.NewBuffer(params)) req, _ := http.NewRequest(mode, url, bytes.NewBuffer(params))
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
@ -175,7 +176,7 @@ func (js *Jellyseerr) getUsers() error {
pageCount := 1 pageCount := 1
pageIndex := 0 pageIndex := 0
for { for {
res, err := js.getUserPage(0) res, err := js.getUserPage(pageIndex)
if err != nil { if err != nil {
return err return err
} }
@ -197,8 +198,11 @@ func (js *Jellyseerr) getUsers() error {
func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) { func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
params := url.Values{} params := url.Values{}
params.Add("take", "30") params.Add("take", "30")
params.Add("skip", strconv.Itoa(page)) params.Add("skip", strconv.Itoa(page*30))
params.Add("sort", "created") params.Add("sort", "created")
if js.LogRequestBodies {
fmt.Printf("Jellyseerr API Client: Sending with URL params \"%+v\"\n", params)
}
resp, status, err := js.getJSON(js.server+"/user", nil, params) resp, status, err := js.getJSON(js.server+"/user", nil, params)
var data GetUsersDTO var data GetUsersDTO
if status != 200 { if status != 200 {
@ -211,25 +215,55 @@ func (js *Jellyseerr) getUserPage(page int) (GetUsersDTO, error) {
return data, err return data, err
} }
// MustGetUser provides the same function as ImportFromJellyfin, but will always return the user,
// even if they already existed.
func (js *Jellyseerr) MustGetUser(jfID string) (User, error) { func (js *Jellyseerr) MustGetUser(jfID string) (User, error) {
js.getUsers() u, _, err := js.GetOrImportUser(jfID)
if u, ok := js.userCache[jfID]; ok { return u, err
return u, nil }
// GetImportedUser provides the same function as ImportFromJellyfin, but will always return the user,
// even if they already existed. Also returns whether the user was imported or not,
func (js *Jellyseerr) GetOrImportUser(jfID string) (u User, imported bool, err error) {
imported = false
u, err = js.GetExistingUser(jfID)
if err == nil {
return
} }
users, err := js.ImportFromJellyfin(jfID) var users []User
var u User users, err = js.ImportFromJellyfin(jfID)
if err != nil { if err != nil {
return u, err return
} }
if len(users) != 0 { if len(users) != 0 {
return users[0], err u = users[0]
err = nil
return
} }
if u, ok := js.userCache[jfID]; ok { err = fmt.Errorf("user not found or imported")
return u, nil return
}
func (js *Jellyseerr) GetExistingUser(jfID string) (u User, err error) {
js.getUsers()
ok := false
err = nil
if u, ok = js.userCache[jfID]; ok {
return
} }
return u, fmt.Errorf("user not found") js.cacheExpiry = time.Now()
js.getUsers()
if u, ok = js.userCache[jfID]; ok {
err = nil
return
}
err = fmt.Errorf("user not found")
return
}
func (js *Jellyseerr) getUser(jfID string) (User, error) {
if js.AutoImportUsers {
return js.MustGetUser(jfID)
}
return js.GetExistingUser(jfID)
} }
func (js *Jellyseerr) Me() (User, error) { func (js *Jellyseerr) Me() (User, error) {
@ -248,7 +282,7 @@ func (js *Jellyseerr) Me() (User, error) {
func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) { func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
data := permissionsDTO{Permissions: -1} data := permissionsDTO{Permissions: -1}
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return data.Permissions, err return data.Permissions, err
} }
@ -265,7 +299,7 @@ func (js *Jellyseerr) GetPermissions(jfID string) (Permissions, error) {
} }
func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error { func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -283,7 +317,7 @@ func (js *Jellyseerr) SetPermissions(jfID string, perm Permissions) error {
} }
func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error { func (js *Jellyseerr) ApplyTemplateToUser(jfID string, tmpl UserTemplate) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -304,7 +338,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
if _, ok := conf[FieldEmail]; ok { if _, ok := conf[FieldEmail]; ok {
return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead") return fmt.Errorf("email is read only, set with ModifyMainUserSettings instead")
} }
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -322,7 +356,7 @@ func (js *Jellyseerr) ModifyUser(jfID string, conf map[UserField]any) error {
} }
func (js *Jellyseerr) DeleteUser(jfID string) error { func (js *Jellyseerr) DeleteUser(jfID string) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -339,7 +373,7 @@ func (js *Jellyseerr) DeleteUser(jfID string) error {
} }
func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) { func (js *Jellyseerr) GetNotificationPreferences(jfID string) (Notifications, error) {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return Notifications{}, err return Notifications{}, err
} }
@ -364,7 +398,7 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific
/* if tmpl.NotifTypes.Empty() { /* if tmpl.NotifTypes.Empty() {
tmpl.NotifTypes = nil tmpl.NotifTypes = nil
}*/ }*/
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -380,7 +414,7 @@ func (js *Jellyseerr) ApplyNotificationsTemplateToUser(jfID string, tmpl Notific
} }
func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error { func (js *Jellyseerr) ModifyNotifications(jfID string, conf map[NotificationsField]any) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
@ -414,12 +448,12 @@ func (js *Jellyseerr) UserByID(jellyseerrID int64) (User, error) {
} }
func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error { func (js *Jellyseerr) ModifyMainUserSettings(jfID string, conf MainUserSettings) error {
u, err := js.MustGetUser(jfID) u, err := js.getUser(jfID)
if err != nil { if err != nil {
return err return err
} }
_, status, err := js.put(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false) _, status, err := js.post(fmt.Sprintf(js.server+"/user/%d/settings/main", u.ID), conf, false)
if err != nil { if err != nil {
return err return err
} }

81
jellyseerrdaemon.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"strconv"
"time"
"github.com/hrfee/jfa-go/jellyseerr"
)
func (app *appContext) SynchronizeJellyseerrUser(jfID string) {
user, imported, err := app.js.GetOrImportUser(jfID)
if err != nil {
app.debug.Printf("Failed to get or trigger import for Jellyseerr (user \"%s\"): %v", jfID, err)
return
}
if imported {
app.debug.Printf("Jellyseerr: Triggered import for Jellyfin user \"%s\" (ID %d)", jfID, user.ID)
}
notif, err := app.js.GetNotificationPreferencesByID(user.ID)
if err != nil {
app.debug.Printf("Failed to get notification prefs for Jellyseerr (user \"%s\"): %v", jfID, err)
return
}
contactMethods := map[jellyseerr.NotificationsField]any{}
email, ok := app.storage.GetEmailsKey(jfID)
if ok && email.Addr != "" && user.Email != email.Addr {
err = app.js.ModifyMainUserSettings(jfID, jellyseerr.MainUserSettings{Email: email.Addr})
if err != nil {
app.err.Printf("Failed to set Jellyseerr email address: %v\n", err)
} else {
contactMethods[jellyseerr.FieldEmailEnabled] = email.Contact
}
}
if discordEnabled {
dcUser, ok := app.storage.GetDiscordKey(jfID)
if ok && dcUser.ID != "" && notif.DiscordID != dcUser.ID {
contactMethods[jellyseerr.FieldDiscord] = dcUser.ID
contactMethods[jellyseerr.FieldDiscordEnabled] = dcUser.Contact
}
}
if telegramEnabled {
tgUser, ok := app.storage.GetTelegramKey(jfID)
chatID, _ := strconv.ParseInt(notif.TelegramChatID, 10, 64)
if ok && tgUser.ChatID != 0 && chatID != tgUser.ChatID {
u, _ := app.storage.GetTelegramKey(jfID)
contactMethods[jellyseerr.FieldTelegram] = u.ChatID
contactMethods[jellyseerr.FieldTelegramEnabled] = tgUser.Contact
}
}
if len(contactMethods) != 0 {
err := app.js.ModifyNotifications(jfID, contactMethods)
if err != nil {
app.err.Printf("Failed to sync contact methods with Jellyseerr: %v", err)
}
}
}
func (app *appContext) SynchronizeJellyseerrUsers() {
users, status, err := app.jf.GetUsers(false)
if err != nil || status != 200 {
app.err.Printf("Failed to get users (%d): %s", status, err)
return
}
// I'm sure Jellyseerr can handle it,
// but past issues with the Jellyfin db scare me from
// running these concurrently. W/e, its a bg task anyway.
for _, user := range users {
app.SynchronizeJellyseerrUser(user.ID)
}
}
func newJellyseerrDaemon(interval time.Duration, app *appContext) *GenericDaemon {
d := NewGenericDaemon(interval, app,
func(app *appContext) {
app.SynchronizeJellyseerrUsers()
},
)
d.Name("Jellyseerr import daemon")
return d
}

15
main.go
View File

@ -369,6 +369,7 @@ func start(asDaemon, firstCall bool) {
app.config.Section("jellyseerr").Key("api_key").String(), app.config.Section("jellyseerr").Key("api_key").String(),
common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true), common.NewTimeoutHandler("Jellyseerr", jellyseerrServer, true),
) )
app.js.AutoImportUsers = app.config.Section("jellyseerr").Key("import_existing").MustBool(false)
// app.js.LogRequestBodies = true // app.js.LogRequestBodies = true
} }
@ -480,13 +481,21 @@ func start(asDaemon, firstCall bool) {
os.Exit(0) os.Exit(0)
} }
invDaemon := newInviteDaemon(time.Duration(60*time.Second), app) invDaemon := newHousekeepingDaemon(time.Duration(60*time.Second), app)
go invDaemon.run() go invDaemon.run()
defer invDaemon.Shutdown() defer invDaemon.Shutdown()
userDaemon := newUserDaemon(time.Duration(60*time.Second), app) userDaemon := newUserDaemon(time.Duration(60*time.Second), app)
go userDaemon.run() go userDaemon.run()
defer userDaemon.shutdown() defer userDaemon.Shutdown()
var jellyseerrDaemon *GenericDaemon
if app.config.Section("jellyseerr").Key("enabled").MustBool(false) && app.config.Section("jellyseerr").Key("import_existing").MustBool(false) {
jellyseerrDaemon = newJellyseerrDaemon(time.Duration(30*time.Second), app)
// jellyseerrDaemon = newJellyseerrDaemon(time.Duration(5*time.Minute), app)
go jellyseerrDaemon.run()
defer jellyseerrDaemon.Shutdown()
}
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer { if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
go app.StartPWR() go app.StartPWR()
@ -496,7 +505,7 @@ func start(asDaemon, firstCall bool) {
go app.checkForUpdates() go app.checkForUpdates()
} }
var backupDaemon *housekeepingDaemon var backupDaemon *GenericDaemon
if app.config.Section("backups").Key("enabled").MustBool(false) { if app.config.Section("backups").Key("enabled").MustBool(false) {
backupDaemon = newBackupDaemon(app) backupDaemon = newBackupDaemon(app)
go backupDaemon.run() go backupDaemon.run()

View File

@ -7,47 +7,14 @@ import (
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
) )
type userDaemon struct { func newUserDaemon(interval time.Duration, app *appContext) *GenericDaemon {
Stopped bool d := NewGenericDaemon(interval, app,
ShutdownChannel chan string func(app *appContext) {
Interval time.Duration app.checkUsers()
period time.Duration },
app *appContext )
} d.Name("User daemon")
return d
func newUserDaemon(interval time.Duration, app *appContext) *userDaemon {
return &userDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
}
func (rt *userDaemon) run() {
rt.app.info.Println("User daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
rt.app.checkUsers()
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (rt *userDaemon) shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel
close(rt.ShutdownChannel)
} }
func (app *appContext) checkUsers() { func (app *appContext) checkUsers() {