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