diff --git a/api-activities.go b/api-activities.go new file mode 100644 index 0000000..929a6f5 --- /dev/null +++ b/api-activities.go @@ -0,0 +1,186 @@ +package main + +import ( + "github.com/gin-gonic/gin" + "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: + return "creation" + case ActivityDeletion: + return "deletion" + case ActivityDisabled: + return "disabled" + case ActivityEnabled: + return "enabled" + case ActivityContactLinked: + return "contactLinked" + case ActivityContactUnlinked: + return "contactUnlinked" + case ActivityChangePassword: + return "changePassword" + case ActivityResetPassword: + return "resetPassword" + case ActivityCreateInvite: + return "createInvite" + case ActivityDeleteInvite: + return "deleteInvite" + } + return "unknown" +} + +func stringToActivitySource(v string) ActivitySource { + switch v { + case "user": + return ActivityUser + case "admin": + return ActivityAdmin + case "anon": + return ActivityAnon + case "daemon": + return ActivityDaemon + } + return ActivityAnon +} + +func activitySourceToString(v ActivitySource) string { + switch v { + case ActivityUser: + return "user" + case ActivityAdmin: + return "admin" + case ActivityAnon: + return "anon" + case ActivityDaemon: + return "daemon" + } + return "anon" +} + +// @Summary Get the requested set of activities, Paginated, filtered and sorted. +// @Produce json +// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters" +// @Success 200 {object} GetActivitiesRespDTO +// @Router /activity [post] +// @Security Bearer +// @tags Activity +func (app *appContext) GetActivities(gc *gin.Context) { + req := GetActivitiesDTO{} + gc.BindJSON(&req) + query := &badgerhold.Query{} + activityTypes := make([]interface{}, 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.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 + err := app.storage.db.Find(&results, query) + + if err != nil { + app.err.Printf("Failed to read activities from DB: %v\n", err) + } + + resp := GetActivitiesRespDTO{ + Activities: make([]ActivityDTO, len(results)), + LastPage: len(results) != req.Limit, + } + + for i, act := range results { + resp.Activities[i] = ActivityDTO{ + ID: act.ID, + Type: activityTypeToString(act.Type), + UserID: act.UserID, + SourceType: activitySourceToString(act.SourceType), + Source: act.Source, + InviteCode: act.InviteCode, + Value: act.Value, + Time: act.Time.Unix(), + } + if act.Type == ActivityDeletion || act.Type == ActivityCreation { + resp.Activities[i].Username = act.Value + resp.Activities[i].Value = "" + } else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil { + resp.Activities[i].Username = user.Name + } + + if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" { + user, status, err := app.jf.UserByID(act.Source, false) + if status == 200 && err == nil { + resp.Activities[i].SourceUsername = user.Name + } + } + } + + gc.JSON(200, resp) +} + +// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds. +// @Produce json +// @Param id path string true "ID of activity to delete" +// @Success 200 {object} boolResponse +// @Router /activity/{id} [delete] +// @Security Bearer +// @tags Activity +func (app *appContext) DeleteActivity(gc *gin.Context) { + app.storage.DeleteActivityKey(gc.Param("id")) + respondBool(200, true, gc) +} + +// @Summary Returns the total number of activities stored in the database. +// @Produce json +// @Success 200 {object} GetActivityCountDTO +// @Router /activity/count [get] +// @Security Bearer +// @tags Activity +func (app *appContext) GetActivityCount(gc *gin.Context) { + resp := GetActivityCountDTO{} + var err error + resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{}) + if err != nil { + resp.Count = 0 + } + gc.JSON(200, resp) +} diff --git a/api-invites.go b/api-invites.go index 43713b9..248eab8 100644 --- a/api-invites.go +++ b/api-invites.go @@ -85,6 +85,14 @@ func (app *appContext) checkInvites() { wait.Wait() } app.storage.DeleteInvitesKey(data.Code) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityDeleteInvite, + SourceType: ActivityDaemon, + InviteCode: data.Code, + Value: data.Label, + Time: time.Now(), + }) } } @@ -130,12 +138,26 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool } match = false app.storage.DeleteInvitesKey(code) + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityDeleteInvite, + SourceType: ActivityDaemon, + InviteCode: code, + Value: inv.Label, + Time: time.Now(), + }) } else if used { del := false newInv := inv if newInv.RemainingUses == 1 { del = true app.storage.DeleteInvitesKey(code) + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityDeleteInvite, + SourceType: ActivityDaemon, + InviteCode: code, + Value: inv.Label, + Time: time.Now(), + }) } else if newInv.RemainingUses != 0 { // 0 means infinite i guess? newInv.RemainingUses-- @@ -236,6 +258,18 @@ func (app *appContext) GenerateInvite(gc *gin.Context) { } } app.storage.SetInvitesKey(invite.Code, invite) + + // Record activity + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityCreateInvite, + UserID: "", + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + InviteCode: invite.Code, + Value: invite.Label, + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -429,10 +463,20 @@ func (app *appContext) DeleteInvite(gc *gin.Context) { var req deleteInviteDTO gc.BindJSON(&req) app.debug.Printf("%s: Deletion requested", req.Code) - var ok bool - _, ok = app.storage.GetInvitesKey(req.Code) + inv, ok := app.storage.GetInvitesKey(req.Code) if ok { app.storage.DeleteInvitesKey(req.Code) + + // Record activity + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityDeleteInvite, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + InviteCode: req.Code, + Value: inv.Label, + Time: time.Now(), + }) + app.info.Printf("%s: Invite deleted", req.Code) respondBool(200, true, gc) return diff --git a/api-messages.go b/api-messages.go index 15e7f45..7520c78 100644 --- a/api-messages.go +++ b/api-messages.go @@ -5,6 +5,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/lithammer/shortuuid/v3" "gopkg.in/ini.v1" ) @@ -677,7 +678,18 @@ func (app *appContext) DiscordConnect(gc *gin.Context) { respondBool(500, false, gc) return } + app.storage.SetDiscordKey(req.JellyfinID, user) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactLinked, + UserID: req.JellyfinID, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Value: "discord", + Time: time.Now(), + }) + linkExistingOmbiDiscordTelegram(app) respondBool(200, true, gc) } @@ -697,6 +709,16 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) { return } */ app.storage.DeleteDiscordKey(req.ID) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactUnlinked, + UserID: req.ID, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Value: "discord", + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -715,6 +737,16 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) { return } */ app.storage.DeleteTelegramKey(req.ID) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactUnlinked, + UserID: req.ID, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Value: "telegram", + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -733,5 +765,15 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) { return } */ app.storage.DeleteMatrixKey(req.ID) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactUnlinked, + UserID: req.ID, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Value: "matrix", + Time: time.Now(), + }) + respondBool(200, true, gc) } diff --git a/api-userpage.go b/api-userpage.go index 0df5653..97684ec 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" + "github.com/lithammer/shortuuid/v3" "github.com/timshannon/badgerhold/v4" ) @@ -207,6 +208,16 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) { } emailStore.Addr = claims["email"].(string) app.storage.SetEmailsKey(id, emailStore) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactLinked, + UserID: gc.GetString("jfId"), + SourceType: ActivityUser, + Source: gc.GetString("jfId"), + Value: "email", + Time: time.Now(), + }) + if app.config.Section("ombi").Key("enabled").MustBool(false) { ombiUser, code, err := app.getOmbiUser(id) if code == 200 && err == nil { @@ -359,6 +370,16 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) { dcUser.Contact = existingUser.Contact } app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactLinked, + UserID: gc.GetString("jfId"), + SourceType: ActivityUser, + Source: gc.GetString("jfId"), + Value: "discord", + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -397,6 +418,16 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) { tgUser.Contact = existingUser.Contact } app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactLinked, + UserID: gc.GetString("jfId"), + SourceType: ActivityUser, + Source: gc.GetString("jfId"), + Value: "telegram", + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -468,6 +499,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) { } app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactLinked, + UserID: gc.GetString("jfId"), + SourceType: ActivityUser, + Source: gc.GetString("jfId"), + Value: "matrix", + Time: time.Now(), + }) + delete(app.matrix.tokens, pin) respondBool(200, true, gc) } @@ -480,6 +521,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) { // @Tags User Page func (app *appContext) UnlinkMyDiscord(gc *gin.Context) { app.storage.DeleteDiscordKey(gc.GetString("jfId")) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactUnlinked, + UserID: gc.GetString("jfId"), + SourceType: ActivityUser, + Source: gc.GetString("jfId"), + Value: "discord", + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -491,6 +542,16 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) { // @Tags User Page func (app *appContext) UnlinkMyTelegram(gc *gin.Context) { app.storage.DeleteTelegramKey(gc.GetString("jfId")) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactUnlinked, + UserID: gc.GetString("jfId"), + SourceType: ActivityUser, + Source: gc.GetString("jfId"), + Value: "telegram", + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -502,6 +563,16 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) { // @Tags User Page func (app *appContext) UnlinkMyMatrix(gc *gin.Context) { app.storage.DeleteMatrixKey(gc.GetString("jfId")) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactUnlinked, + UserID: gc.GetString("jfId"), + SourceType: ActivityUser, + Source: gc.GetString("jfId"), + Value: "matrix", + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -620,6 +691,15 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) { respondBool(500, false, gc) return } + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityChangePassword, + UserID: user.ID, + SourceType: ActivityUser, + Source: user.ID, + Time: time.Now(), + }) + if app.config.Section("ombi").Key("enabled").MustBool(false) { func() { ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId")) diff --git a/api-users.go b/api-users.go index f735bda..e8dd451 100644 --- a/api-users.go +++ b/api-users.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" "github.com/hrfee/mediabrowser" + "github.com/lithammer/shortuuid/v3" "github.com/timshannon/badgerhold/v4" ) @@ -45,6 +46,17 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { return } id := user.ID + + // Record activity + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityCreation, + UserID: id, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Value: user.Name, + Time: time.Now(), + }) + profile := app.storage.GetDefaultProfile() if req.Profile != "" && req.Profile != "none" { if p, ok := app.storage.GetProfileKey(req.Profile); ok { @@ -303,6 +315,24 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc } id := user.ID + // Record activity + sourceType := ActivityAnon + source := "" + if invite.ReferrerJellyfinID != "" { + sourceType = ActivityUser + source = invite.ReferrerJellyfinID + } + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityCreation, + UserID: id, + SourceType: sourceType, + Source: source, + InviteCode: invite.Code, + Value: user.Name, + Time: time.Now(), + }) + emailStore := EmailAddress{ Addr: req.Email, Contact: (req.Email != ""), @@ -353,6 +383,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc if app.storage.deprecatedDiscord == nil { app.storage.deprecatedDiscord = discordStore{} } + // Note we don't log an activity here, since it's part of creating a user. app.storage.SetDiscordKey(user.ID, discordUser) delete(app.discord.verifiedTokens, req.DiscordPIN) } @@ -539,6 +570,10 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { sendMail = false } } + activityType := ActivityDisabled + if req.Enabled { + activityType = ActivityEnabled + } for _, userID := range req.Users { user, status, err := app.jf.UserByID(userID, false) if status != 200 || err != nil { @@ -553,6 +588,16 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err) continue } + + // Record activity + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: activityType, + UserID: userID, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Time: time.Now(), + }) + if sendMail && req.Notify { if err := app.sendByID(msg, userID); err != nil { app.err.Printf("Failed to send account enabled/disabled email: %v", err) @@ -605,6 +650,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { } } } + + username := "" + if user, status, err := app.jf.UserByID(userID, false); status == 200 && err == nil { + username = user.Name + } + status, err := app.jf.DeleteUser(userID) if !(status == 200 || status == 204) || err != nil { msg := fmt.Sprintf("%d: %v", status, err) @@ -614,6 +665,17 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { errors[userID] += msg } } + + // Record activity + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityDeletion, + UserID: userID, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Value: username, + Time: time.Now(), + }) + if sendMail && req.Notify { if err := app.sendByID(msg, userID); err != nil { app.err.Printf("Failed to send account deletion email: %v", err) @@ -1097,6 +1159,20 @@ func (app *appContext) ModifyEmails(gc *gin.Context) { emailStore.Addr = address app.storage.SetEmailsKey(id, emailStore) + + activityType := ActivityContactLinked + if address == "" { + activityType = ActivityContactUnlinked + } + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: activityType, + UserID: id, + SourceType: ActivityAdmin, + Source: gc.GetString("jfId"), + Value: "email", + Time: time.Now(), + }) + if ombiEnabled { ombiUser, code, err := app.getOmbiUser(id) if code == 200 && err == nil { diff --git a/api.go b/api.go index d9d44e4..1b926e0 100644 --- a/api.go +++ b/api.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/hrfee/mediabrowser" "github.com/itchyny/timefmt-go" + "github.com/lithammer/shortuuid/v3" "gopkg.in/ini.v1" ) @@ -157,6 +158,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) { } username = resp.UsersReset[0] } + var user mediabrowser.User var status int var err error @@ -170,6 +172,15 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) { respondBool(500, false, gc) return } + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityResetPassword, + UserID: user.ID, + SourceType: ActivityUser, + Source: user.ID, + Time: time.Now(), + }) + prevPassword := req.PIN if isInternal { prevPassword = "" diff --git a/config.go b/config.go index 4c1b8ad..b3189d1 100644 --- a/config.go +++ b/config.go @@ -78,6 +78,9 @@ func (app *appContext) loadConfig() error { app.MustSetValue("smtp", "cert_validation", "true") app.MustSetValue("smtp", "auth_type", "4") + app.MustSetValue("activity_log", "keep_n_records", "1000") + app.MustSetValue("activity_log", "delete_after_days", "90") + sc := app.config.Section("discord").Key("start_command").MustString("start") app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!")) diff --git a/config/config-base.json b/config/config-base.json index 00ac494..5099751 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -515,6 +515,31 @@ } } }, + "activity_log": { + "order": [], + "meta": { + "name": "Activity Log", + "description": "Settings for data retention of the activity log." + }, + "settings": { + "keep_n_records": { + "name": "Number of records to keep", + "required": false, + "requires_restart": true, + "type": "number", + "value": 1000, + "description": "How many of the most recent activities to keep. Set to 0 to disable." + }, + "delete_after_days": { + "name": "Delete activities older than (days):", + "required": false, + "requires_restart": true, + "type": "number", + "value": 90, + "description": "If an activity was created this many days ago, it will be deleted. Set to 0 to disable." + } + } + }, "captcha": { "order": [], "meta": { diff --git a/css/loader.css b/css/loader.css index b9c4bfa..40459ec 100644 --- a/css/loader.css +++ b/css/loader.css @@ -3,6 +3,10 @@ color: rgba(0, 0, 0, 0) !important; } +.loader.rel { + position: relative; +} + .loader .dot { --diameter: 0.5rem; --radius: calc(var(--diameter) / 2); @@ -15,6 +19,12 @@ left: calc(50% - var(--radius)); animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite; } + +.loader.rel .dot { + position: absolute; + top: 50%; +} + .loader.loader-sm .dot { --deviation: 10%; } diff --git a/daemon.go b/daemon.go index 0313dca..9a6a9d1 100644 --- a/daemon.go +++ b/daemon.go @@ -3,7 +3,9 @@ package main import ( "time" + "github.com/dgraph-io/badger/v3" "github.com/hrfee/mediabrowser" + "github.com/timshannon/badgerhold/v4" ) // clearEmails removes stored emails for users which no longer exist. @@ -72,6 +74,37 @@ func (app *appContext) clearTelegram() { } } +func (app *appContext) clearActivities() { + app.debug.Println("Husekeeping: Cleaning up Activity log...") + keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000) + maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90) + minAge := time.Now().AddDate(0, 0, -maxAgeDays) + err := error(nil) + errorSource := 0 + if maxAgeDays != 0 { + err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge)) + } + if err == nil && keepCount != 0 { + // app.debug.Printf("Keeping %d records", keepCount) + err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount)) + if err != nil { + errorSource = 1 + } + } + if err == badger.ErrTxnTooBig { + app.debug.Printf("Activities: Delete txn was too big, doing it manually.") + list := []Activity{} + if errorSource == 0 { + app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge)) + } else { + app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount)) + } + for _, record := range list { + app.storage.DeleteActivityKey(record.ID) + } + } +} + // https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS type housekeepingDaemon struct { @@ -91,10 +124,13 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo period: interval, app: app, } - daemon.jobs = []func(app *appContext){func(app *appContext) { - app.debug.Println("Housekeeping: Checking for expired invites") - app.checkInvites() - }} + daemon.jobs = []func(app *appContext){ + func(app *appContext) { + app.debug.Println("Housekeeping: Checking for expired invites") + app.checkInvites() + }, + func(app *appContext) { app.clearActivities() }, + } clearEmail := app.config.Section("email").Key("require_unique").MustBool(false) clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) diff --git a/html/admin.html b/html/admin.html index 48818a9..df6cfdf 100644 --- a/html/admin.html +++ b/html/admin.html @@ -475,6 +475,7 @@
{{ .strings.invites }} {{ .strings.accounts }} + {{ .strings.activity }} {{ .strings.settings }}
@@ -719,6 +720,57 @@ +
+
+
+ {{ .strings.activity }} + + + + + +
+
+ +
+ + + +
+
+
+ + +
+
+
+
+
+
+ {{ .strings.noResultsFound }} + {{ .strings.keepSearchingDescription }} +
+ + +
+
+
+
+ + +
+
+
+
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 32f473f..769ff1e 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -6,6 +6,7 @@ "invites": "Invites", "invite": "Invite", "accounts": "Accounts", + "activity": "Activity", "settings": "Settings", "inviteMonths": "Months", "inviteDays": "Days", @@ -54,8 +55,12 @@ "reset": "Reset", "donate": "Donate", "unlink": "Unlink Account", + "deleted": "Deleted", + "disabled": "Disabled", "sendPWR": "Send Password Reset", "noResultsFound": "No Results Found", + "keepSearching": "Keep Searching", + "keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.", "contactThrough": "Contact through:", "extendExpiry": "Extend expiry", "sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.", @@ -118,6 +123,7 @@ "accessJFA": "Access jfa-go", "accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.", "sortingBy": "Sorting By", + "sortDirection": "Sort Direction", "filters": "Filters", "clickToRemoveFilter": "Click to remove this filter.", "clearSearch": "Clear search", @@ -129,7 +135,47 @@ "userPagePage": "User Page: Page", "buildTime": "Build Time", "builtBy": "Built By", - "loginNotAdmin": "Not an Admin?" + "loginNotAdmin": "Not an Admin?", + "referrer": "Referrer", + "accountLinked": "{contactMethod} linked: {user}", + "accountUnlinked": "{contactMethod} removed: {user}", + "accountResetPassword": "{user} reset their password", + "accountChangedPassword": "{user} changed their password", + "accountCreated": "Account created: {user}", + "accountDeleted": "Account deleted: {user}", + "accountDisabled": "Account disabled: {user}", + "accountReEnabled": "Account re-enabled: {user}", + "accountExpired": "Account expired: {user}", + "userDeleted": "User was deleted.", + "userDisabled": "User was disabled", + "inviteCreated": "Invite created: {invite}", + "inviteDeleted": "Invite deleted: {invite}", + "inviteExpired": "Invite expired: {invite}", + "fromInvite": "From Invite", + "byAdmin": "By Admin", + "byUser": "By User", + "byJfaGo": "By jfa-go", + "activityID": "Activity ID", + "title": "Title", + "usersMentioned": "User mentioned", + "actor": "Actor", + "actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.", + "accountCreationFilter": "Account Creation", + "accountDeletionFilter": "Account Deletion", + "accountDisabledFilter": "Account Disabled", + "accountEnabledFilter": "Account Enabled", + "contactLinkedFilter": "Contact Linked", + "contactUnlinkedFilter": "Contact Unlinked", + "passwordChangeFilter": "Password Changed", + "passwordResetFilter": "Password Reset", + "inviteCreatedFilter": "Invite Created", + "inviteDeletedFilter": "Invite Deleted/Expired", + "loadMore": "Load More", + "loadAll": "Load All", + "noMoreResults": "No more results.", + "totalRecords": "{n} Total Records", + "loadedRecords": "{n} Loaded", + "shownRecords": "{n} Shown" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", @@ -145,6 +191,9 @@ "telegramVerified": "Telegram account verified.", "accountConnected": "Account connected.", "referralsEnabled": "Referrals enabled.", + "activityDeleted": "Activity Deleted.", + "errorInviteNoLongerExists": "Invite no longer exists.", + "errorInviteNotFound": "Invite not found.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", "errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.", "errorSettingsFailed": "Application failed.", @@ -166,6 +215,7 @@ "errorApplyUpdate": "Failed to apply update, try manually.", "errorCheckUpdate": "Failed to check for update.", "errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.", + "errorLoadActivities": "Failed to load activities.", "updateAvailable": "A new update is available, check settings.", "noUpdatesAvailable": "No new updates available." }, diff --git a/main.go b/main.go index 4b6c211..1249d29 100644 --- a/main.go +++ b/main.go @@ -638,6 +638,9 @@ func flagPassed(name string) (found bool) { // @tag.name Profiles & Settings // @tag.description Profile and settings related operations. +// @tag.name Activity +// @tag.description Routes related to the activity log. + // @tag.name Configuration // @tag.description jfa-go settings. diff --git a/models.go b/models.go index d4f841e..f24d2f9 100644 --- a/models.go +++ b/models.go @@ -430,3 +430,32 @@ type GetMyReferralRespDTO struct { type EnableDisableReferralDTO struct { Users []string `json:"users"` } + +type ActivityDTO struct { + ID string `json:"id"` + Type string `json:"type"` + UserID string `json:"user_id"` + Username string `json:"username"` + SourceType string `json:"source_type"` + Source string `json:"source"` + SourceUsername string `json:"source_username"` + InviteCode string `json:"invite_code"` + Value string `json:"value"` + Time int64 `json:"time"` +} + +type GetActivitiesDTO struct { + Type []string `json:"type"` // Types of activity to get. Leave blank for all. + Limit int `json:"limit"` + Page int `json:"page"` // zero-indexed + Ascending bool `json:"ascending"` +} + +type GetActivitiesRespDTO struct { + Activities []ActivityDTO `json:"activities"` + LastPage bool `json:"last_page"` +} + +type GetActivityCountDTO struct { + Count uint64 `json:"count"` +} diff --git a/router.go b/router.go index 875ad20..6c1e8e1 100644 --- a/router.go +++ b/router.go @@ -118,6 +118,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.GET(p+"/accounts", app.AdminPage) router.GET(p+"/settings", app.AdminPage) + router.GET(p+"/activity", app.AdminPage) + router.GET(p+"/accounts/user/:userID", app.AdminPage) + router.GET(p+"/invites/:code", app.AdminPage) router.GET(p+"/lang/:page/:file", app.ServeLang) router.GET(p+"/token/login", app.getTokenLogin) router.GET(p+"/token/refresh", app.getTokenRefresh) @@ -232,6 +235,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile) } + api.POST(p+"/activity", app.GetActivities) + api.DELETE(p+"/activity/:id", app.DeleteActivity) + api.GET(p+"/activity/count", app.GetActivityCount) + if userPageEnabled { user.GET("/details", app.MyDetails) user.POST("/contact", app.SetMyContactMethods) diff --git a/storage.go b/storage.go index 18af06d..4ccf7de 100644 --- a/storage.go +++ b/storage.go @@ -21,6 +21,42 @@ type telegramStore map[string]TelegramUser type matrixStore map[string]MatrixUser type emailStore map[string]EmailAddress +type ActivityType int + +const ( + ActivityCreation ActivityType = iota + ActivityDeletion + ActivityDisabled + ActivityEnabled + ActivityContactLinked + ActivityContactUnlinked + ActivityChangePassword + ActivityResetPassword + ActivityCreateInvite + ActivityDeleteInvite + ActivityUnknown +) + +type ActivitySource int + +const ( + ActivityUser ActivitySource = iota // Source = UserID. For ActivityCreation, this would mean the referrer. + ActivityAdmin // Source = Admin's UserID, or blank if jellyfin login isn't on. + ActivityAnon // Source = Blank, or potentially browser info. For ActivityCreation, this would be via an invite + ActivityDaemon // Source = Blank, was deleted/disabled due to expiry by daemon +) + +type Activity struct { + ID string `badgerhold:"key"` + Type ActivityType `badgerhold:"index"` + UserID string // ID of target user. For account creation, this will be the newly created account + SourceType ActivitySource + Source string + InviteCode string // Set for ActivityCreation, create/deleteInvite + Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username. + Time time.Time +} + type UserExpiry struct { JellyfinID string `badgerhold:"key"` Expiry time.Time @@ -514,6 +550,32 @@ func (st *Storage) DeleteCustomContentKey(k string) { st.db.Delete(k, CustomContent{}) } +// GetActivityKey returns the value stored in the store's key. +func (st *Storage) GetActivityKey(k string) (Activity, bool) { + result := Activity{} + err := st.db.Get(k, &result) + ok := true + if err != nil { + // fmt.Printf("Failed to find custom content: %v\n", err) + ok = false + } + return result, ok +} + +// SetActivityKey stores value v in key k. +func (st *Storage) SetActivityKey(k string, v Activity) { + v.ID = k + err := st.db.Upsert(k, v) + if err != nil { + // fmt.Printf("Failed to set custom content: %v\n", err) + } +} + +// DeleteActivityKey deletes value at key k. +func (st *Storage) DeleteActivityKey(k string) { + st.db.Delete(k, Activity{}) +} + type TelegramUser struct { JellyfinID string `badgerhold:"key"` ChatID int64 `badgerhold:"index"` diff --git a/ts/admin.ts b/ts/admin.ts index 2fe99c7..5edce5b 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -5,6 +5,7 @@ import { Tabs } from "./modules/tabs.js"; import { inviteList, createInvite } from "./modules/invites.js"; import { accountsList } from "./modules/accounts.js"; import { settingsList } from "./modules/settings.js"; +import { activityList } from "./modules/activity.js"; import { ProfileEditor } from "./modules/profiles.js"; import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js"; import { Updater } from "./modules/update.js"; @@ -89,6 +90,8 @@ var inviteCreator = new createInvite(); var accounts = new accountsList(); +var activity = new activityList(); + window.invites = new inviteList(); var settings = new settingsList(); @@ -120,6 +123,10 @@ const tabs: { url: string, reloader: () => void }[] = [ url: "accounts", reloader: accounts.reload }, + { + url: "activity", + reloader: activity.reload + }, { url: "settings", reloader: settings.reload @@ -137,6 +144,9 @@ for (let tab of tabs) { } } +let isInviteURL = window.invites.isInviteURL(); +let isAccountURL = accounts.isAccountURL(); + // Default tab if ((window.URLBase + "/").includes(window.location.pathname)) { window.tabs.switch(defaultTab.url, true); @@ -146,7 +156,9 @@ document.addEventListener("tab-change", (event: CustomEvent) => { const urlParams = new URLSearchParams(window.location.search); const lang = urlParams.get('lang'); let tab = window.URLBase + "/" + event.detail; - if (tab == window.URLBase + "/invites") { + if (event.detail == "") { + tab = window.location.pathname; + } else if (tab == window.URLBase + "/invites") { if (window.location.pathname == window.URLBase + "/") { tab = window.URLBase + "/"; } else if (window.URLBase) { tab = window.URLBase; } @@ -167,6 +179,7 @@ const login = new Login(window.modals.login as Modal, "/", window.loginAppearanc login.onLogin = () => { console.log("Logged in."); window.updater = new Updater(); + // FIXME: Decide whether to autoload activity or not setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000); const currentTab = window.tabs.current; switch (currentTab) { @@ -179,6 +192,23 @@ login.onLogin = () => { case "settings": settings.reload(); break; + case "activity": // FIXME: fix URL clash with route + activity.reload(); + break; + default: + console.log(isAccountURL, isInviteURL); + if (isInviteURL) { + window.invites.reload(() => { + window.invites.loadInviteURL(); + window.tabs.switch("invites", false, true); + }); + } else if (isAccountURL) { + accounts.reload(() => { + accounts.loadAccountURL(); + window.tabs.switch("accounts", false, true); + }); + } + break; } } diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 34595ad..982b971 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -3,6 +3,7 @@ import { templateEmail } from "../modules/settings.js"; import { Marked } from "@ts-stack/markdown"; import { stripMarkdown } from "../modules/stripmd.js"; import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; +import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; const dateParser = require("any-date-parser"); interface User { @@ -39,7 +40,7 @@ interface announcementTemplate { var addDiscord: (passData: string) => void; -class user implements User { +class user implements User, SearchableItem { private _id = ""; private _row: HTMLTableRowElement; private _check: HTMLInputElement; @@ -73,6 +74,8 @@ class user implements User { private _referralsEnabled: boolean; private _referralsEnabledCheck: HTMLElement; + focus = () => this._row.scrollIntoView({ behavior: "smooth", block: "center" }); + lastNotifyMethod = (): string => { // Telegram, Matrix, Discord const telegram = window.telegramEnabled && this._telegramUsername && this._telegramUsername != ""; @@ -269,7 +272,7 @@ class user implements User {
- `; + `; (this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix; } else { this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused"); @@ -780,13 +783,13 @@ export class accountsList { private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement; private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement; private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement; - private _search = document.getElementById("accounts-search") as HTMLInputElement; + private _searchBox = document.getElementById("accounts-search") as HTMLInputElement; + private _search: Search; private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement; private _users: { [id: string]: user }; private _ordering: string[] = []; private _checkCount: number = 0; - private _inSearch = false; // Whether the enable/disable button should enable or not. private _shouldEnable = false; @@ -836,7 +839,7 @@ export class accountsList { } } - private _queries: { [field: string]: { name: string, getter: string, bool: boolean, string: boolean, date: boolean, dependsOnTableHeader?: string, show?: boolean }} = { + private _queries: { [field: string]: QueryType } = { "id": { // We don't use a translation here to circumvent the name substitution feature. name: "Jellyfin/Emby ID", @@ -887,7 +890,7 @@ export class accountsList { bool: true, string: false, date: false, - dependsOnTableHeader: "accounts-header-access-jfa" + dependsOnElement: ".accounts-header-access-jfa" }, "email": { name: window.lang.strings("emailAddress"), @@ -895,7 +898,7 @@ export class accountsList { bool: true, string: true, date: false, - dependsOnTableHeader: "accounts-header-email" + dependsOnElement: ".accounts-header-email" }, "telegram": { name: "Telegram", @@ -903,7 +906,7 @@ export class accountsList { bool: true, string: true, date: false, - dependsOnTableHeader: "accounts-header-telegram" + dependsOnElement: ".accounts-header-telegram" }, "matrix": { name: "Matrix", @@ -911,7 +914,7 @@ export class accountsList { bool: true, string: true, date: false, - dependsOnTableHeader: "accounts-header-matrix" + dependsOnElement: ".accounts-header-matrix" }, "discord": { name: "Discord", @@ -919,7 +922,7 @@ export class accountsList { bool: true, string: true, date: false, - dependsOnTableHeader: "accounts-header-discord" + dependsOnElement: ".accounts-header-discord" }, "expiry": { name: window.lang.strings("expiry"), @@ -927,7 +930,7 @@ export class accountsList { bool: true, string: false, date: true, - dependsOnTableHeader: "accounts-header-expiry" + dependsOnElement: ".accounts-header-expiry" }, "last-active": { name: window.lang.strings("lastActiveTime"), @@ -942,229 +945,12 @@ export class accountsList { bool: true, string: false, date: false, - dependsOnTableHeader: "accounts-header-referrals" + dependsOnElement: ".accounts-header-referrals" } } private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found"); - search = (query: String): string[] => { - console.log(this._queries); - this._filterArea.textContent = ""; - - query = query.toLowerCase(); - let result: string[] = [...this._ordering]; - // console.log("initial:", result); - - // const words = query.split(" "); - let words: string[] = []; - - let quoteSymbol = ``; - let queryStart = -1; - let lastQuote = -1; - for (let i = 0; i < query.length; i++) { - if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) { - queryStart = i; - } - if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) { - if (lastQuote != -1) { - lastQuote = -1; - quoteSymbol = ``; - } else { - lastQuote = i; - quoteSymbol = query[i]; - } - } - - if (query[i] == " " || i == query.length-1) { - if (lastQuote != -1) { - continue; - } else { - let end = i+1; - if (query[i] == " ") { - end = i; - while (i+1 < query.length && query[i+1] == " ") { - i += 1; - } - } - words.push(query.substring(queryStart, end).replace(/['"]/g, "")); - console.log("pushed", words); - queryStart = -1; - } - } - } - - query = ""; - for (let word of words) { - if (!word.includes(":")) { - let cachedResult = [...result]; - for (let id of cachedResult) { - const u = this._users[id]; - if (!u.matchesSearch(word)) { - result.splice(result.indexOf(id), 1); - } - } - continue; - } - const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)]; - - if (!(split[0] in this._queries)) continue; - - const queryFormat = this._queries[split[0]]; - - if (queryFormat.bool) { - let isBool = false; - let boolState = false; - if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") { - isBool = true; - boolState = true; - } else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") { - isBool = true; - boolState = false; - } - if (isBool) { - const filterCard = document.createElement("span"); - filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); - filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full"); - filterCard.innerHTML = ` - ${queryFormat.name} - - `; - - filterCard.addEventListener("click", () => { - for (let quote of [`"`, `'`, ``]) { - this._search.value = this._search.value.replace(split[0] + ":" + quote + split[1] + quote, ""); - } - this._search.oninput((null as Event)); - }) - - this._filterArea.appendChild(filterCard); - - // console.log("is bool, state", boolState); - // So removing elements doesn't affect us - let cachedResult = [...result]; - for (let id of cachedResult) { - const u = this._users[id]; - const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u); - // console.log("got", queryFormat.getter + ":", value); - // Remove from result if not matching query - if (!((value && boolState) || (!value && !boolState))) { - // console.log("not matching, result is", result); - result.splice(result.indexOf(id), 1); - } - } - continue - } - } - if (queryFormat.string) { - const filterCard = document.createElement("span"); - filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); - filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full"); - filterCard.innerHTML = ` - ${queryFormat.name}: "${split[1]}" - `; - - filterCard.addEventListener("click", () => { - for (let quote of [`"`, `'`, ``]) { - let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig"); - this._search.value = this._search.value.replace(regex, ""); - } - this._search.oninput((null as Event)); - }) - - this._filterArea.appendChild(filterCard); - - let cachedResult = [...result]; - for (let id of cachedResult) { - const u = this._users[id]; - const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u); - if (!(value.includes(split[1]))) { - result.splice(result.indexOf(id), 1); - } - } - continue; - } - if (queryFormat.date) { - // -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0 - let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2)); - let unmodifiedValue = split[1]; - if (compareType != 2) { - split[1] = split[1].substring(1); - } - if (compareType == 2) compareType = 0; - - let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]); - // Month in Date objects is 0-based, so make our parsed date that way too - if ("month" in attempt) attempt.month -= 1; - - let date: Date = (Date as any).fromString(split[1]) as Date; - console.log("Read", attempt, "and", date); - if ("invalid" in (date as any)) continue; - - const filterCard = document.createElement("span"); - filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter"); - filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full"); - filterCard.innerHTML = ` - ${queryFormat.name}: ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]} - `; - - filterCard.addEventListener("click", () => { - for (let quote of [`"`, `'`, ``]) { - let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig"); - this._search.value = this._search.value.replace(regex, ""); - } - - this._search.oninput((null as Event)); - }) - - this._filterArea.appendChild(filterCard); - - let cachedResult = [...result]; - for (let id of cachedResult) { - const u = this._users[id]; - const unixValue = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u); - if (unixValue == 0) { - result.splice(result.indexOf(id), 1); - continue; - } - let value = new Date(unixValue*1000); - - const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]]; - - // When doing > or <