From 69dcaf37978d7f4ab6755db623ace57c72343c8c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 19 Oct 2023 17:59:34 +0100 Subject: [PATCH 01/23] activity: Add initial data structure --- storage.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/storage.go b/storage.go index 18af06d..567b477 100644 --- a/storage.go +++ b/storage.go @@ -21,6 +21,36 @@ type telegramStore map[string]TelegramUser type matrixStore map[string]MatrixUser type emailStore map[string]EmailAddress +type ActivityType int + +const ( + ActivityCreation ActivityType = iota // FIXME + ActivityDeletion // FIXME + ActivityDisabled // FIXME + ActivityEnabled // FIXME + ActivityLinked // FIXME + ActivityChangePassword // FIXME + ActivityResetPassword // FIXME + ActivityCreateInvite // FIXME +) + +type ActivitySource int + +const ( + ActivityUser ActivitySource = iota // Source = UserID. For ActivityCreation, this would mean the referrer. + ActivityAdmin // Source = Admin's UserID, or simply just "admin" + ActivityAnon // Source = Blank, or potentially browser info. For ActivityCreation, this would be via an invite +) + +type Activity struct { + Type ActivityType `badgerhold:"index"` + UserID string + SourceType ActivitySource + Source string + InviteCode string // Only set for ActivityCreation + Time time.Time +} + type UserExpiry struct { JellyfinID string `badgerhold:"key"` Expiry time.Time From 2c787b4d46aa4a104321ae73a9262ea035917b48 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 19 Oct 2023 18:14:40 +0100 Subject: [PATCH 02/23] activity: log creations --- api-users.go | 28 ++++++++++++++++++++++++++++ storage.go | 42 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/api-users.go b/api-users.go index f735bda..11702f2 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,16 @@ 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"), + Time: time.Now(), + }) + profile := app.storage.GetDefaultProfile() if req.Profile != "" && req.Profile != "none" { if p, ok := app.storage.GetProfileKey(req.Profile); ok { @@ -303,6 +314,23 @@ 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, + Time: time.Now(), + }) + emailStore := EmailAddress{ Addr: req.Email, Contact: (req.Email != ""), diff --git a/storage.go b/storage.go index 567b477..9534d13 100644 --- a/storage.go +++ b/storage.go @@ -24,14 +24,15 @@ type emailStore map[string]EmailAddress type ActivityType int const ( - ActivityCreation ActivityType = iota // FIXME - ActivityDeletion // FIXME - ActivityDisabled // FIXME - ActivityEnabled // FIXME - ActivityLinked // FIXME - ActivityChangePassword // FIXME - ActivityResetPassword // FIXME - ActivityCreateInvite // FIXME + ActivityCreation ActivityType = iota + ActivityDeletion + ActivityDisabled + ActivityEnabled + ActivityLinked + ActivityChangePassword + ActivityResetPassword + ActivityCreateInvite + ActivityDeleteInvite ) type ActivitySource int @@ -544,6 +545,31 @@ 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) { + 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"` From b620c0d9ae2c63025c705f58b5902388f2a9b501 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 19 Oct 2023 18:56:35 +0100 Subject: [PATCH 03/23] activity: implement most initial logging resetPassword, changePassword, delete/createInvite, enable/disable, creation/deletion of invites & users are all done, only remaining one is account linking. --- api-invites.go | 39 +++++++++++++++++++++++++++++++++++++++ api-userpage.go | 10 ++++++++++ api-users.go | 24 ++++++++++++++++++++++++ api.go | 11 +++++++++++ storage.go | 9 +++++---- userdaemon.go | 14 ++++++++++++++ views.go | 17 +++++++++++++++++ 7 files changed, 120 insertions(+), 4 deletions(-) diff --git a/api-invites.go b/api-invites.go index 14c5f45..e6d92f4 100644 --- a/api-invites.go +++ b/api-invites.go @@ -85,6 +85,13 @@ func (app *appContext) checkInvites() { wait.Wait() } app.storage.DeleteInvitesKey(data.Code) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityDeleteInvite, + SourceType: ActivityDaemon, + InviteCode: data.Code, + Time: time.Now(), + }) } } @@ -130,12 +137,24 @@ 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, + 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, + Time: time.Now(), + }) } else if newInv.RemainingUses != 0 { // 0 means infinite i guess? newInv.RemainingUses-- @@ -236,6 +255,17 @@ 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, + Time: time.Now(), + }) + respondBool(200, true, gc) } @@ -433,6 +463,15 @@ func (app *appContext) DeleteInvite(gc *gin.Context) { _, 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"), + Time: time.Now(), + }) + app.info.Printf("%s: Invite deleted", req.Code) respondBool(200, true, gc) return diff --git a/api-userpage.go b/api-userpage.go index 0df5653..5ccb46d 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" ) @@ -620,6 +621,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 11702f2..0d2eb06 100644 --- a/api-users.go +++ b/api-users.go @@ -567,6 +567,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 { @@ -581,6 +585,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) @@ -642,6 +656,16 @@ 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"), + 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) 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/storage.go b/storage.go index 9534d13..0f00db0 100644 --- a/storage.go +++ b/storage.go @@ -38,14 +38,15 @@ const ( type ActivitySource int const ( - ActivityUser ActivitySource = iota // Source = UserID. For ActivityCreation, this would mean the referrer. - ActivityAdmin // Source = Admin's UserID, or simply just "admin" - ActivityAnon // Source = Blank, or potentially browser info. For ActivityCreation, this would be via an invite + 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 { Type ActivityType `badgerhold:"index"` - UserID string + UserID string // ID of target user. For account creation, this will be the newly created account SourceType ActivitySource Source string InviteCode string // Only set for ActivityCreation diff --git a/userdaemon.go b/userdaemon.go index de0e0ce..5ac7dcd 100644 --- a/userdaemon.go +++ b/userdaemon.go @@ -4,6 +4,7 @@ import ( "time" "github.com/hrfee/mediabrowser" + "github.com/lithammer/shortuuid/v3" ) type userDaemon struct { @@ -95,18 +96,31 @@ func (app *appContext) checkUsers() { continue } app.info.Printf("%s expired user \"%s\"", termPlural, user.Name) + + // Record activity + activity := Activity{ + UserID: id, + SourceType: ActivityDaemon, + Time: time.Now(), + } + if mode == "delete" { status, err = app.jf.DeleteUser(id) + activity.Type = ActivityDeletion } else if mode == "disable" { user.Policy.IsDisabled = true // Admins can't be disabled user.Policy.IsAdministrator = false status, err = app.jf.SetPolicy(id, user.Policy) + activity.Type = ActivityDisabled } if !(status == 200 || status == 204) || err != nil { app.err.Printf("Failed to %s \"%s\" (%d): %s", mode, user.Name, status, err) continue } + + app.storage.SetActivityKey(shortuuid.New(), activity) + app.storage.DeleteUserExpiryKey(expiry.JellyfinID) app.jf.CacheExpiry = time.Now() if contact { diff --git a/views.go b/views.go index 7e56900..2d91e21 100644 --- a/views.go +++ b/views.go @@ -17,6 +17,7 @@ import ( "github.com/golang-jwt/jwt" "github.com/gomarkdown/markdown" "github.com/hrfee/mediabrowser" + "github.com/lithammer/shortuuid/v3" "github.com/steambap/captcha" ) @@ -329,6 +330,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) { } username = pwr.Username } + if (status == 200 || status == 204) && err == nil && (isInternal || resp.Success) { data["success"] = true data["pin"] = pin @@ -338,6 +340,21 @@ func (app *appContext) ResetPassword(gc *gin.Context) { } else { app.err.Printf("Password Reset failed (%d): %v", status, err) } + + // Only log PWRs we know the user for. + if username != "" { + jfUser, status, err := app.jf.UserByName(username, false) + if err == nil && status == 200 { + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityResetPassword, + UserID: jfUser.ID, + SourceType: ActivityUser, + Source: jfUser.ID, + Time: time.Now(), + }) + } + } + if app.config.Section("ombi").Key("enabled").MustBool(false) { jfUser, status, err := app.jf.UserByName(username, false) if status != 200 || err != nil { From 9d1c7bba6f7efcd6ffd0b868657a2b95e5599f4a Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 19 Oct 2023 21:13:00 +0100 Subject: [PATCH 04/23] activity: log account link/unlinks --- api-messages.go | 42 +++++++++++++++++++++++++++++ api-userpage.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ api-users.go | 11 ++++++++ storage.go | 4 ++- 4 files changed, 126 insertions(+), 1 deletion(-) 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 5ccb46d..97684ec 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -208,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 { @@ -360,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) } @@ -398,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) } @@ -469,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) } @@ -481,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) } @@ -492,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) } @@ -503,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) } diff --git a/api-users.go b/api-users.go index 0d2eb06..fd0b5a7 100644 --- a/api-users.go +++ b/api-users.go @@ -381,6 +381,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) } @@ -1149,6 +1150,16 @@ func (app *appContext) ModifyEmails(gc *gin.Context) { emailStore.Addr = address app.storage.SetEmailsKey(id, emailStore) + + app.storage.SetActivityKey(shortuuid.New(), Activity{ + Type: ActivityContactLinked, + 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/storage.go b/storage.go index 0f00db0..39f31c6 100644 --- a/storage.go +++ b/storage.go @@ -28,7 +28,8 @@ const ( ActivityDeletion ActivityDisabled ActivityEnabled - ActivityLinked + ActivityContactLinked + ActivityContactUnlinked ActivityChangePassword ActivityResetPassword ActivityCreateInvite @@ -50,6 +51,7 @@ type Activity struct { SourceType ActivitySource Source string InviteCode string // Only set for ActivityCreation + Value string // Used for ActivityContactLinked, "email/discord/telegram/matrix" Time time.Time } From df1581d48e9c4433cdf71396ff2d2c9d44260035 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 19 Oct 2023 22:10:42 +0100 Subject: [PATCH 05/23] activity: route to show activity activity log filterable by type, sortable by time, and paginated. --- api-activities.go | 141 ++++++++++++++++++++++++++++++++++++++++++++++ main.go | 3 + models.go | 22 ++++++++ router.go | 2 + storage.go | 3 + 5 files changed, 171 insertions(+) create mode 100644 api-activities.go diff --git a/api-activities.go b/api-activities.go new file mode 100644 index 0000000..d6fdd60 --- /dev/null +++ b/api-activities.go @@ -0,0 +1,141 @@ +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 [get] +// @Security Bearer +// @tags Activity +func (app *appContext) GetActivities(gc *gin.Context) { + req := GetActivitiesDTO{} + gc.BindJSON(&req) + query := &badgerhold.Query{} + activityType := stringToActivityType(req.Type) + if activityType != ActivityUnknown { + query = badgerhold.Where("Type").Eq(activityType) + } + + 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)), + } + + 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(), + } + } + + gc.JSON(200, resp) +} 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..aa77f2f 100644 --- a/models.go +++ b/models.go @@ -430,3 +430,25 @@ 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"` + SourceType string `json:"source_type"` + Source string `json:"source"` + InviteCode string `json:"invite_code"` + Value string `json:"value"` + Time int64 `json:"time"` +} + +type GetActivitiesDTO struct { + Type string `json:"type"` // Type 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"` +} diff --git a/router.go b/router.go index 875ad20..edcbfdb 100644 --- a/router.go +++ b/router.go @@ -232,6 +232,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile) } + api.GET(p+"/activity", app.GetActivities) + if userPageEnabled { user.GET("/details", app.MyDetails) user.POST("/contact", app.SetMyContactMethods) diff --git a/storage.go b/storage.go index 39f31c6..5d5af8d 100644 --- a/storage.go +++ b/storage.go @@ -34,6 +34,7 @@ const ( ActivityResetPassword ActivityCreateInvite ActivityDeleteInvite + ActivityUnknown ) type ActivitySource int @@ -46,6 +47,7 @@ const ( ) 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 @@ -562,6 +564,7 @@ func (st *Storage) GetActivityKey(k string) (Activity, bool) { // 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) From 5a0677bac87989b70ce57274431556c63a7102b1 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 19 Oct 2023 22:44:27 +0100 Subject: [PATCH 06/23] activity: allow multiple types in route filter --- api-activities.go | 9 +++-- html/admin.html | 88 +++++++++++++++++++++++++++++++++++++++++++ lang/admin/en-us.json | 1 + models.go | 8 ++-- 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/api-activities.go b/api-activities.go index d6fdd60..305a66f 100644 --- a/api-activities.go +++ b/api-activities.go @@ -96,9 +96,12 @@ func (app *appContext) GetActivities(gc *gin.Context) { req := GetActivitiesDTO{} gc.BindJSON(&req) query := &badgerhold.Query{} - activityType := stringToActivityType(req.Type) - if activityType != ActivityUnknown { - query = badgerhold.Where("Type").Eq(activityType) + 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 { diff --git a/html/admin.html b/html/admin.html index 48818a9..6ea2dfb 100644 --- a/html/admin.html +++ b/html/admin.html @@ -475,6 +475,7 @@
{{ .strings.invites }} {{ .strings.accounts }} + {{ .strings.activity }} {{ .strings.settings }}
@@ -719,6 +720,93 @@ +
+
+
+ {{ .strings.activity }} + + + +
+ +
+ + +
+
{{ .strings.actions }}
+
+ {{ .quantityStrings.addUser.Singular }} + + {{ .strings.modifySettings }} + {{ if .referralsEnabled }} + {{ .strings.enableReferrals }} + {{ end }} + {{ .strings.extendExpiry }} + + {{ .strings.sendPWR }} + {{ .quantityStrings.deleteUser.Singular }} +
+
+ + + + + + {{ if .jellyfinLogin }} + + {{ end }} + + {{ if .telegramEnabled }} + + {{ end }} + {{ if .matrixEnabled }} + + {{ end }} + {{ if .discordEnabled }} + + {{ end }} + {{ if .referralsEnabled }} + + {{ end }} + + + + + +
{{ .strings.username }}{{ .strings.accessJFA }}{{ .strings.emailAddress }}TelegramMatrixDiscord{{ .strings.referrals }}{{ .strings.expiry }}{{ .strings.lastActiveTime }}
+
+
+ {{ .strings.noResultsFound }} + +
+
+
+
+
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 32f473f..e343d1b 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", diff --git a/models.go b/models.go index aa77f2f..0a4afa6 100644 --- a/models.go +++ b/models.go @@ -443,10 +443,10 @@ type ActivityDTO struct { } type GetActivitiesDTO struct { - Type string `json:"type"` // Type of activity to get. Leave blank for all. - Limit int `json:"limit"` - Page int `json:"page"` // zero-indexed - Ascending bool `json:"ascending"` + 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 { From 274324557ce9f2a5901e391486bab44e72182e10 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 20 Oct 2023 00:06:10 +0100 Subject: [PATCH 07/23] activity: start stubbed out example card, beginning frontend code completely broken, just need to commit so I can move between devices. --- html/admin.html | 58 ++++++++++------------- lang/admin/en-us.json | 3 +- ts/admin.ts | 5 ++ ts/modules/activity.ts | 102 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 35 deletions(-) create mode 100644 ts/modules/activity.ts diff --git a/html/admin.html b/html/admin.html index 6ea2dfb..40e6037 100644 --- a/html/admin.html +++ b/html/admin.html @@ -768,40 +768,30 @@ {{ .strings.sendPWR }} {{ .quantityStrings.deleteUser.Singular }}
-
- - - - - - {{ if .jellyfinLogin }} - - {{ end }} - - {{ if .telegramEnabled }} - - {{ end }} - {{ if .matrixEnabled }} - - {{ end }} - {{ if .discordEnabled }} - - {{ end }} - {{ if .referralsEnabled }} - - {{ end }} - - - - - -
{{ .strings.username }}{{ .strings.accessJFA }}{{ .strings.emailAddress }}TelegramMatrixDiscord{{ .strings.referrals }}{{ .strings.expiry }}{{ .strings.lastActiveTime }}
-
-
- {{ .strings.noResultsFound }} - +
+
+
+ + +
+ + {{ .strings.aboutProgram }} + {{ .strings.userProfiles }} +
+
+
+
+ Account Created: "hrfee" + 26/10/23 14:32 +
+
+
+ From InviteBdBmpGDzuJhHSsboAsYgrE +
+
+ Referrerusername +
+
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index e343d1b..c9cffef 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -130,7 +130,8 @@ "userPagePage": "User Page: Page", "buildTime": "Build Time", "builtBy": "Built By", - "loginNotAdmin": "Not an Admin?" + "loginNotAdmin": "Not an Admin?", + "referrer": "Referrer" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", diff --git a/ts/admin.ts b/ts/admin.ts index 2fe99c7..78010d1 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -120,6 +120,10 @@ const tabs: { url: string, reloader: () => void }[] = [ url: "accounts", reloader: accounts.reload }, + { + url: "activity", + reloader: () => {console.log("FIXME: Reload Activity")} + }, { url: "settings", reloader: settings.reload @@ -179,6 +183,7 @@ login.onLogin = () => { case "settings": settings.reload(); break; + // FIXME: Reload activity } } diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts new file mode 100644 index 0000000..14e7fef --- /dev/null +++ b/ts/modules/activity.ts @@ -0,0 +1,102 @@ +export interface activity { + id: string; + type: string; + user_id: string; + source_type: string; + source: string; + invite_code: string; + value: string; + time: number; +} + +var activityTypeMoods = { + "creation": 1, + "deletion": -1, + "disabled": -1, + "enabled": 1, + "contactLinked": 1, + "contactUnlinked": -1, + "changePassword": 0, + "resetPassword": 0, + "createInvite": 1, + "deleteInvite": -1 +}; + +var moodColours = ["~warning", "~neutral", "~urge"]; + +export class Activity { // FIXME: Add "implements" + private _card: HTMLElement; + private _title: HTMLElement; + private _time: HTMLElement; + private _sourceType: HTMLElement; + private _source: HTMLElement; + private _referrer: HTMLElement; + private _act: activity; + + get type(): string { return this._act.type; } + set type(v: string) { + this._act.type = v; + + let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative + + for (let i = 0; i < moodColours.length; i++) { + if (i-1 == mood) this._card.classList.add(moodColours[i]); + else this._card.classList.remove(moodColours[i]); + } + } + + get source_type(): string { return this._act.source_type; } + set source_type(v: string) { + this._act.source_type = v; + if (v == "user") { + if (this.type == "creation") { + this._referrer.innerHTML = `${window.lang.strings("referrer")}FIXME`; + } else if (this.type == "contactLinked" || this.type == "contactUnlinked" || this.type == "changePassword" || this.type == "resetPassword") { + // FIXME: Reflect in title + } + } else if (v == "admin") { + // FIXME: Handle contactLinked/Unlinked, creation/deletion, enable/disable, createInvite/deleteInvite + } else if (v == "anon") { + this._referrer.innerHTML = ``; + } else if (v == "daemon") { + // FIXME: Handle deleteInvite, disabled, deletion + } + } + + constructor(act: activity) { + this._card = document.createElement("div"); + + this._card.classList.add("card", "@low"); + this._card.innerHTML = ` +
+ + +
+
+
+ +
+
+ +
+
+ `; + + this._title = this._card.querySelector(".activity-title"); + this._time = this._card.querySelector(".activity-time"); + this._sourceType = this._card.querySelector(".activity-source-type"); + this._source = this._card.querySelector(".activity-source"); + this._referrer = this._card.querySelector(".activity-referrer"); + + this.update(act); + } + + update = (act: activity) => { + // FIXME + this._act = act; + this.type = act.type; + } + + asElement = () => { return this._card; }; +} + From a73dfddd3f50e7a613e29cf822b0013b0eac0a75 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 20 Oct 2023 18:14:32 +0100 Subject: [PATCH 08/23] activity: partially functional frontend code doesn't fill in all the blanks yet, but almost there ish. Filters & stuff not done yet, just loads everything. --- api-invites.go | 1 + html/admin.html | 2 +- lang/admin/en-us.json | 18 +++++- storage.go | 2 +- ts/admin.ts | 10 +++- ts/modules/activity.ts | 128 ++++++++++++++++++++++++++++++++++++----- 6 files changed, 142 insertions(+), 19 deletions(-) diff --git a/api-invites.go b/api-invites.go index e6d92f4..356c15a 100644 --- a/api-invites.go +++ b/api-invites.go @@ -263,6 +263,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) { SourceType: ActivityAdmin, Source: gc.GetString("jfId"), InviteCode: invite.Code, + Value: invite.Label, Time: time.Now(), }) diff --git a/html/admin.html b/html/admin.html index 40e6037..81ffce3 100644 --- a/html/admin.html +++ b/html/admin.html @@ -778,7 +778,7 @@ {{ .strings.aboutProgram }} {{ .strings.userProfiles }}
-
+
Account Created: "hrfee" diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index c9cffef..75673a9 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -55,6 +55,8 @@ "reset": "Reset", "donate": "Donate", "unlink": "Unlink Account", + "deleted": "Deleted", + "disabled": "Disabled", "sendPWR": "Send Password Reset", "noResultsFound": "No Results Found", "contactThrough": "Contact through:", @@ -131,7 +133,20 @@ "buildTime": "Build Time", "builtBy": "Built By", "loginNotAdmin": "Not an Admin?", - "referrer": "Referrer" + "referrer": "Referrer", + "accountLinked": "{user}: {contactMethod} linked", + "accountUnlinked": "{user}: {contactMethod} removed", + "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}" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", @@ -168,6 +183,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/storage.go b/storage.go index 5d5af8d..bfee54e 100644 --- a/storage.go +++ b/storage.go @@ -53,7 +53,7 @@ type Activity struct { SourceType ActivitySource Source string InviteCode string // Only set for ActivityCreation - Value string // Used for ActivityContactLinked, "email/discord/telegram/matrix" + Value string // Used for ActivityContactLinked, "email/discord/telegram/matrix", and Create/DeleteInvite, where it's the label. Time time.Time } diff --git a/ts/admin.ts b/ts/admin.ts index 78010d1..1a8109f 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(); @@ -122,7 +125,7 @@ const tabs: { url: string, reloader: () => void }[] = [ }, { url: "activity", - reloader: () => {console.log("FIXME: Reload Activity")} + reloader: activity.reload }, { url: "settings", @@ -171,6 +174,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) { @@ -183,7 +187,9 @@ login.onLogin = () => { case "settings": settings.reload(); break; - // FIXME: Reload activity + case "activity": // FIXME: fix URL clash with route + activity.reload(); + break; } } diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 14e7fef..104af07 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -1,3 +1,5 @@ +import { _get, toDateString } from "../modules/common.js"; + export interface activity { id: string; type: string; @@ -28,9 +30,11 @@ export class Activity { // FIXME: Add "implements" private _card: HTMLElement; private _title: HTMLElement; private _time: HTMLElement; + private _timeUnix: number; private _sourceType: HTMLElement; private _source: HTMLElement; private _referrer: HTMLElement; + private _expiryTypeBadge: HTMLElement; private _act: activity; get type(): string { return this._act.type; } @@ -43,24 +47,87 @@ export class Activity { // FIXME: Add "implements" if (i-1 == mood) this._card.classList.add(moodColours[i]); else this._card.classList.remove(moodColours[i]); } + + if (this.type == "changePassword" || this.type == "resetPassword") { + let innerHTML = ``; + if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword"); + else innerHTML = window.lang.strings("accountResetPassword"); + innerHTML = innerHTML.replace("{user}", `FIXME`); + this._title.innerHTML = innerHTML; + } else if (this.type == "contactLinked" || this.type == "contactUnlinked") { + let platform = this._act.type; + if (platform == "email") { + platform = window.lang.strings("emailAddress"); + } else { + platform = platform.charAt(0).toUpperCase() + platform.slice(1); + } + let innerHTML = ``; + if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked"); + else innerHTML = window.lang.strings("accountUnlinked"); + innerHTML = innerHTML.replace("{user}", `FIXME`).replace("{contactMethod}", platform); + this._title.innerHTML = innerHTML; + } else if (this.type == "creation") { + this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", `FIXME`); + if (this.source_type == "user") { + this._referrer.innerHTML = `${window.lang.strings("referrer")}FIXME`; + } else { + this._referrer.textContent = ``; + } + } else if (this.type == "deletion") { + if (this.source_type == "daemon") { + this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `FIXME`); + this._expiryTypeBadge.classList.add("~critical"); + this._expiryTypeBadge.classList.remove("~warning"); + this._expiryTypeBadge.textContent = window.lang.strings("deleted"); + } else { + this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", `FIXME`); + } + } else if (this.type == "enabled") { + this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", `FIXME`); + } else if (this.type == "disabled") { + if (this.source_type == "daemon") { + this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `FIXME`); + this._expiryTypeBadge.classList.add("~warning"); + this._expiryTypeBadge.classList.remove("~critical"); + this._expiryTypeBadge.textContent = window.lang.strings("disabled"); + } else { + this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", `FIXME`); + } + } else if (this.type == "createInvite") { + this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", `${this.value || this.invite_code}`); + } else if (this.type == "deleteInvite") { + + this._title.innerHTML = window.lang.strings("inviteDeleted").replace("{invite}", this.value || this.invite_code); + } + + /*} else if (this.source_type == "admin") { + // FIXME: Handle contactLinked/Unlinked, creation/deletion, enable/disable, createInvite/deleteInvite + } else if (this.source_type == "anon") { + this._referrer.innerHTML = ``; + } else if (this.source_type == "daemon") { + // FIXME: Handle deleteInvite, disabled, deletion + }*/ + } + + get time(): number { return this._timeUnix; } + set time(v: number) { + this._timeUnix = v; + this._time.textContent = toDateString(new Date(v*1000)); } get source_type(): string { return this._act.source_type; } set source_type(v: string) { this._act.source_type = v; - if (v == "user") { - if (this.type == "creation") { - this._referrer.innerHTML = `${window.lang.strings("referrer")}FIXME`; - } else if (this.type == "contactLinked" || this.type == "contactUnlinked" || this.type == "changePassword" || this.type == "resetPassword") { - // FIXME: Reflect in title - } - } else if (v == "admin") { - // FIXME: Handle contactLinked/Unlinked, creation/deletion, enable/disable, createInvite/deleteInvite - } else if (v == "anon") { - this._referrer.innerHTML = ``; - } else if (v == "daemon") { - // FIXME: Handle deleteInvite, disabled, deletion - } + } + + get invite_code(): string { return this._act.invite_code; } + set invite_code(v: string) { + this._act.invite_code = v; + } + + get value(): string { return this._act.value; } + set value(v: string) { + this._act.value = v; } constructor(act: activity) { @@ -69,7 +136,7 @@ export class Activity { // FIXME: Add "implements" this._card.classList.add("card", "@low"); this._card.innerHTML = `
- +
@@ -87,6 +154,7 @@ export class Activity { // FIXME: Add "implements" this._sourceType = this._card.querySelector(".activity-source-type"); this._source = this._card.querySelector(".activity-source"); this._referrer = this._card.querySelector(".activity-referrer"); + this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type"); this.update(act); } @@ -94,9 +162,41 @@ export class Activity { // FIXME: Add "implements" update = (act: activity) => { // FIXME this._act = act; + this.source_type = act.source_type; + this.invite_code = act.invite_code; + this.value = act.value; this.type = act.type; } asElement = () => { return this._card; }; } +interface ActivitiesDTO { + activities: activity[]; +} + +export class activityList { + private _activityList: HTMLElement; + + reload = () => { + _get("/activity", null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status != 200) { + window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities")); + return; + } + + let resp = req.response as ActivitiesDTO; + this._activityList.textContent = ``; + + for (let act of resp.activities) { + const activity = new Activity(act); + this._activityList.appendChild(activity.asElement()); + } + }); + } + + constructor() { + this._activityList = document.getElementById("activity-card-list"); + } +} From 1032e4e7473d6b2de4f72c7fc7da59865bede4a8 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 20 Oct 2023 22:16:40 +0100 Subject: [PATCH 09/23] activity: more presentable cards, fixes fixed some missing data (being stored and being shown), improved layout, also usernames are now injected by the route. --- api-activities.go | 12 ++++- api-invites.go | 8 +++- html/admin.html | 2 +- lang/admin/en-us.json | 7 +-- models.go | 18 ++++---- router.go | 2 +- storage.go | 2 +- ts/modules/activity.ts | 99 ++++++++++++++++++++++++++++++++---------- userdaemon.go | 6 +-- 9 files changed, 113 insertions(+), 43 deletions(-) diff --git a/api-activities.go b/api-activities.go index 305a66f..7620307 100644 --- a/api-activities.go +++ b/api-activities.go @@ -89,7 +89,7 @@ func activitySourceToString(v ActivitySource) string { // @Produce json // @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters" // @Success 200 {object} GetActivitiesRespDTO -// @Router /activity [get] +// @Router /activity [post] // @Security Bearer // @tags Activity func (app *appContext) GetActivities(gc *gin.Context) { @@ -138,6 +138,16 @@ func (app *appContext) GetActivities(gc *gin.Context) { Value: act.Value, Time: act.Time.Unix(), } + user, status, err := app.jf.UserByID(act.UserID, false) + if 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) diff --git a/api-invites.go b/api-invites.go index 356c15a..c537a9c 100644 --- a/api-invites.go +++ b/api-invites.go @@ -90,6 +90,7 @@ func (app *appContext) checkInvites() { Type: ActivityDeleteInvite, SourceType: ActivityDaemon, InviteCode: data.Code, + Value: data.Label, Time: time.Now(), }) } @@ -141,6 +142,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool Type: ActivityDeleteInvite, SourceType: ActivityDaemon, InviteCode: code, + Value: inv.Label, Time: time.Now(), }) } else if used { @@ -153,6 +155,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool Type: ActivityDeleteInvite, SourceType: ActivityDaemon, InviteCode: code, + Value: inv.Label, Time: time.Now(), }) } else if newInv.RemainingUses != 0 { @@ -460,8 +463,7 @@ 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) @@ -470,6 +472,8 @@ func (app *appContext) DeleteInvite(gc *gin.Context) { Type: ActivityDeleteInvite, SourceType: ActivityAdmin, Source: gc.GetString("jfId"), + InviteCode: req.Code, + Value: inv.Label, Time: time.Now(), }) diff --git a/html/admin.html b/html/admin.html index 81ffce3..6dd523e 100644 --- a/html/admin.html +++ b/html/admin.html @@ -778,7 +778,7 @@ {{ .strings.aboutProgram }} {{ .strings.userProfiles }}
-
+
Account Created: "hrfee" diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 75673a9..d81b750 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -134,8 +134,8 @@ "builtBy": "Built By", "loginNotAdmin": "Not an Admin?", "referrer": "Referrer", - "accountLinked": "{user}: {contactMethod} linked", - "accountUnlinked": "{user}: {contactMethod} removed", + "accountLinked": "{contactMethod} linked: {user}", + "accountUnlinked": "{contactMethod} removed: {user}", "accountResetPassword": "{user} reset their password", "accountChangedPassword": "{user} changed their password", "accountCreated": "Account created: {user}", @@ -146,7 +146,8 @@ "userDeleted": "User was deleted.", "userDisabled": "User was disabled", "inviteCreated": "Invite created: {invite}", - "inviteDeleted": "Invite deleted: {invite}" + "inviteDeleted": "Invite deleted: {invite}", + "inviteExpired": "Invite expired: {invite}" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", diff --git a/models.go b/models.go index 0a4afa6..b8573fe 100644 --- a/models.go +++ b/models.go @@ -432,14 +432,16 @@ type EnableDisableReferralDTO struct { } type ActivityDTO struct { - ID string `json:"id"` - Type string `json:"type"` - UserID string `json:"user_id"` - SourceType string `json:"source_type"` - Source string `json:"source"` - InviteCode string `json:"invite_code"` - Value string `json:"value"` - Time int64 `json:"time"` + 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 { diff --git a/router.go b/router.go index edcbfdb..db34361 100644 --- a/router.go +++ b/router.go @@ -232,7 +232,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile) } - api.GET(p+"/activity", app.GetActivities) + api.POST(p+"/activity", app.GetActivities) if userPageEnabled { user.GET("/details", app.MyDetails) diff --git a/storage.go b/storage.go index bfee54e..a3984a4 100644 --- a/storage.go +++ b/storage.go @@ -52,7 +52,7 @@ type Activity struct { UserID string // ID of target user. For account creation, this will be the newly created account SourceType ActivitySource Source string - InviteCode string // Only set for ActivityCreation + InviteCode string // Set for ActivityCreation, create/deleteInvite Value string // Used for ActivityContactLinked, "email/discord/telegram/matrix", and Create/DeleteInvite, where it's the label. Time time.Time } diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 104af07..940518e 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -1,4 +1,4 @@ -import { _get, toDateString } from "../modules/common.js"; +import { _post, toDateString } from "../modules/common.js"; export interface activity { id: string; @@ -9,6 +9,8 @@ export interface activity { invite_code: string; value: string; time: number; + username: string; + source_username: string; } var activityTypeMoods = { @@ -37,25 +39,50 @@ export class Activity { // FIXME: Add "implements" private _expiryTypeBadge: HTMLElement; private _act: activity; + _genUserLink = (): string => { + return `${this._act.username || this._act.user_id.substring(0, 5)}`; + } + + _genSrcUserLink = (): string => { + return `${this._act.source_username || this._act.source.substring(0, 5)}`; + } + + private _renderInvText = (): string => { return `${this.value || this.invite_code || "???"}`; } + + private _genInvLink = (): string => { + return `${this._renderInvText()}`; + } + get type(): string { return this._act.type; } set type(v: string) { this._act.type = v; let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative + this._card.classList.remove("~warning"); + this._card.classList.remove("~neutral"); + this._card.classList.remove("~urge"); - for (let i = 0; i < moodColours.length; i++) { + if (mood == -1) { + this._card.classList.add("~warning"); + } else if (mood == 0) { + this._card.classList.add("~neutral"); + } else if (mood == 1) { + this._card.classList.add("~urge"); + } + + /* for (let i = 0; i < moodColours.length; i++) { if (i-1 == mood) this._card.classList.add(moodColours[i]); else this._card.classList.remove(moodColours[i]); - } + } */ if (this.type == "changePassword" || this.type == "resetPassword") { let innerHTML = ``; if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword"); else innerHTML = window.lang.strings("accountResetPassword"); - innerHTML = innerHTML.replace("{user}", `FIXME`); + innerHTML = innerHTML.replace("{user}", this._genUserLink()); this._title.innerHTML = innerHTML; } else if (this.type == "contactLinked" || this.type == "contactUnlinked") { - let platform = this._act.type; + let platform = this.value; if (platform == "email") { platform = window.lang.strings("emailAddress"); } else { @@ -64,40 +91,46 @@ export class Activity { // FIXME: Add "implements" let innerHTML = ``; if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked"); else innerHTML = window.lang.strings("accountUnlinked"); - innerHTML = innerHTML.replace("{user}", `FIXME`).replace("{contactMethod}", platform); + innerHTML = innerHTML.replace("{user}", this._genUserLink()).replace("{contactMethod}", platform); this._title.innerHTML = innerHTML; } else if (this.type == "creation") { - this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", `FIXME`); + this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", this._genUserLink()); if (this.source_type == "user") { - this._referrer.innerHTML = `${window.lang.strings("referrer")}FIXME`; + this._referrer.innerHTML = `${window.lang.strings("referrer")}${this._genSrcUserLink()}`; } else { this._referrer.textContent = ``; } } else if (this.type == "deletion") { if (this.source_type == "daemon") { - this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `FIXME`); + this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink()); this._expiryTypeBadge.classList.add("~critical"); - this._expiryTypeBadge.classList.remove("~warning"); + this._expiryTypeBadge.classList.remove("~info"); this._expiryTypeBadge.textContent = window.lang.strings("deleted"); } else { - this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", `FIXME`); + this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserLink()); } } else if (this.type == "enabled") { - this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", `FIXME`); + this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", this._genUserLink()); } else if (this.type == "disabled") { if (this.source_type == "daemon") { - this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `FIXME`); - this._expiryTypeBadge.classList.add("~warning"); + this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink()); + this._expiryTypeBadge.classList.add("~info"); this._expiryTypeBadge.classList.remove("~critical"); this._expiryTypeBadge.textContent = window.lang.strings("disabled"); } else { - this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", `FIXME`); + this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", this._genUserLink()); } } else if (this.type == "createInvite") { - this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", `${this.value || this.invite_code}`); + this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", this._genInvLink()); } else if (this.type == "deleteInvite") { + let innerHTML = ``; + if (this.source_type == "daemon") { + innerHTML = window.lang.strings("inviteExpired"); + } else { + innerHTML = window.lang.strings("inviteDeleted"); + } - this._title.innerHTML = window.lang.strings("inviteDeleted").replace("{invite}", this.value || this.invite_code); + this._title.innerHTML = innerHTML.replace("{invite}", this._renderInvText()); } /*} else if (this.source_type == "admin") { @@ -130,14 +163,22 @@ export class Activity { // FIXME: Add "implements" this._act.value = v; } + get source(): string { return this._act.source; } + set source(v: string) { + this._act.source = v; + } + constructor(act: activity) { this._card = document.createElement("div"); - this._card.classList.add("card", "@low"); + this._card.classList.add("card", "@low", "my-2"); this._card.innerHTML = ` -
- - +
+ +
+ + +
@@ -156,6 +197,10 @@ export class Activity { // FIXME: Add "implements" this._referrer = this._card.querySelector(".activity-referrer"); this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type"); + document.addEventListener("timefmt-change", () => { + this.time = this.time; + }); + this.update(act); } @@ -164,6 +209,8 @@ export class Activity { // FIXME: Add "implements" this._act = act; this.source_type = act.source_type; this.invite_code = act.invite_code; + this.time = act.time; + this.source = act.source; this.value = act.value; this.type = act.type; } @@ -179,7 +226,13 @@ export class activityList { private _activityList: HTMLElement; reload = () => { - _get("/activity", null, (req: XMLHttpRequest) => { + let send = { + "type": [], + "limit": 30, + "page": 0, + "ascending": false + } + _post("/activity", send, (req: XMLHttpRequest) => { if (req.readyState != 4) return; if (req.status != 200) { window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities")); @@ -193,7 +246,7 @@ export class activityList { const activity = new Activity(act); this._activityList.appendChild(activity.asElement()); } - }); + }, true); } constructor() { diff --git a/userdaemon.go b/userdaemon.go index 5ac7dcd..0e5aa47 100644 --- a/userdaemon.go +++ b/userdaemon.go @@ -61,10 +61,10 @@ func (app *appContext) checkUsers() { return } mode := "disable" - termPlural := "Disabling" + term := "Disabling" if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" { mode = "delete" - termPlural = "Deleting" + term = "Deleting" } contact := false if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) { @@ -95,7 +95,7 @@ func (app *appContext) checkUsers() { app.storage.DeleteUserExpiryKey(expiry.JellyfinID) continue } - app.info.Printf("%s expired user \"%s\"", termPlural, user.Name) + app.info.Printf("%s expired user \"%s\"", term, user.Name) // Record activity activity := Activity{ From 44172074b94effe50230f298c89ef3529f72fb69 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 21 Oct 2023 12:53:53 +0100 Subject: [PATCH 10/23] activity: render all activities correctly the activity type, usernames, time, referrer, and invite code are displayed correctly for all types of activity. --- api-activities.go | 9 ++++++--- api-users.go | 15 ++++++++++++++- lang/admin/en-us.json | 6 +++++- storage.go | 2 +- ts/modules/activity.ts | 34 ++++++++++++++++++++++++++++------ userdaemon.go | 1 + 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/api-activities.go b/api-activities.go index 7620307..f4ca573 100644 --- a/api-activities.go +++ b/api-activities.go @@ -138,12 +138,15 @@ func (app *appContext) GetActivities(gc *gin.Context) { Value: act.Value, Time: act.Time.Unix(), } - user, status, err := app.jf.UserByID(act.UserID, false) - if status == 200 && err == nil { + 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) + user, status, err := app.jf.UserByID(act.Source, false) if status == 200 && err == nil { resp.Activities[i].SourceUsername = user.Name } diff --git a/api-users.go b/api-users.go index fd0b5a7..e8dd451 100644 --- a/api-users.go +++ b/api-users.go @@ -53,6 +53,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { UserID: id, SourceType: ActivityAdmin, Source: gc.GetString("jfId"), + Value: user.Name, Time: time.Now(), }) @@ -328,6 +329,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc SourceType: sourceType, Source: source, InviteCode: invite.Code, + Value: user.Name, Time: time.Now(), }) @@ -648,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) @@ -664,6 +672,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { UserID: userID, SourceType: ActivityAdmin, Source: gc.GetString("jfId"), + Value: username, Time: time.Now(), }) @@ -1151,8 +1160,12 @@ 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: ActivityContactLinked, + Type: activityType, UserID: id, SourceType: ActivityAdmin, Source: gc.GetString("jfId"), diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index d81b750..0779ab5 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -147,7 +147,11 @@ "userDisabled": "User was disabled", "inviteCreated": "Invite created: {invite}", "inviteDeleted": "Invite deleted: {invite}", - "inviteExpired": "Invite expired: {invite}" + "inviteExpired": "Invite expired: {invite}", + "fromInvite": "From Invite", + "byAdmin": "By Admin", + "byUser": "By User", + "byJfaGo": "By jfa-go" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", diff --git a/storage.go b/storage.go index a3984a4..4ccf7de 100644 --- a/storage.go +++ b/storage.go @@ -53,7 +53,7 @@ type Activity struct { SourceType ActivitySource Source string InviteCode string // Set for ActivityCreation, create/deleteInvite - Value string // Used for ActivityContactLinked, "email/discord/telegram/matrix", and Create/DeleteInvite, where it's the label. + 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 } diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 940518e..7e1a69f 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -39,12 +39,20 @@ export class Activity { // FIXME: Add "implements" private _expiryTypeBadge: HTMLElement; private _act: activity; + _genUserText = (): string => { + return `${this._act.username || this._act.user_id.substring(0, 5)}`; + } + + _genSrcUserText = (): string => { + return `${this._act.source_username || this._act.source.substring(0, 5)}`; + } + _genUserLink = (): string => { - return `${this._act.username || this._act.user_id.substring(0, 5)}`; + return `${this._genUserText()}`; } _genSrcUserLink = (): string => { - return `${this._act.source_username || this._act.source.substring(0, 5)}`; + return `${this._genSrcUserText()}`; } private _renderInvText = (): string => { return `${this.value || this.invite_code || "???"}`; } @@ -102,12 +110,12 @@ export class Activity { // FIXME: Add "implements" } } else if (this.type == "deletion") { if (this.source_type == "daemon") { - this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink()); + this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserText()); this._expiryTypeBadge.classList.add("~critical"); this._expiryTypeBadge.classList.remove("~info"); this._expiryTypeBadge.textContent = window.lang.strings("deleted"); } else { - this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserLink()); + this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserText()); } } else if (this.type == "enabled") { this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", this._genUserLink()); @@ -151,6 +159,15 @@ export class Activity { // FIXME: Add "implements" get source_type(): string { return this._act.source_type; } set source_type(v: string) { this._act.source_type = v; + if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") { + this._sourceType.textContent = window.lang.strings("fromInvite"); + } else if (this.source_type == "admin") { + this._sourceType.textContent = window.lang.strings("byAdmin"); + } else if (this.source_type == "user" && this.type != "creation") { + this._sourceType.textContent = window.lang.strings("byUser"); + } else if (this.source_type == "daemon") { + this._sourceType.textContent = window.lang.strings("byJfaGo"); + } } get invite_code(): string { return this._act.invite_code; } @@ -166,6 +183,11 @@ export class Activity { // FIXME: Add "implements" get source(): string { return this._act.source; } set source(v: string) { this._act.source = v; + if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") { + this._source.innerHTML = this._genInvLink(); + } else if ((this.source_type == "admin" || this.source_type == "user") && this._act.source != "" && this._act.source_username != "") { + this._source.innerHTML = this._genSrcUserLink(); + } } constructor(act: activity) { @@ -180,7 +202,7 @@ export class Activity { // FIXME: Add "implements"
-
+
@@ -228,7 +250,7 @@ export class activityList { reload = () => { let send = { "type": [], - "limit": 30, + "limit": 60, "page": 0, "ascending": false } diff --git a/userdaemon.go b/userdaemon.go index 0e5aa47..af3b860 100644 --- a/userdaemon.go +++ b/userdaemon.go @@ -107,6 +107,7 @@ func (app *appContext) checkUsers() { if mode == "delete" { status, err = app.jf.DeleteUser(id) activity.Type = ActivityDeletion + activity.Value = user.Name } else if mode == "disable" { user.Policy.IsDisabled = true // Admins can't be disabled From 3cad30a8e5428a6182c2920d069bc7d9c8432190 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 21 Oct 2023 13:38:11 +0100 Subject: [PATCH 11/23] activity: add delete button --- api-activities.go | 12 ++++++++++++ lang/admin/en-us.json | 1 + router.go | 1 + ts/modules/activity.ts | 42 +++++++++++++++++++++++++++++++----------- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/api-activities.go b/api-activities.go index f4ca573..db13c95 100644 --- a/api-activities.go +++ b/api-activities.go @@ -155,3 +155,15 @@ func (app *appContext) GetActivities(gc *gin.Context) { 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) +} diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 0779ab5..6b9e16f 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -167,6 +167,7 @@ "telegramVerified": "Telegram account verified.", "accountConnected": "Account connected.", "referralsEnabled": "Referrals enabled.", + "activityDeleted": "Activity Deleted.", "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.", diff --git a/router.go b/router.go index db34361..eda497e 100644 --- a/router.go +++ b/router.go @@ -233,6 +233,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { } api.POST(p+"/activity", app.GetActivities) + api.DELETE(p+"/activity/:id", app.DeleteActivity) if userPageEnabled { user.GET("/details", app.MyDetails) diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 7e1a69f..16146e5 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -1,4 +1,4 @@ -import { _post, toDateString } from "../modules/common.js"; +import { _post, _delete, toDateString } from "../modules/common.js"; export interface activity { id: string; @@ -28,6 +28,8 @@ var activityTypeMoods = { var moodColours = ["~warning", "~neutral", "~urge"]; +export var activityReload = new CustomEvent("activity-reload"); + export class Activity { // FIXME: Add "implements" private _card: HTMLElement; private _title: HTMLElement; @@ -37,6 +39,7 @@ export class Activity { // FIXME: Add "implements" private _source: HTMLElement; private _referrer: HTMLElement; private _expiryTypeBadge: HTMLElement; + private _delete: HTMLElement; private _act: activity; _genUserText = (): string => { @@ -66,16 +69,18 @@ export class Activity { // FIXME: Add "implements" this._act.type = v; let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative - this._card.classList.remove("~warning"); - this._card.classList.remove("~neutral"); - this._card.classList.remove("~urge"); - - if (mood == -1) { - this._card.classList.add("~warning"); - } else if (mood == 0) { - this._card.classList.add("~neutral"); - } else if (mood == 1) { - this._card.classList.add("~urge"); + for (let el of [this._card, this._delete]) { + el.classList.remove("~warning"); + el.classList.remove("~neutral"); + el.classList.remove("~urge"); + + if (mood == -1) { + el.classList.add("~warning"); + } else if (mood == 0) { + el.classList.add("~neutral"); + } else if (mood == 1) { + el.classList.add("~urge"); + } } /* for (let i = 0; i < moodColours.length; i++) { @@ -209,6 +214,9 @@ export class Activity { // FIXME: Add "implements"
+
+ +
`; @@ -218,11 +226,14 @@ export class Activity { // FIXME: Add "implements" this._source = this._card.querySelector(".activity-source"); this._referrer = this._card.querySelector(".activity-referrer"); this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type"); + this._delete = this._card.querySelector(".activity-delete"); document.addEventListener("timefmt-change", () => { this.time = this.time; }); + this._delete.addEventListener("click", this.delete); + this.update(act); } @@ -237,6 +248,14 @@ export class Activity { // FIXME: Add "implements" this.type = act.type; } + delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status == 200) { + window.notifications.customSuccess("activityDeleted", window.lang.notif("activityDeleted")); + } + document.dispatchEvent(activityReload); + }); + asElement = () => { return this._card; }; } @@ -273,5 +292,6 @@ export class activityList { constructor() { this._activityList = document.getElementById("activity-card-list"); + document.addEventListener("activity-reload", this.reload); } } From 4fa0630aef3e41995e3c5c100205b1af160192d6 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 21 Oct 2023 14:33:09 +0100 Subject: [PATCH 12/23] accounts: modularize search now part of ts/modules/search.ts, UI of the activity page is gonna be very similar so it made sense to. --- ts/modules/accounts.ts | 304 ++++++----------------------------------- ts/modules/search.ts | 299 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 265 deletions(-) create mode 100644 ts/modules/search.ts diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 34595ad..cb1c757 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; @@ -269,7 +270,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 +781,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 +837,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 +888,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 +896,7 @@ export class accountsList { bool: true, string: true, date: false, - dependsOnTableHeader: "accounts-header-email" + dependsOnElement: ".accounts-header-email" }, "telegram": { name: "Telegram", @@ -903,7 +904,7 @@ export class accountsList { bool: true, string: true, date: false, - dependsOnTableHeader: "accounts-header-telegram" + dependsOnElement: ".accounts-header-telegram" }, "matrix": { name: "Matrix", @@ -911,7 +912,7 @@ export class accountsList { bool: true, string: true, date: false, - dependsOnTableHeader: "accounts-header-matrix" + dependsOnElement: ".accounts-header-matrix" }, "discord": { name: "Discord", @@ -919,7 +920,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 +928,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 +943,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 <
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index bf95874..8ff10bb 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -59,6 +59,8 @@ "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.", @@ -169,6 +171,7 @@ "inviteCreatedFilter": "Invite Created", "inviteDeletedFilter": "Invite Deleted/Expired", "loadMore": "Load More", + "loadAll": "Load All", "noMoreResults": "No more results." }, "notifications": { diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 5bd9d14..982b971 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -1829,7 +1829,7 @@ export class accountsList { queries: this._queries, setVisibility: this.setVisibility, clearSearchButtonSelector: ".accounts-search-clear", - onSearchCallback: (_0: number, _1: boolean) => { + onSearchCallback: (_0: number, _1: boolean, _2: boolean) => { this._checkCheckCount(); } }; diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 10fa9aa..abf285e 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -352,7 +352,11 @@ export class activityList { private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement; private _loader = document.getElementById("activity-loader"); private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement; + private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement; private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement; + private _keepSearchingDescription = document.getElementById("activity-keep-searching-description"); + private _keepSearchingButton = document.getElementById("activity-keep-searching"); + private _search: Search; private _ascending: boolean; private _hasLoaded: boolean; @@ -375,6 +379,10 @@ export class activityList { reload = () => { this._lastLoad = Date.now(); this._lastPage = false; + this._loadMoreButton.textContent = window.lang.strings("loadMore"); + this._loadMoreButton.disabled = false; + this._loadAllButton.classList.remove("unfocused"); + this._loadAllButton.disabled = false; // this._page = 0; let limit = 10; if (this._page != 0) { @@ -414,17 +422,23 @@ export class activityList { if (this._search.inSearch) { this._search.onSearchBoxChange(true); + this._loadAllButton.classList.remove("unfocused"); } else { this.setVisibility(this._ordering, true); + this._loadAllButton.classList.add("unfocused"); this._notFoundPanel.classList.add("unfocused"); } }, true); } - loadMore = () => { + loadMore = (callback?: () => void, loadAll: boolean = false) => { this._lastLoad = Date.now(); this._loadMoreButton.disabled = true; - const timeout = setTimeout(() => this._loadMoreButton.disabled = false, 1000); + // this._loadAllButton.disabled = true; + const timeout = setTimeout(() => { + this._loadMoreButton.disabled = false; + // this._loadAllButton.disabled = false; + }, 1000); this._page += 1; let send = { @@ -450,6 +464,8 @@ export class activityList { if (this._lastPage) { clearTimeout(timeout); this._loadMoreButton.disabled = true; + removeLoader(this._loadAllButton); + this._loadAllButton.classList.add("unfocused"); this._loadMoreButton.textContent = window.lang.strings("noMoreResults"); } @@ -460,12 +476,17 @@ export class activityList { // this._search.items = this._activities; // this._search.ordering = this._ordering; - if (this._search.inSearch) { - this._search.onSearchBoxChange(true); + if (this._search.inSearch || loadAll) { + if (this._lastPage) { + loadAll = false; + } + this._search.onSearchBoxChange(true, loadAll); } else { this.setVisibility(this._ordering, true); this._notFoundPanel.classList.add("unfocused"); } + + if (callback) callback(); // removeLoader(this._loader); // this._activityList.classList.remove("unfocused"); }, true); @@ -601,14 +622,27 @@ export class activityList { // console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight); if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) { // window.notifications.customSuccess("scroll", "Reached bottom."); - // Wait 1s between loads - if (this._lastLoad + 1000 > Date.now()) return; + // Wait .5s between loads + if (this._lastLoad + 500 > Date.now()) return; this.loadMore(); } } private _prevResultCount = 0; + private _notFoundCallback = (notFound: boolean) => { + if (notFound) this._loadMoreButton.classList.add("unfocused"); + else this._loadMoreButton.classList.remove("unfocused"); + + if (notFound && !this._lastPage) { + this._keepSearchingButton.classList.remove("unfocused"); + this._keepSearchingDescription.classList.remove("unfocused"); + } else { + this._keepSearchingButton.classList.add("unfocused"); + this._keepSearchingDescription.classList.add("unfocused"); + } + }; + constructor() { this._activityList = document.getElementById("activity-card-list"); document.addEventListener("activity-reload", this.reload); @@ -623,10 +657,13 @@ export class activityList { queries: this._queries, setVisibility: this.setVisibility, filterList: document.getElementById("activity-filter-list"), - onSearchCallback: (visibleCount: number, newItems: boolean) => { + // notFoundCallback: this._notFoundCallback, + onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => { + if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused"); + else this._loadAllButton.classList.add("unfocused"); if (visibleCount < 10) { - if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage)) this.loadMore(); + if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll); } this._prevResultCount = visibleCount; } @@ -638,7 +675,15 @@ export class activityList { this.ascending = false; this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending); - this._loadMoreButton.onclick = this.loadMore; + this._loadMoreButton.onclick = () => this.loadMore(); + this._loadAllButton.onclick = () => { + addLoader(this._loadAllButton, true); + this.loadMore(() => {}, true); + }; + /* this._keepSearchingButton.onclick = () => { + addLoader(this._keepSearchingButton, true); + this.loadMore(() => removeLoader(this._keepSearchingButton, true)); + }; */ this._refreshButton.onclick = this.reload; window.onscroll = this.detectScroll; diff --git a/ts/modules/search.ts b/ts/modules/search.ts index 5aa9cc2..b311b56 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -16,12 +16,13 @@ export interface SearchConfiguration { sortingByButton: HTMLButtonElement; searchOptionsHeader: HTMLElement; notFoundPanel: HTMLElement; + notFoundCallback?: (notFound: boolean) => void; filterList: HTMLElement; clearSearchButtonSelector: string; search: HTMLInputElement; queries: { [field: string]: QueryType }; setVisibility: (items: string[], visible: boolean) => void; - onSearchCallback: (visibleCount: number, newItems: boolean) => void; + onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void; loadMore?: () => void; } @@ -268,7 +269,7 @@ export class Search { get ordering(): string[] { return this._ordering; } set ordering(v: string[]) { this._ordering = v; } - onSearchBoxChange = (newItems: boolean = false) => { + onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => { const query = this._c.search.value; if (!query) { this.inSearch = false; @@ -277,13 +278,14 @@ export class Search { } const results = this.search(query); this._c.setVisibility(results, true); - this._c.onSearchCallback(results.length, newItems); + this._c.onSearchCallback(results.length, newItems, loadAll); this.showHideSearchOptionsHeader(); if (results.length == 0) { this._c.notFoundPanel.classList.remove("unfocused"); } else { this._c.notFoundPanel.classList.add("unfocused"); } + if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0); } fillInFilter = (name: string, value: string, offset?: number) => { From 663389693f189ac8cda086600148b390d766b070 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 23 Oct 2023 11:34:04 +0100 Subject: [PATCH 20/23] activity: add counter for total, loaded and shown total: number of activities in the DB loaded: How many the web UI has loaded shown: How many are shown (differs when in a search). --- api-activities.go | 16 +++++++++++++++ html/admin.html | 9 ++++++++- lang/admin/en-us.json | 5 ++++- models.go | 4 ++++ router.go | 1 + ts/modules/activity.ts | 44 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 76 insertions(+), 3 deletions(-) diff --git a/api-activities.go b/api-activities.go index 71fb092..929a6f5 100644 --- a/api-activities.go +++ b/api-activities.go @@ -168,3 +168,19 @@ 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/html/admin.html b/html/admin.html index fc87e01..df6cfdf 100644 --- a/html/admin.html +++ b/html/admin.html @@ -737,7 +737,14 @@
- +
+ +
+ + + +
+
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index 8ff10bb..769ff1e 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -172,7 +172,10 @@ "inviteDeletedFilter": "Invite Deleted/Expired", "loadMore": "Load More", "loadAll": "Load All", - "noMoreResults": "No more results." + "noMoreResults": "No more results.", + "totalRecords": "{n} Total Records", + "loadedRecords": "{n} Loaded", + "shownRecords": "{n} Shown" }, "notifications": { "changedEmailAddress": "Changed email address of {n}.", diff --git a/models.go b/models.go index e24899b..f24d2f9 100644 --- a/models.go +++ b/models.go @@ -455,3 +455,7 @@ 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 a50acab..6c1e8e1 100644 --- a/router.go +++ b/router.go @@ -237,6 +237,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { 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) diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index abf285e..255d239 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -1,4 +1,4 @@ -import { _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js"; +import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js"; import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; import { accountURLEvent } from "../modules/accounts.js"; import { inviteURLEvent } from "../modules/invites.js"; @@ -357,6 +357,32 @@ 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 _search: Search; private _ascending: boolean; private _hasLoaded: boolean; @@ -383,6 +409,11 @@ export class activityList { this._loadMoreButton.disabled = false; this._loadAllButton.classList.remove("unfocused"); this._loadAllButton.disabled = false; + + this.total = 0; + this.loaded = 0; + this.shown = 0; + // this._page = 0; let limit = 10; if (this._page != 0) { @@ -396,6 +427,11 @@ 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; if (req.status != 200) { @@ -420,6 +456,8 @@ export class activityList { this._search.items = this._activities; this._search.ordering = this._ordering; + this.loaded = this._ordering.length; + if (this._search.inSearch) { this._search.onSearchBoxChange(true); this._loadAllButton.classList.remove("unfocused"); @@ -476,6 +514,8 @@ export class activityList { // this._search.items = this._activities; // this._search.ordering = this._ordering; + this.loaded = this._ordering.length; + if (this._search.inSearch || loadAll) { if (this._lastPage) { loadAll = false; @@ -659,6 +699,8 @@ export class activityList { filterList: document.getElementById("activity-filter-list"), // notFoundCallback: this._notFoundCallback, onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => { + this.shown = visibleCount; + if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused"); else this._loadAllButton.classList.add("unfocused"); From 44d7e173e3bd8393e35b915bc44860669dd8477c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 23 Oct 2023 12:49:28 +0100 Subject: [PATCH 21/23] activity: add limiting settings limit to keeping n most recent logs, and/or logs younger than {n} days in settings > Activity Log. --- config.go | 3 +++ config/config-base.json | 25 +++++++++++++++++++++++ daemon.go | 44 +++++++++++++++++++++++++++++++++++++---- ts/modules/activity.ts | 2 +- 4 files changed, 69 insertions(+), 5 deletions(-) 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/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/ts/modules/activity.ts b/ts/modules/activity.ts index 255d239..ebdc43f 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -704,7 +704,7 @@ export class activityList { if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused"); else this._loadAllButton.classList.add("unfocused"); - if (visibleCount < 10) { + if (visibleCount < 10 || loadAll) { if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll); } this._prevResultCount = visibleCount; From 3951116bdc8cac4b9f961eee1e6ea209e78edcc6 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 23 Oct 2023 18:18:08 +0100 Subject: [PATCH 22/23] activity: reload invites on link click --- ts/modules/activity.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index ebdc43f..a579263 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -305,9 +305,11 @@ export class Activity implements activity, SearchableItem { for (let i = 0; i < pseudoInvites.length; i++) { const navigate = (event: Event) => { event.preventDefault(); - window.tabs.switch("invites"); - document.dispatchEvent(inviteURLEvent(pseudoInvites[i].getAttribute("data-id"))); - window.history.pushState(null, document.title, pseudoInvites[i].getAttribute("data-href")); + window.invites.reload(() => { + window.tabs.switch("invites"); + document.dispatchEvent(inviteURLEvent(pseudoInvites[i].getAttribute("data-id"))); + window.history.pushState(null, document.title, pseudoInvites[i].getAttribute("data-href")); + }); } pseudoInvites[i].onclick = navigate; pseudoInvites[i].onkeydown = navigate; From 3739634b632396bbf866ca70a10767394756aef0 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 23 Oct 2023 18:36:32 +0100 Subject: [PATCH 23/23] activity: fix "shown" counter when not in search --- ts/modules/activity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index a579263..6bf2f2f 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -464,6 +464,7 @@ export class activityList { this._search.onSearchBoxChange(true); this._loadAllButton.classList.remove("unfocused"); } else { + this.shown = this.loaded; this.setVisibility(this._ordering, true); this._loadAllButton.classList.add("unfocused"); this._notFoundPanel.classList.add("unfocused");