From 41dff3d5bbe4e4efc859bbddc5e5d180e69964a1 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 15 Mar 2025 14:53:53 +0000 Subject: [PATCH 001/249] user: fix welcome message sent value on NewUserFromAdmin inverted since WelcomeNewUser returns a bool called "failed", rather than one indicating success. --- api-users.go | 2 +- ts/modules/accounts.ts | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/api-users.go b/api-users.go index e865b27..352366d 100644 --- a/api-users.go +++ b/api-users.go @@ -63,7 +63,7 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) { welcomeMessageSentIfNecessary := true if nu.Created { - welcomeMessageSentIfNecessary = app.WelcomeNewUser(nu.User, time.Time{}) + welcomeMessageSentIfNecessary = !app.WelcomeNewUser(nu.User, time.Time{}) } respondUser(nu.Status, nu.Created, welcomeMessageSentIfNecessary, nu.Message, gc) diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index b9da9e5..ab88af0 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -103,7 +103,7 @@ class user implements User, SearchableItem { set selected(state: boolean) { this._selected = state; this._check.checked = state; - state ? document.dispatchEvent(this._checkEvent) : document.dispatchEvent(this._uncheckEvent); + state ? document.dispatchEvent(this._checkEvent()) : document.dispatchEvent(this._uncheckEvent()); } get name(): string { return this._username.textContent; } @@ -481,8 +481,8 @@ class user implements User, SearchableItem { ); } - private _checkEvent = new CustomEvent("accountCheckEvent"); - private _uncheckEvent = new CustomEvent("accountUncheckEvent"); + private _checkEvent = () => new CustomEvent("accountCheckEvent", {detail: this.id}); + private _uncheckEvent = () => new CustomEvent("accountUncheckEvent", {detail: this.id}); constructor(user: User) { this._row = document.createElement("tr") as HTMLTableRowElement; @@ -696,7 +696,7 @@ class user implements User, SearchableItem { asElement = (): HTMLTableRowElement => { return this._row; } remove = () => { if (this.selected) { - document.dispatchEvent(this._uncheckEvent); + document.dispatchEvent(this._uncheckEvent()); } this._row.remove(); } @@ -927,6 +927,17 @@ export class accountsList { state ? this._checkCount = count : 0; } + selectAllBetweenIDs = (startID: string, endID: string) => { + let inRange = false; + for (let id of this._ordering) { + if (!(inRange || id == startID)) continue; + inRange = true; + if (!(this._table.contains(this._users[id].asElement()))) continue; + this._users[id].selected = true; + if (id == endID) return; + } + } + add = (u: User) => { let domAccount = new user(u); this._users[u.id] = domAccount; From c5f4098b5b271c8b617870f71013f1d8b9f9011b Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 13 May 2025 14:01:41 +0100 Subject: [PATCH 002/249] db: add max vlog size setting --- config.go | 2 ++ config/config-base.yaml | 13 +++++++++++++ storage.go | 1 + 3 files changed, 16 insertions(+) diff --git a/config.go b/config.go index c58f425..88b0013 100644 --- a/config.go +++ b/config.go @@ -155,6 +155,8 @@ func (app *appContext) loadConfig() error { app.MustSetValue("ui", "port", "8056") app.MustSetValue("advanced", "tls_port", "8057") + app.MustSetValue("advanced", "value_log_size", "1024") + pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"} allDisabled := true for _, v := range pwrMethods { diff --git a/config/config-base.yaml b/config/config-base.yaml index ff427d7..1da810b 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -330,6 +330,19 @@ sections: requires_restart: true type: password description: Leave blank for no Authentication. + - setting: value_log_note + name: 'Value Log:' + type: note + depends_true: enabled + required: false + description: The Value Log (vlog) file in your config folder is used to ensure data integrity in the database. It can get large, so you can adjust the maximum size. After quitting jfa-go, the file can be deleted safely without data loss. + style: info + - setting: value_log_size + name: Database max vlog size (MiB) + requires_restart: true + type: number + value: 1024 + description: Max size for the database's value log. Lower values may reduce performance. - setting: debug_log_emails name: 'Debug Storage Logging: Emails' requires_restart: true diff --git a/storage.go b/storage.go index 0a4e5cb..f9e503d 100644 --- a/storage.go +++ b/storage.go @@ -175,6 +175,7 @@ func generateLogActions(c *ini.File) map[string]DebugLogAction { func (app *appContext) ConnectDB() { opts := badgerhold.DefaultOptions + opts.Options.ValueLogFileSize = app.config.Section("advanced").Key("value_log_size").MustInt64(1024) * 1024 opts.Dir = app.storage.db_path opts.ValueDir = app.storage.db_path db, err := badgerhold.Open(opts) From d6f5c91d7892bd1fc9009deba973bf1ee978b901 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 13 May 2025 15:07:55 +0100 Subject: [PATCH 003/249] backups: add more info, keep 1 of each version opt backup's filename format has changed, and includes the commit too now. a Backup struct for going to/from the filename has been added, and the option "keep 1 backup from each version" has been added, leaving the most recent backup from each version always. All pre-this-commit backups are considered the same "old" version. --- api-backups.go | 20 ++--- backups.go | 157 +++++++++++++++++++++++++++++++++---- backups_test.go | 57 ++++++++++++++ config.go | 1 + config/config-base.yaml | 6 ++ discord.go | 2 +- email.go | 2 +- go.mod | 12 +-- go.sum | 20 ++--- html/admin.html | 1 + logmessages/logmessages.go | 1 + main.go | 2 +- models.go | 13 +-- telegram.go | 2 +- ts/modules/settings.ts | 2 + 15 files changed, 248 insertions(+), 50 deletions(-) create mode 100644 backups_test.go diff --git a/api-backups.go b/api-backups.go index 109082e..ca7e28b 100644 --- a/api-backups.go +++ b/api-backups.go @@ -4,8 +4,6 @@ import ( "os" "path/filepath" "sort" - "strings" - "time" "github.com/gin-gonic/gin" lm "github.com/hrfee/jfa-go/logmessages" @@ -33,9 +31,9 @@ func (app *appContext) CreateBackup(gc *gin.Context) { func (app *appContext) GetBackup(gc *gin.Context) { fname := gc.Param("fname") // Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess. - ok := (strings.HasPrefix(fname, BACKUP_PREFIX) || strings.HasPrefix(fname, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX)) && strings.HasSuffix(fname, BACKUP_SUFFIX) - t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(fname, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX)) - if !ok || err != nil || t.IsZero() { + b := Backup{} + err := b.FromString(fname) + if err != nil || b.Date.IsZero() { app.debug.Printf(lm.IgnoreInvalidFilename, fname, err) respondBool(400, false, gc) return @@ -62,7 +60,8 @@ func (app *appContext) GetBackups(gc *gin.Context) { resp.Backups[i].Name = item.Name() fullpath := filepath.Join(path, item.Name()) resp.Backups[i].Path = fullpath - resp.Backups[i].Date = backups.dates[i].Unix() + resp.Backups[i].Date = backups.info[i].Date.Unix() + resp.Backups[i].Commit = backups.info[i].Commit fstat, err := os.Stat(fullpath) if err == nil { resp.Backups[i].Size = fileSize(fstat.Size()) @@ -81,9 +80,9 @@ func (app *appContext) GetBackups(gc *gin.Context) { func (app *appContext) RestoreLocalBackup(gc *gin.Context) { fname := gc.Param("fname") // Hopefully this is enough to ensure the path isn't malicious. Hidden behind bearer auth anyway so shouldn't matter too much I guess. - ok := strings.HasPrefix(fname, BACKUP_PREFIX) && strings.HasSuffix(fname, BACKUP_SUFFIX) - t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(fname, BACKUP_PREFIX), BACKUP_SUFFIX)) - if !ok || err != nil || t.IsZero() { + b := Backup{} + err := b.FromString(fname) + if err != nil || b.Date.IsZero() { app.debug.Printf(lm.IgnoreInvalidFilename, fname, err) respondBool(400, false, gc) return @@ -110,7 +109,8 @@ func (app *appContext) RestoreBackup(gc *gin.Context) { } app.debug.Printf(lm.GetUpload, file.Filename) path := app.config.Section("backups").Key("path").String() - fullpath := filepath.Join(path, BACKUP_UPLOAD_PREFIX+BACKUP_PREFIX+time.Now().Local().Format(BACKUP_DATEFMT)+BACKUP_SUFFIX) + b := Backup{Upload: true} + fullpath := filepath.Join(path, b.String()) gc.SaveUploadedFile(file, fullpath) app.debug.Printf(lm.Write, fullpath) LOADBAK = fullpath diff --git a/backups.go b/backups.go index 6690454..0a1ae57 100644 --- a/backups.go +++ b/backups.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" @@ -12,35 +13,126 @@ import ( ) const ( - BACKUP_PREFIX = "jfa-go-db-" + BACKUP_PREFIX = "jfa-go-db" + BACKUP_COMMIT_PREFIX = "-c-" + BACKUP_DATE_PREFIX = "-d-" BACKUP_UPLOAD_PREFIX = "upload-" BACKUP_DATEFMT = "2006-01-02T15-04-05" BACKUP_SUFFIX = ".bak" ) +type Backup struct { + Date time.Time + Commit string + Upload bool +} + +func (b Backup) IsZero() bool { return b.Date.IsZero() && b.Commit == "" && b.Upload == false } + +func (b Backup) Equals(a Backup) bool { + return a.Date.Equal(b.Date) && a.Commit == b.Commit && a.Upload == b.Upload +} + +// Pre 21/03/25 format: "{BACKUP_PREFIX}{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-2006-01-02T15-04-05.bak" +// Post 21/03/25 format: "{BACKUP_PREFIX}-c-{commit}-d-{date in BACKUP_DATEFMT}{BACKUP_SUFFIX}" = "jfa-go-db-c-0b92060-d-2006-01-02T15-04-05.bak" + +func (b Backup) String() string { + t := b.Date + if t.IsZero() { + t = time.Now() + } + out := BACKUP_PREFIX + if b.Upload { + out = BACKUP_UPLOAD_PREFIX + out + } + if b.Commit != "" { + out += BACKUP_COMMIT_PREFIX + b.Commit + } + out += BACKUP_DATE_PREFIX + t.Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX + return out +} + +func (b *Backup) FromString(f string) error { + of := f + if strings.HasPrefix(f, BACKUP_UPLOAD_PREFIX) { + b.Upload = true + f = f[len(BACKUP_UPLOAD_PREFIX):] + } + if !strings.HasPrefix(f, BACKUP_PREFIX) { + return fmt.Errorf("file doesn't have correct prefix (\"%s\")", BACKUP_PREFIX) + } + f = f[len(BACKUP_PREFIX):] + if !strings.HasSuffix(f, BACKUP_SUFFIX) { + return fmt.Errorf("file doesn't have correct suffix (\"%s\")", BACKUP_SUFFIX) + } + for range 2 { + if strings.HasPrefix(f, BACKUP_COMMIT_PREFIX) { + f = f[len(BACKUP_COMMIT_PREFIX):] + commitEnd := strings.Index(f, BACKUP_DATE_PREFIX) + if commitEnd == -1 { + commitEnd = strings.Index(f, BACKUP_SUFFIX) + } + if commitEnd == -1 { + return fmt.Errorf("end of commit (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_DATE_PREFIX, BACKUP_PREFIX, f) + } + b.Commit = f[:commitEnd] + f = f[commitEnd:] + } else if strings.HasPrefix(f, BACKUP_DATE_PREFIX) { + f = f[len(BACKUP_DATE_PREFIX):] + dateEnd := strings.Index(f, BACKUP_COMMIT_PREFIX) + if dateEnd == -1 { + dateEnd = strings.Index(f, BACKUP_SUFFIX) + } + if dateEnd == -1 { + return fmt.Errorf("end of date (\"%s\" or \"%s\") not found in \"%s\"", BACKUP_COMMIT_PREFIX, BACKUP_PREFIX, f) + } + t, err := time.Parse(BACKUP_DATEFMT, f[:dateEnd]) + if err != nil { + return err + } + b.Date = t + f = f[dateEnd:] + } + } + if b.Date.IsZero() { + return b.FromOldString(of) + } + return nil +} + +func (b *Backup) FromOldString(f string) error { + t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(f, BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX+"-"), BACKUP_SUFFIX)) + if err != nil { + return fmt.Errorf(lm.FailedParseTime, err) + } + b.Date = t + return nil + +} + type BackupList struct { files []os.DirEntry - dates []time.Time + info []Backup count int } func (bl BackupList) Len() int { return len(bl.files) } func (bl BackupList) Swap(i, j int) { bl.files[i], bl.files[j] = bl.files[j], bl.files[i] - bl.dates[i], bl.dates[j] = bl.dates[j], bl.dates[i] + bl.info[i], bl.info[j] = bl.info[j], bl.info[i] } func (bl BackupList) Less(i, j int) bool { // Push non-backup files to the end of the array, // Since they didn't have a date parsed. - if bl.dates[i].IsZero() { + if bl.info[i].Date.IsZero() { return false } - if bl.dates[j].IsZero() { + if bl.info[j].Date.IsZero() { return true } // Sort by oldest first - return bl.dates[j].After(bl.dates[i]) + return bl.info[j].Date.After(bl.info[i].Date) } // Get human-readable file size from f.Size() result. @@ -72,18 +164,19 @@ func (app *appContext) getBackups() *BackupList { } backups := &BackupList{} backups.files = items - backups.dates = make([]time.Time, len(items)) + backups.info = make([]Backup, len(items)) backups.count = 0 for i, item := range items { + // Even though Backup{} can parse and check validity, still check if the file ends in .bak, we don't need to print an error if a file isn't a .bak. if item.IsDir() || !(strings.HasSuffix(item.Name(), BACKUP_SUFFIX)) { continue } - t, err := time.Parse(BACKUP_DATEFMT, strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(item.Name(), BACKUP_UPLOAD_PREFIX), BACKUP_PREFIX), BACKUP_SUFFIX)) - if err != nil { - app.debug.Printf(lm.FailedParseTime, err) + b := Backup{} + if err := b.FromString(item.Name()); err != nil { + app.debug.Printf(lm.FailedParseBackup, item.Name(), err) continue } - backups.dates[i] = t + backups.info[i] = b backups.count++ } return backups @@ -91,17 +184,47 @@ func (app *appContext) getBackups() *BackupList { func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) { toKeep := app.config.Section("backups").Key("keep_n_backups").MustInt(20) - fname := BACKUP_PREFIX + time.Now().Local().Format(BACKUP_DATEFMT) + BACKUP_SUFFIX + keepPreviousVersions := app.config.Section("backups").Key("keep_previous_version_backup").MustBool(true) + + b := Backup{Commit: commit} + fname := b.String() path := app.config.Section("backups").Key("path").String() backups := app.getBackups() if backups == nil { return } toDelete := backups.count + 1 - toKeep + if toDelete > 0 || keepPreviousVersions { + sort.Sort(backups) + } + backupsByCommit := map[string]int{} + if keepPreviousVersions { + // Count backups by commit + for _, b := range backups.info { + if b.IsZero() { + continue + } + // If b.Commit is empty, the backup is pre-versions-in-backup-names. + // Still use the empty string as a key, considering these as a single version. + count, ok := backupsByCommit[b.Commit] + if !ok { + count = 0 + } + count += 1 + backupsByCommit[b.Commit] = count + } + fmt.Printf("remaining:%+v\n", backupsByCommit) + } // fmt.Printf("toDelete: %d, backCount: %d, keep: %d, length: %d\n", toDelete, backups.count, toKeep, len(backups.files)) if toDelete > 0 && toDelete <= backups.count { - sort.Sort(backups) - for _, item := range backups.files[:toDelete] { + for i := range toDelete { + backupsRemaining, ok := backupsByCommit[backups.info[i].Commit] + app.debug.Println("item", backups.files[i], "remaining", backupsRemaining) + if keepPreviousVersions && ok && backupsRemaining <= 1 { + continue + } + + item := backups.files[i] fullpath := filepath.Join(path, item.Name()) err := os.Remove(fullpath) if err != nil { @@ -109,6 +232,10 @@ func (app *appContext) makeBackup() (fileDetails CreateBackupDTO) { return } app.debug.Printf(lm.DeleteOldBackup, fullpath) + if keepPreviousVersions && ok { + backupsRemaining -= 1 + backupsByCommit[backups.info[i].Commit] = backupsRemaining + } } } fullpath := filepath.Join(path, fname) @@ -140,7 +267,7 @@ func (app *appContext) loadPendingBackup() { if LOADBAK == "" { return } - oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK)) + oldPath := filepath.Join(app.dataPath, "db-"+strconv.FormatInt(time.Now().Unix(), 10)+"-pre-"+filepath.Base(LOADBAK)) err := os.Rename(app.storage.db_path, oldPath) if err != nil { app.err.Fatalf(lm.FailedMoveOldDB, oldPath, err) diff --git a/backups_test.go b/backups_test.go new file mode 100644 index 0000000..abdcda8 --- /dev/null +++ b/backups_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "testing" + "time" +) + +func testBackupParse(f string, a Backup, t *testing.T) { + b := Backup{} + err := b.FromString(f) + if err != nil { + t.Fatalf("error: %+v", err) + } + if !b.Equals(a) { + t.Fatalf("not equal: %+v != %+v", b, a) + } +} + +func TestBackupParserOld(t *testing.T) { + Q1 := BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX + A1 := Backup{} + A1.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00") + testBackupParse(Q1, A1, t) +} +func TestBackupParserOldUpload(t *testing.T) { + Q2 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX + A2 := Backup{ + Upload: true, + } + A2.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00") + testBackupParse(Q2, A2, t) +} +func TestBackupParserUploadDate(t *testing.T) { + Q3 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX + A3 := Backup{ + Upload: true, + } + A3.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00") + testBackupParse(Q3, A3, t) +} +func TestBackupParserUploadCommitDate(t *testing.T) { + Q4 := BACKUP_UPLOAD_PREFIX + BACKUP_PREFIX + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_SUFFIX + A4 := Backup{ + Commit: "testcommit", + Upload: true, + } + A4.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00") + testBackupParse(Q4, A4, t) +} +func TestBackupParserDateCommit(t *testing.T) { + Q5 := BACKUP_PREFIX + BACKUP_DATE_PREFIX + "2023-12-21T21-08-00" + BACKUP_COMMIT_PREFIX + "testcommit" + BACKUP_SUFFIX + A5 := Backup{ + Commit: "testcommit", + } + A5.Date, _ = time.Parse(BACKUP_DATEFMT, "2023-12-21T21-08-00") + testBackupParse(Q5, A5, t) +} diff --git a/config.go b/config.go index c58f425..c149084 100644 --- a/config.go +++ b/config.go @@ -141,6 +141,7 @@ func (app *appContext) loadConfig() error { app.MustSetValue("backups", "every_n_minutes", "1440") app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups")) app.MustSetValue("backups", "keep_n_backups", "20") + app.MustSetValue("backups", "keep_previous_version_backup", "true") app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("device").SetValue("jfa-go") diff --git a/config/config-base.yaml b/config/config-base.yaml index ff427d7..49e4392 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1315,6 +1315,12 @@ sections: value: 20 description: Number of most recent backups to keep. Once this is hit, the oldest backup will be deleted before doing a new one. + - setting: keep_previous_version_backup + name: Keep 1 backup from each previous version + requires_restart: true + type: bool + value: true + description: Always keep the most recent backup for each jfa-go version, incase updates mess things up. If enabled, these aren't counted by the "Number of backups to keep" setting. - section: welcome_email meta: name: Welcome Message diff --git a/discord.go b/discord.go index df102da..96fa4bc 100644 --- a/discord.go +++ b/discord.go @@ -196,7 +196,7 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU var inv *dg.Invite var err error if d.InviteChannel.Name == "" { - d.app.err.Println(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty) + d.app.err.Printf(lm.FailedCreateDiscordInviteChannel, lm.InviteChannelEmpty) return } if d.InviteChannel.ID == "" { diff --git a/email.go b/email.go index 6fb0fac..82da3f2 100644 --- a/email.go +++ b/email.go @@ -576,7 +576,7 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo // Only used in html email. template["pin_code"] = pwr.Pin } else { - app.info.Println(lm.FailedGeneratePWRLink, err) + app.info.Printf(lm.FailedGeneratePWRLink, err) template["pin"] = pwr.Pin } } else { diff --git a/go.mod b/go.mod index 08cbe88..6cd8c7a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/hrfee/jfa-go -go 1.22.0 +go 1.23.0 + +toolchain go1.24.0 replace github.com/hrfee/jfa-go/docs => ./docs @@ -131,12 +133,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.11.0 // indirect - golang.org/x/crypto v0.28.0 // indirect + golang.org/x/crypto v0.35.0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/image v0.21.0 // indirect - golang.org/x/net v0.30.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect + golang.org/x/net v0.36.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect golang.org/x/tools v0.26.0 // indirect google.golang.org/protobuf v1.35.1 // indirect ) diff --git a/go.sum b/go.sum index 2dfb31f..c263bc6 100644 --- a/go.sum +++ b/go.sum @@ -401,8 +401,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= @@ -441,8 +441,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= +golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -454,8 +454,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -483,8 +483,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -496,8 +496,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/html/admin.html b/html/admin.html index ef75925..bb10c4c 100644 --- a/html/admin.html +++ b/html/admin.html @@ -372,6 +372,7 @@ {{ .strings.name }} {{ .strings.date }} + {{ .strings.version }} {{ .strings.backupDownloadRestore }} diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index f2b9ea2..fbde1e1 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -200,6 +200,7 @@ const ( DeleteOldBackup = "Deleted old backup \"%s\"" FailedDeleteOldBackup = "Failed to delete old backup \"%s\": %v" CreateBackup = "Created database backup \"%+v\"" + FailedParseBackup = "Failed to parse backup \"%s\": %v" FailedCreateBackup = "Faled to create database backup: %v" MoveOldDB = "Moved existing database to \"%s\"" FailedMoveOldDB = "Failed to move existing database to \"%s\": %v" diff --git a/main.go b/main.go index 04d408f..d2f881c 100644 --- a/main.go +++ b/main.go @@ -171,7 +171,7 @@ func test(app *appContext) { var username string fmt.Scanln(&username) user, err := app.jf.UserByName(username, false) - fmt.Printf("UserByName (%s): code %d err %s", username, err) + fmt.Printf("UserByName (%s): err %v", username, err) out, _ := json.MarshalIndent(user, "", " ") fmt.Print(string(out)) } diff --git a/models.go b/models.go index fd24e2a..97e669b 100644 --- a/models.go +++ b/models.go @@ -39,13 +39,13 @@ type newUserResponse struct { } type deleteUserDTO struct { - Users []string `json:"users" binding:"required"` // List of usernames to delete + Users []string `json:"users" binding:"required"` // List of user IDs. Notify bool `json:"notify"` // Whether to notify users of deletion Reason string `json:"reason"` // Account deletion reason (for notification) } type enableDisableUserDTO struct { - Users []string `json:"users" binding:"required"` // List of usernames to delete + Users []string `json:"users" binding:"required"` // List of userIDs. Enabled bool `json:"enabled"` // True = enable users, False = disable. Notify bool `json:"notify"` // Whether to notify users of deletion Reason string `json:"reason"` // Account deletion reason (for notification) @@ -446,10 +446,11 @@ type GetActivityCountDTO struct { } type CreateBackupDTO struct { - Size string `json:"size"` - Name string `json:"name"` - Path string `json:"path"` - Date int64 `json:"date"` + Size string `json:"size"` + Name string `json:"name"` + Path string `json:"path"` + Date int64 `json:"date"` + Commit string `json:"commit"` } type GetBackupsDTO struct { diff --git a/telegram.go b/telegram.go index b68e91c..c7cf5fb 100644 --- a/telegram.go +++ b/telegram.go @@ -121,7 +121,7 @@ func (t *TelegramDaemon) NewAssignedAuthToken(id string) string { } func (t *TelegramDaemon) run() { - t.app.info.Println(lm.StartDaemon, lm.Telegram) + t.app.info.Printf(lm.StartDaemon, lm.Telegram) u := tg.NewUpdate(0) u.Timeout = 60 updates, err := t.bot.GetUpdatesChan(u) diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 01aa5df..c9d1459 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -13,6 +13,7 @@ interface BackupDTO { name: string; path: string; date: number; + commit: string; } interface settingsChangedEvent extends Event { @@ -731,6 +732,7 @@ export class settingsList { tr.innerHTML = ` ${b.name} ${toDateString(new Date(b.date*1000))} + ${b.commit || "?"} From dca83dcc8e3119fe7ec98912205a815e5edb3ab5 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 13 May 2025 15:24:26 +0100 Subject: [PATCH 004/249] db: reduce default vlog size to 256M --- config.go | 2 +- config/config-base.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index 2426091..ff8a29e 100644 --- a/config.go +++ b/config.go @@ -156,7 +156,7 @@ func (app *appContext) loadConfig() error { app.MustSetValue("ui", "port", "8056") app.MustSetValue("advanced", "tls_port", "8057") - app.MustSetValue("advanced", "value_log_size", "1024") + app.MustSetValue("advanced", "value_log_size", "512") pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"} allDisabled := true diff --git a/config/config-base.yaml b/config/config-base.yaml index 5fff581..39775d1 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -341,7 +341,7 @@ sections: name: Database max vlog size (MiB) requires_restart: true type: number - value: 1024 + value: 256 description: Max size for the database's value log. Lower values may reduce performance. - setting: debug_log_emails name: 'Debug Storage Logging: Emails' From 5cc97eaf17aede747cf5a249f6fc63fc5681296e Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 13 May 2025 15:32:59 +0100 Subject: [PATCH 005/249] goreleaser: fix deprecations this sucks --- .goreleaser.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index afc3531..d9b5da3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -74,9 +74,9 @@ builds: - amd64 archives: - id: windows-tray - builds: + ids: - windows-tray - format: zip + formats: [ "zip" ] name_template: >- {{ .ProjectName }}_{{ .Version }}_TrayIcon_ {{- if eq .Os "darwin" }}macOS @@ -84,9 +84,9 @@ archives: {{- if eq .Arch "amd64" }}x86_64 {{- else }}{{ .Arch }}{{ end }} - id: linux-tray - builds: + ids: - linux-tray - format: zip + formats: [ "zip" ] name_template: >- {{ .ProjectName }}_{{ .Version }}_TrayIcon_ {{- if eq .Os "darwin" }}macOS @@ -94,9 +94,9 @@ archives: {{- if eq .Arch "amd64" }}x86_64 {{- else }}{{ .Arch }}{{ end }} - id: notray - builds: + ids: - notray - format: zip + formats: [ "zip" ] name_template: >- {{ .ProjectName }}_{{ .Version }}_ {{- if eq .Os "darwin" }}macOS @@ -104,9 +104,9 @@ archives: {{- if eq .Arch "amd64" }}x86_64 {{- else }}{{ .Arch }}{{ end }} - id: notray-e2ee - builds: + ids: - notray-e2ee - format: zip + formats: [ "zip" ] name_template: >- {{ .ProjectName }}_{{ .Version }}_MatrixE2EE_ {{- if eq .Os "darwin" }}macOS @@ -133,7 +133,7 @@ nfpms: license: MIT vendor: hrfee.dev version_metadata: git - builds: + ids: - notray-e2ee contents: - src: ./LICENSE @@ -161,7 +161,7 @@ nfpms: license: MIT vendor: hrfee.dev version_metadata: git - builds: + ids: - linux-tray contents: - src: ./LICENSE From d710b9ad4deabb3dced5598f121be9f70cce5ca4 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 13 May 2025 16:58:26 +0100 Subject: [PATCH 006/249] db: fix valuelogfilesize calc for some reason I thought it was in kibibytes? no, its in bytes as it should be. --- storage.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/storage.go b/storage.go index f9e503d..bab57a3 100644 --- a/storage.go +++ b/storage.go @@ -175,7 +175,8 @@ func generateLogActions(c *ini.File) map[string]DebugLogAction { func (app *appContext) ConnectDB() { opts := badgerhold.DefaultOptions - opts.Options.ValueLogFileSize = app.config.Section("advanced").Key("value_log_size").MustInt64(1024) * 1024 + // ValueLogFileSize is in bytes, so multiply by 1e6 + opts.Options.ValueLogFileSize = app.config.Section("advanced").Key("value_log_size").MustInt64(256) * 1e6 opts.Dir = app.storage.db_path opts.ValueDir = app.storage.db_path db, err := badgerhold.Open(opts) From 2d98c6cff4f1a2e5544e3d07c3c552ef63471010 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 13 May 2025 17:31:09 +0100 Subject: [PATCH 007/249] settings: add email test note just send an announcement to yourself. --- common/common.go | 7 +++++++ config/config-base.yaml | 6 ++++++ jellyseerr/jellyseerr.go | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/common/common.go b/common/common.go index 58d0424..6f33115 100644 --- a/common/common.go +++ b/common/common.go @@ -79,6 +79,13 @@ func GenericErr(status int, err error) error { } } +func GenericErrFromResponse(resp *http.Response, err error) error { + if resp == nil { + return ErrUnknown{code: -2} + } + return GenericErr(resp.StatusCode, err) +} + type ConfigurableTransport interface { // SetTransport sets the http.Transport to use for requests. Can be used to set a proxy. SetTransport(t *http.Transport) diff --git a/config/config-base.yaml b/config/config-base.yaml index 39775d1..d429010 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -720,6 +720,12 @@ sections: type: bool value: false description: Disables using the same address on multiple accounts. + - setting: test_note + name: 'Test your settings:' + type: note + depends_true: enabled + required: false + description: Go over to the accounts tab, select your user (ensuring you've assigned it an email address) and send yourself an announcement. - section: mailgun meta: name: Mailgun (Email) diff --git a/jellyseerr/jellyseerr.go b/jellyseerr/jellyseerr.go index f7d52ca..79ddc1c 100644 --- a/jellyseerr/jellyseerr.go +++ b/jellyseerr/jellyseerr.go @@ -87,7 +87,7 @@ func (js *Jellyseerr) req(mode string, uri string, data any, queryParams url.Val } } resp, err := js.httpClient.Do(req) - err = co.GenericErr(resp.StatusCode, err) + err = co.GenericErrFromResponse(resp, err) defer js.timeoutHandler() var responseText string defer resp.Body.Close() From c52ba2162ee7def64c13e2d7e8f672c7c2734b3a Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 13 May 2025 21:10:40 +0100 Subject: [PATCH 008/249] config: start adding path parameters to change the urls of the admin page, the my account page and of invites. Seems to work, but need to check all the code over and test. --- api-messages.go | 10 +- api-userpage.go | 8 +- config.go | 25 +++- config/config-base.yaml | 27 ++++ email.go | 4 +- html/404.html | 3 +- html/admin.html | 16 +-- html/crash.html | 1 + html/create-success.html | 1 - html/form-base.html | 4 - html/form.html | 1 - html/header.html | 29 ++++- html/invalidCode.html | 1 - html/login-modal.html | 2 +- html/password-reset.html | 3 +- html/setup.html | 1 - html/user.html | 12 +- main.go | 64 +++++----- models.go | 15 +++ router.go | 34 ++--- setup.go | 2 + ts/admin.ts | 8 +- ts/form.ts | 12 +- ts/modules/account-linking.ts | 2 +- ts/modules/accounts.ts | 2 + ts/modules/activity.ts | 12 +- ts/modules/common.ts | 14 ++- ts/modules/discord.ts | 2 + ts/modules/invites.ts | 4 +- ts/modules/login.ts | 4 +- ts/modules/modal.ts | 2 +- ts/modules/pages.ts | 1 + ts/modules/profiles.ts | 2 + ts/modules/search.ts | 2 + ts/modules/settings.ts | 2 + ts/modules/tabs.ts | 4 +- ts/modules/update.ts | 2 + ts/pwr-pin.ts | 2 + ts/setup.ts | 3 +- ts/typings/d.ts | 16 ++- ts/user.ts | 13 +- user-auth.go | 3 +- views.go | 227 ++++++++++++++++------------------ 43 files changed, 336 insertions(+), 266 deletions(-) diff --git a/api-messages.go b/api-messages.go index cb4f67f..e3297b5 100644 --- a/api-messages.go +++ b/api-messages.go @@ -414,7 +414,7 @@ func (app *appContext) TelegramVerified(gc *gin.Context) { respondBool(200, ok, gc) } -// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. +// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths. // @Produce json // @Success 200 {object} boolResponse // @Success 401 {object} boolResponse @@ -438,7 +438,7 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { respondBool(200, ok, gc) } -// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. +// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths. // @Produce json // @Success 200 {object} boolResponse // @Failure 401 {object} boolResponse @@ -462,7 +462,7 @@ func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { respondBool(200, ok, gc) } -// @Summary Returns a 10-minute, one-use Discord server invite +// @Summary Returns a 10-minute, one-use Discord server invite. NOTE: "/invite" might have been changed in Settings > URL Paths. // @Produce json // @Success 200 {object} DiscordInviteDTO // @Failure 400 {object} boolResponse @@ -489,7 +489,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) { gc.JSON(200, DiscordInviteDTO{invURL, iconURL}) } -// @Summary Generate and send a new PIN to a specified Matrix user. +// @Summary Generate and send a new PIN to a specified Matrix user. NOTE: "/invite" might have been changed in Settings > URL Paths. // @Produce json // @Success 200 {object} boolResponse // @Failure 400 {object} stringResponse @@ -528,7 +528,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) { respondBool(200, true, gc) } -// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code. +// @Summary Check whether a matrix PIN is valid, and mark the token as verified if so. Requires invite code. NOTE: "/invite" might have been changed in Settings > URL Paths. // @Produce json // @Success 200 {object} boolResponse // @Failure 401 {object} boolResponse diff --git a/api-userpage.go b/api-userpage.go index a7d9b77..f608fbb 100644 --- a/api-userpage.go +++ b/api-userpage.go @@ -164,9 +164,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) { var target ConfirmationTarget var id string fail := func() { - gcHTML(gc, 404, "404.html", gin.H{ - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{ "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) } @@ -201,7 +199,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) { // Perform an Action if target == NoOp { - gc.Redirect(http.StatusSeeOther, "/my/account") + gc.Redirect(http.StatusSeeOther, PAGES.MyAccount) return } else if target == UserEmailChange { app.modifyEmail(id, claims["email"].(string)) @@ -216,7 +214,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) { }, gc, true) app.info.Printf(lm.UserEmailAdjusted, gc.GetString("jfId")) - gc.Redirect(http.StatusSeeOther, "/my/account") + gc.Redirect(http.StatusSeeOther, PAGES.MyAccount) return } } diff --git a/config.go b/config.go index ff8a29e..4cb2bb7 100644 --- a/config.go +++ b/config.go @@ -22,6 +22,9 @@ var telegramEnabled = false var discordEnabled = false var matrixEnabled = false +// URL subpaths. Ignore the "Current" field. +var PAGES = PagePaths{} + func (app *appContext) GetPath(sect, key string) (fs.FS, string) { val := app.config.Section(sect).Key(key).MustString("") if strings.HasPrefix(val, "jfa-go:") { @@ -35,6 +38,13 @@ func (app *appContext) MustSetValue(section, key, val string) { app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val)) } +func (app *appContext) MustSetURLPath(section, key, val string) { + if !strings.HasPrefix(val, "/") { + val = "/" + val + } + app.MustSetValue(section, key, val) +} + func (app *appContext) loadConfig() error { var err error app.config, err = ini.ShadowLoad(app.configPath) @@ -42,6 +52,13 @@ func (app *appContext) loadConfig() error { return err } + app.MustSetURLPath("url_paths", "admin", "/") + app.MustSetURLPath("url_paths", "user_page", "/my/account") + app.MustSetURLPath("url_paths", "form", "/invite") + PAGES.Admin = app.config.Section("url_paths").Key("admin").MustString("/") + PAGES.MyAccount = app.config.Section("url_paths").Key("user_page").MustString("/my/account") + PAGES.Form = app.config.Section("url_paths").Key("form").MustString("/invite") + app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String()) app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String()) @@ -58,12 +75,12 @@ func (app *appContext) loadConfig() error { app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db")))) } - app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") - if app.URLBase == "/invite" || app.URLBase == "/accounts" || app.URLBase == "/settings" || app.URLBase == "/activity" { - app.err.Printf(lm.BadURLBase, app.URLBase) + PAGES.Base = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") + if PAGES.Base == "/invite" || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" { + app.err.Printf(lm.BadURLBase, PAGES.Base) } app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/") - if !strings.HasSuffix(app.ExternalURI, app.URLBase) { + if !strings.HasSuffix(app.ExternalURI, PAGES.Base) { app.err.Println(lm.NoURLSuffix) } if app.ExternalURI == "" { diff --git a/config/config-base.yaml b/config/config-base.yaml index d429010..2a17342 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -232,6 +232,33 @@ sections: - ["opaque", "Opaque"] value: clear description: Appearance of the Admin login screen. +- section: url_paths + meta: + name: URL Paths + description: Settings for changing where different pages are accessed. + advanced: true + settings: + - setting: admin + name: Admin page subpath + type: text + required: true + requires_restart: true + value: "/" + description: URL subpath the admin page should be at. + - setting: user_page + name: "\"My Account\" subpath" + type: text + required: true + requires_restart: true + value: "/my/account" + description: URL subpath the "My Account" page should be at. + - setting: form + name: Invite subpath + type: text + required: true + requires_restart: true + value: "/invite" + description: URL subpath invites should be on. - section: advanced meta: name: Advanced diff --git a/email.go b/email.go index 82da3f2..5201a33 100644 --- a/email.go +++ b/email.go @@ -329,7 +329,7 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC if code == "" { // Personal email change inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key)) } else { // Invite email confirmation - inviteLink = fmt.Sprintf("%s/invite/%s?key=%s", inviteLink, code, url.PathEscape(key)) + inviteLink = fmt.Sprintf("%s%s/%s?key=%s", inviteLink, PAGES.Form, code, url.PathEscape(key)) } template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) template["confirmationURL"] = inviteLink @@ -393,7 +393,7 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext expiry := invite.ValidTill d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) message := app.config.Section("messages").Key("message").String() - inviteLink := fmt.Sprintf("%s/invite/%s", app.ExternalURI, code) + inviteLink := fmt.Sprintf("%s%s/%s", app.ExternalURI, PAGES.Form, code) template := map[string]interface{}{ "hello": emailer.lang.InviteEmail.get("hello"), "youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"), diff --git a/html/404.html b/html/404.html index 22b0cbb..429ecba 100644 --- a/html/404.html +++ b/html/404.html @@ -1,9 +1,8 @@ - - {{ template "header.html" . }} 404 - jfa-go + {{ template "header.html" . }}
diff --git a/html/admin.html b/html/admin.html index bb10c4c..25630bd 100644 --- a/html/admin.html +++ b/html/admin.html @@ -1,16 +1,7 @@ - Admin - jfa-go @@ -47,7 +37,7 @@
- + diff --git a/html/crash.html b/html/crash.html index 44e4b43..e01329b 100644 --- a/html/crash.html +++ b/html/crash.html @@ -1,6 +1,7 @@ + {{ template "header.html" . }} Crash report diff --git a/html/create-success.html b/html/create-success.html index 19b7660..105424f 100644 --- a/html/create-success.html +++ b/html/create-success.html @@ -1,7 +1,6 @@ - {{ template "header.html" . }} {{ .strings.successHeader }} - jfa-go diff --git a/html/form-base.html b/html/form-base.html index f25bb2e..77e6a36 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -3,7 +3,6 @@ window.usernameEnabled = {{ .username }}; window.validationStrings = JSON.parse({{ .validationStrings }}); window.invalidPassword = "{{ .strings.reEnterPasswordInvalid }}"; - window.URLBase = "{{ .urlBase }}"; window.code = "{{ .code }}"; window.language = "{{ .langName }}"; window.messages = JSON.parse({{ .notifications }}); @@ -14,16 +13,13 @@ window.userExpiryHours = {{ .userExpiryHours }}; window.userExpiryMinutes = {{ .userExpiryMinutes }}; window.userExpiryMessage = {{ .userExpiryMessage }}; - window.telegramEnabled = {{ .telegramEnabled }}; window.telegramRequired = {{ .telegramRequired }}; window.telegramPIN = "{{ .telegramPIN }}"; window.emailRequired = {{ .emailRequired }}; - window.discordEnabled = {{ .discordEnabled }}; window.discordRequired = {{ .discordRequired }}; window.discordPIN = "{{ .discordPIN }}"; window.discordInviteLink = {{ .discordInviteLink }}; window.discordServerName = "{{ .discordServerName }}"; - window.matrixEnabled = {{ .matrixEnabled }}; window.matrixRequired = {{ .matrixRequired }}; window.matrixUserID = "{{ .matrixUser }}"; window.captcha = {{ .captcha }}; diff --git a/html/form.html b/html/form.html index 5199cad..336d21d 100644 --- a/html/form.html +++ b/html/form.html @@ -1,7 +1,6 @@ - {{ template "header.html" . }} {{ if .passwordReset }} {{ .strings.passwordReset }} diff --git a/html/header.html b/html/header.html index 6af2da8..b857990 100644 --- a/html/header.html +++ b/html/header.html @@ -1,13 +1,32 @@ + - - - - - + + + + + + diff --git a/html/invalidCode.html b/html/invalidCode.html index bc8de0f..611577b 100644 --- a/html/invalidCode.html +++ b/html/invalidCode.html @@ -1,7 +1,6 @@ - {{ template "header.html" . }} Invalid Code - jfa-go diff --git a/html/login-modal.html b/html/login-modal.html index 408e5c7..dde3998 100644 --- a/html/login-modal.html +++ b/html/login-modal.html @@ -15,7 +15,7 @@ {{ $hasTwoCards = 1 }}
{{ .strings.loginNotAdmin }} - {{ .strings.myAccount }} + {{ .strings.myAccount }}
{{ end }} {{ end }} diff --git a/html/password-reset.html b/html/password-reset.html index ebd75fd..4ffd558 100644 --- a/html/password-reset.html +++ b/html/password-reset.html @@ -1,7 +1,6 @@ - {{ template "header.html" . }} {{ .strings.passwordReset }} - jfa-go @@ -40,6 +39,6 @@ {{ .contactMessage }} - + diff --git a/html/setup.html b/html/setup.html index 0d53716..ffed526 100644 --- a/html/setup.html +++ b/html/setup.html @@ -1,7 +1,6 @@ - {{ template "header.html" . }} {{ .lang.Strings.pageTitle }} diff --git a/html/user.html b/html/user.html index 7e7484b..8de812b 100644 --- a/html/user.html +++ b/html/user.html @@ -1,31 +1,21 @@ - {{ template "header.html" . }} {{ .strings.myAccount }} @@ -156,7 +146,7 @@ {{ end }} - + diff --git a/main.go b/main.go index d2f881c..7d1de5a 100644 --- a/main.go +++ b/main.go @@ -103,37 +103,37 @@ type appContext struct { adminUsers []User invalidTokens []string // Keeping jf name because I can't think of a better one - jf *mediabrowser.MediaBrowser - authJf *mediabrowser.MediaBrowser - ombi *OmbiWrapper - js *JellyseerrWrapper - thirdPartyServices []ThirdPartyService - datePattern string - timePattern string - storage Storage - validator Validator - email *Emailer - telegram *TelegramDaemon - discord *DiscordDaemon - matrix *MatrixDaemon - contactMethods []ContactMethodLinker - info, debug, err *logger.Logger - host string - port int - version string - URLBase, ExternalURI, ExternalDomain string - updater *Updater - webhooks *WebhookSender - newUpdate bool // Whether whatever's in update is new. - tag Tag - update Update - proxyEnabled bool - proxyTransport *http.Transport - proxyConfig easyproxy.ProxyConfig - internalPWRs map[string]InternalPWR - pwrCaptchas map[string]Captcha - ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request - confirmationKeysLock sync.Mutex + jf *mediabrowser.MediaBrowser + authJf *mediabrowser.MediaBrowser + ombi *OmbiWrapper + js *JellyseerrWrapper + thirdPartyServices []ThirdPartyService + datePattern string + timePattern string + storage Storage + validator Validator + email *Emailer + telegram *TelegramDaemon + discord *DiscordDaemon + matrix *MatrixDaemon + contactMethods []ContactMethodLinker + info, debug, err *logger.Logger + host string + port int + version string + ExternalURI, ExternalDomain string + updater *Updater + webhooks *WebhookSender + newUpdate bool // Whether whatever's in update is new. + tag Tag + update Update + proxyEnabled bool + proxyTransport *http.Transport + proxyConfig easyproxy.ProxyConfig + internalPWRs map[string]InternalPWR + pwrCaptchas map[string]Captcha + ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request + confirmationKeysLock sync.Mutex } func generateSecret(length int) (string, error) { @@ -147,7 +147,7 @@ func generateSecret(length int) (string, error) { func test(app *appContext) { fmt.Printf("\n\n----\n\n") - settings := map[string]interface{}{ + settings := map[string]any{ "server": app.jf.Server, "server version": app.jf.ServerInfo.Version, "server name": app.jf.ServerInfo.Name, diff --git a/models.go b/models.go index 97e669b..686effd 100644 --- a/models.go +++ b/models.go @@ -467,3 +467,18 @@ type ContactMethodKey struct { PIN string User ContactMethodUser } + +type PagePaths struct { + // The base subfolder the app is hosted on. + Base string `json:"Base"` + // Those for other pages + Admin string `json:"Admin"` + MyAccount string `json:"MyAccount"` + Form string `json:"Form"` +} + +type PagePathsDTO struct { + PagePaths + // The subdirectory this bit of the app is hosted on (e.g. admin is usually on "/", myacc is usually on "/my/account") + Current string `json:"Current"` +} diff --git a/router.go b/router.go index 9edc32c..e88f49f 100644 --- a/router.go +++ b/router.go @@ -108,8 +108,8 @@ func (app *appContext) loadRouter(address string, debug bool) *gin.Engine { } func (app *appContext) loadRoutes(router *gin.Engine) { - routePrefixes := []string{app.URLBase} - if app.URLBase != "" { + routePrefixes := []string{PAGES.Base} + if PAGES.Base != "" { routePrefixes = append(routePrefixes, "") } @@ -118,7 +118,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { for _, p := range routePrefixes { router.GET(p+"/lang/:page", app.GetLanguages) router.Use(static.Serve(p+"/", app.webFS)) - router.GET(p+"/", app.AdminPage) + router.GET(p+PAGES.Admin, app.AdminPage) if app.config.Section("password_resets").Key("link_reset").MustBool(false) { router.GET(p+"/reset", app.ResetPassword) @@ -127,39 +127,39 @@ 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+PAGES.Admin+"/accounts", app.AdminPage) + router.GET(p+PAGES.Admin+"/settings", app.AdminPage) + router.GET(p+PAGES.Admin+"/activity", app.AdminPage) + router.GET(p+PAGES.Admin+"/accounts/user/:userID", app.AdminPage) + router.GET(p+PAGES.Admin+"/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) router.POST(p+"/user/invite", app.NewUserFromInvite) - router.Use(static.Serve(p+"/invite/", app.webFS)) - router.GET(p+"/invite/:invCode", app.InviteProxy) + router.Use(static.Serve(p+PAGES.Form, app.webFS)) + router.GET(p+PAGES.Form+"/:invCode", app.InviteProxy) if app.config.Section("captcha").Key("enabled").MustBool(false) { router.GET(p+"/captcha/gen/:invCode", app.GenCaptcha) router.GET(p+"/captcha/img/:invCode/:captchaID", app.GetCaptcha) router.POST(p+"/captcha/verify/:invCode/:captchaID/:text", app.VerifyCaptcha) } if telegramEnabled { - router.GET(p+"/invite/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite) + router.GET(p+PAGES.Form+"/:invCode/telegram/verified/:pin", app.TelegramVerifiedInvite) } if discordEnabled { - router.GET(p+"/invite/:invCode/discord/verified/:pin", app.DiscordVerifiedInvite) + router.GET(p+PAGES.Form+"/:invCode/discord/verified/:pin", app.DiscordVerifiedInvite) if app.config.Section("discord").Key("provide_invite").MustBool(false) { - router.GET(p+"/invite/:invCode/discord/invite", app.DiscordServerInvite) + router.GET(p+PAGES.Form+"/:invCode/discord/invite", app.DiscordServerInvite) } } if matrixEnabled { - router.GET(p+"/invite/:invCode/matrix/verified/:userID/:pin", app.MatrixCheckPIN) - router.POST(p+"/invite/:invCode/matrix/user", app.MatrixSendPIN) + router.GET(p+PAGES.Form+"/:invCode/matrix/verified/:userID/:pin", app.MatrixCheckPIN) + router.POST(p+PAGES.Form+"/:invCode/matrix/user", app.MatrixSendPIN) router.POST(p+"/users/matrix", app.MatrixConnect) } if userPageEnabled { - router.GET(p+"/my/account", app.MyUserPage) - router.GET(p+"/my/account/password/reset", app.MyUserPage) + router.GET(p+PAGES.MyAccount, app.MyUserPage) + router.GET(p+PAGES.MyAccount+"/password/reset", app.MyUserPage) router.GET(p+"/my/token/login", app.getUserTokenLogin) router.GET(p+"/my/token/refresh", app.getUserTokenRefresh) router.GET(p+"/my/confirm/:jwt", app.ConfirmMyAction) diff --git a/setup.go b/setup.go index bebba74..5d2f1ab 100644 --- a/setup.go +++ b/setup.go @@ -39,8 +39,10 @@ func (app *appContext) ServeSetup(gc *gin.Context) { respond(500, "Failed to fetch default values", gc) return } + pages := PagePathsDTO{PagePaths: PAGES} gc.HTML(200, "setup.html", gin.H{ "cssVersion": cssVersion, + "pages": pages, "lang": app.storage.lang.Setup[lang], "strings": app.storage.lang.Setup[lang].Strings, "emailLang": app.storage.lang.Email[emailLang], diff --git a/ts/admin.ts b/ts/admin.ts index 814fa37..090a8e4 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -11,6 +11,8 @@ import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns import { Updater } from "./modules/update.js"; import { Login } from "./modules/login.js"; +declare var window: GlobalWindow; + const theme = new ThemeManager(document.getElementById("button-theme")); window.lang = new lang(window.langFile as LangFile); @@ -165,12 +167,12 @@ const defaultTab = tabs[0]; window.tabs = new Tabs(); for (let tab of tabs) { - window.tabs.addTab(tab.id, tab.url, null, tab.reloader); + window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader); } let matchedTab = false -for (let tab of tabs) { - if (window.location.pathname.startsWith(window.URLBase + "/" + tab.url)) { +for (const tab of tabs) { + if (window.location.pathname.startsWith(window.pages.Base + window.pages.Current + "/" + tab.url)) { window.tabs.switch(tab.url, true); matchedTab = true; } diff --git a/ts/form.ts b/ts/form.ts index 6552040..7ec413c 100644 --- a/ts/form.ts +++ b/ts/form.ts @@ -6,7 +6,7 @@ import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator. import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; import { Captcha, GreCAPTCHA } from "./modules/captcha.js"; -interface formWindow extends Window { +interface formWindow extends GlobalWindow { invalidPassword: string; successModal: Modal; telegramModal: Modal; @@ -59,7 +59,7 @@ if (window.telegramEnabled) { modal: window.telegramModal as Modal, pin: window.telegramPIN, pinURL: "", - verifiedURL: "/invite/" + window.code + "/telegram/verified/", + verifiedURL: window.pages.Form + "/" + window.code + "/telegram/verified/", invalidCodeError: window.messages["errorInvalidPIN"], accountLinkedError: window.messages["errorAccountLinked"], successError: window.messages["verified"], @@ -89,9 +89,9 @@ if (window.discordEnabled) { const discordConf: ServiceConfiguration = { modal: window.discordModal as Modal, pin: window.discordPIN, - inviteURL: window.discordInviteLink ? ("/invite/" + window.code + "/discord/invite") : "", + inviteURL: window.discordInviteLink ? (window.pages.Form + "/" + window.code + "/discord/invite") : "", pinURL: "", - verifiedURL: "/invite/" + window.code + "/discord/verified/", + verifiedURL: window.pages.Form + "/" + window.code + "/discord/verified/", invalidCodeError: window.messages["errorInvalidPIN"], accountLinkedError: window.messages["errorAccountLinked"], successError: window.messages["verified"], @@ -121,8 +121,8 @@ if (window.matrixEnabled) { const matrixConf: MatrixConfiguration = { modal: window.matrixModal as Modal, - sendMessageURL: "/invite/" + window.code + "/matrix/user", - verifiedURL: "/invite/" + window.code + "/matrix/verified/", + sendMessageURL: window.pages.Form + "/" + window.code + "/matrix/user", + verifiedURL: window.pages.Form + "/" + window.code + "/matrix/verified/", invalidCodeError: window.messages["errorInvalidPIN"], accountLinkedError: window.messages["errorAccountLinked"], unknownError: window.messages["errorUnknown"], diff --git a/ts/modules/account-linking.ts b/ts/modules/account-linking.ts index 301c3cb..effc511 100644 --- a/ts/modules/account-linking.ts +++ b/ts/modules/account-linking.ts @@ -1,7 +1,7 @@ import { Modal } from "../modules/modal.js"; import { _get, _post, toggleLoader, addLoader, removeLoader } from "../modules/common.js"; -interface formWindow extends Window { +interface formWindow extends GlobalWindow { invalidPassword: string; successModal: Modal; telegramModal: Modal; diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index ab88af0..c338bfc 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -6,6 +6,8 @@ import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; import { HiddenInputField } from "./ui.js"; +declare var window: GlobalWindow; + const dateParser = require("any-date-parser"); interface User { diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index d54972f..1049390 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -3,6 +3,8 @@ import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modul import { accountURLEvent } from "../modules/accounts.js"; import { inviteURLEvent } from "../modules/invites.js"; +declare var window: GlobalWindow; + export interface activity { id: string; type: string; @@ -52,8 +54,8 @@ export class Activity implements activity, SearchableItem { link = link.split(split)[0]; } if (link.slice(-1) != "/") { link += "/"; } - // FIXME: I should probably just be using window.URLBase, but incase thats not right, i'll put this warning here - if (link != window.URLBase) console.error(`URL Bases don't match: "${link}" != "${window.URLBase}"`); + // FIXME: I should probably just be using window.pages.Base, but incase thats not right, i'll put this warning here + if (link != window.pages.Base) console.error(`URL Bases don't match: "${link}" != "${window.pages.Base}"`); return link; })(); */ @@ -66,17 +68,17 @@ export class Activity implements activity, SearchableItem { } _genUserLink = (): string => { - return `${this._genUserText()}`; + return `${this._genUserText()}`; } _genSrcUserLink = (): string => { - return `${this._genSrcUserText()}`; + return `${this._genSrcUserText()}`; } private _renderInvText = (): string => { return `${this.value || this.invite_code || "???"}`; } private _genInvLink = (): string => { - return `${this._renderInvText()}`; + return `${this._renderInvText()}`; } diff --git a/ts/modules/common.ts b/ts/modules/common.ts index d87498a..40b5ddd 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -1,4 +1,4 @@ -declare var window: Window; +declare var window: GlobalWindow; export function toDateString(date: Date): string { const locale = window.language || (window as any).navigator.userLanguage || window.navigator.language; @@ -23,7 +23,7 @@ export function toDateString(date: Date): string { export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => { let req = new XMLHttpRequest(); - if (window.URLBase) { url = window.URLBase + url; } + if (window.pages) { url = window.pages.Base + url; } req.open("GET", url, true); req.responseType = 'json'; req.setRequestHeader("Authorization", "Bearer " + window.token); @@ -42,7 +42,7 @@ export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHtt export const _download = (url: string, fname: string): void => { let req = new XMLHttpRequest(); - if (window.URLBase) { url = window.URLBase + url; } + if (window.pages) { url = window.pages.Base + url; } req.open("GET", url, true); req.responseType = 'blob'; req.setRequestHeader("Authorization", "Bearer " + window.token); @@ -58,7 +58,7 @@ export const _download = (url: string, fname: string): void => { export const _upload = (url: string, formData: FormData): void => { let req = new XMLHttpRequest(); - if (window.URLBase) { url = window.URLBase + url; } + if (window.pages) { url = window.pages.Base + url; } req.open("POST", url, true); req.setRequestHeader("Authorization", "Bearer " + window.token); // req.setRequestHeader('Content-Type', 'multipart/form-data'); @@ -67,7 +67,8 @@ export const _upload = (url: string, formData: FormData): void => { export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => { let req = new XMLHttpRequest(); - req.open("POST", window.URLBase + url, true); + if (window.pages) { url = window.pages.Base + url; } + req.open("POST", url, true); if (response) { req.responseType = 'json'; } @@ -88,7 +89,8 @@ export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHt export function _delete(url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void { let req = new XMLHttpRequest(); - req.open("DELETE", window.URLBase + url, true); + if (window.pages) { url = window.pages.Base + url; } + req.open("DELETE", url, true); req.setRequestHeader("Authorization", "Bearer " + window.token); req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); req.onreadystatechange = () => { diff --git a/ts/modules/discord.ts b/ts/modules/discord.ts index 89e8a8c..f773ccf 100644 --- a/ts/modules/discord.ts +++ b/ts/modules/discord.ts @@ -1,5 +1,7 @@ import {addLoader, removeLoader, _get} from "../modules/common.js"; +declare var window: GlobalWindow; + export interface DiscordUser { name: string; avatar_url: string; diff --git a/ts/modules/invites.ts b/ts/modules/invites.ts index c9b759c..4c34a90 100644 --- a/ts/modules/invites.ts +++ b/ts/modules/invites.ts @@ -2,6 +2,8 @@ import { _get, _post, _delete, toClipboard, toggleLoader, toDateString } from ". import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; import { reloadProfileNames } from "../modules/profiles.js"; +declare var window: GlobalWindow; + class DOMInvite implements Invite { updateNotify = (checkbox: HTMLInputElement) => { let state: { [code: string]: { [type: string]: boolean } } = {}; @@ -66,7 +68,7 @@ class DOMInvite implements Invite { codeLink = codeLink.split(split)[0]; } if (codeLink.slice(-1) != "/") { codeLink += "/"; } - this._codeLink = codeLink + "invite/" + code; + this._codeLink = codeLink + window.pages.Form + "/" + code; const linkEl = this._codeArea.querySelector("a") as HTMLAnchorElement; if (this.label == "") { linkEl.textContent = code.replace(/-/g, '-'); diff --git a/ts/modules/login.ts b/ts/modules/login.ts index 71cd590..11d09bd 100644 --- a/ts/modules/login.ts +++ b/ts/modules/login.ts @@ -1,6 +1,8 @@ import { Modal } from "../modules/modal.js"; import { toggleLoader, _post, unicodeB64Encode } from "../modules/common.js"; +declare var window: GlobalWindow; + export class Login { loggedIn: boolean = false; private _modal: Modal; @@ -14,7 +16,7 @@ export class Login { constructor(modal: Modal, endpoint: string, appearance: string) { this._endpoint = endpoint; - this._url = window.URLBase + endpoint; + this._url = window.pages.Base + endpoint; if (this._url[this._url.length-1] != '/') this._url += "/"; this._modal = modal; diff --git a/ts/modules/modal.ts b/ts/modules/modal.ts index 77cbb4e..92e3435 100644 --- a/ts/modules/modal.ts +++ b/ts/modules/modal.ts @@ -1,4 +1,4 @@ -declare var window: Window; +declare var window: GlobalWindow; export class Modal implements Modal { modal: HTMLElement; diff --git a/ts/modules/pages.ts b/ts/modules/pages.ts index 686f771..edf21f3 100644 --- a/ts/modules/pages.ts +++ b/ts/modules/pages.ts @@ -73,6 +73,7 @@ export class PageManager { } loadPage (p: Page) { + console.log("loading page with", p.name || this.defaultName, p.title, p.url + window.location.search); window.history.pushState(p.name || this.defaultName, p.title, p.url + window.location.search); } diff --git a/ts/modules/profiles.ts b/ts/modules/profiles.ts index e58fb27..51d6eb3 100644 --- a/ts/modules/profiles.ts +++ b/ts/modules/profiles.ts @@ -1,5 +1,7 @@ import { _get, _post, _delete, toggleLoader } from "../modules/common.js"; +declare var window: GlobalWindow; + export const profileLoadEvent = new CustomEvent("profileLoadEvent"); export const reloadProfileNames = (then?: () => void) => _get("/profiles/names", null, (req: XMLHttpRequest) => { if (req.readyState != 4) return; diff --git a/ts/modules/search.ts b/ts/modules/search.ts index dd01dbe..fa8d1e1 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -1,5 +1,7 @@ const dateParser = require("any-date-parser"); +declare var window: GlobalWindow; + export interface QueryType { name: string; description?: string; diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index c9d1459..a1a43f8 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -2,6 +2,8 @@ import { _get, _post, _delete, _download, _upload, toggleLoader, addLoader, remo import { Marked } from "@ts-stack/markdown"; import { stripMarkdown } from "../modules/stripmd.js"; +declare var window: GlobalWindow; + const toBool = (s: string): boolean => { let b = Boolean(s); if (s == "false") b = false; diff --git a/ts/modules/tabs.ts b/ts/modules/tabs.ts index 628ed7e..3f7d78b 100644 --- a/ts/modules/tabs.ts +++ b/ts/modules/tabs.ts @@ -1,5 +1,7 @@ import { PageManager, Page } from "../modules/pages.js"; +declare var window: GlobalWindow; + export interface Tab { page: Page; tabEl: HTMLDivElement; @@ -38,7 +40,7 @@ export class Tabs implements Tabs { tab.page = { name: tabID, title: document.title, /*FIXME: Get actual names from translations*/ - url: window.URLBase + "/" + url, + url: url, show: () => { tab.buttonEl.classList.add("active", "~urge"); tab.tabEl.classList.remove("unfocused"); diff --git a/ts/modules/update.ts b/ts/modules/update.ts index ba2c946..c39fc63 100644 --- a/ts/modules/update.ts +++ b/ts/modules/update.ts @@ -1,6 +1,8 @@ import { _get, _post, toggleLoader, toDateString } from "../modules/common.js"; import { Marked, Renderer } from "@ts-stack/markdown"; +declare var window: GlobalWindow; + interface updateDTO { new: boolean; update: Update; diff --git a/ts/pwr-pin.ts b/ts/pwr-pin.ts index 8774967..a274a58 100644 --- a/ts/pwr-pin.ts +++ b/ts/pwr-pin.ts @@ -1,5 +1,7 @@ import { toClipboard, notificationBox } from "./modules/common.js"; +declare var window: GlobalWindow; + const pin = document.getElementById("pin") as HTMLSpanElement; if (pin) { diff --git a/ts/setup.ts b/ts/setup.ts index 747ed83..d987060 100644 --- a/ts/setup.ts +++ b/ts/setup.ts @@ -3,12 +3,11 @@ import { lang, LangFile, loadLangSelector } from "./modules/lang.js"; import { ThemeManager } from "./modules/theme.js"; import { PageManager } from "./modules/pages.js"; -interface sWindow extends Window { +interface sWindow extends GlobalWindow { messages: {}; } declare var window: sWindow; -window.URLBase = ""; const theme = new ThemeManager(document.getElementById("button-theme")); diff --git a/ts/typings/d.ts b/ts/typings/d.ts index a53a3fb..b19a1c7 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -12,8 +12,19 @@ interface ArrayConstructor { from(arrayLike: any, mapFn?, thisArg?): Array; } -declare interface Window { - URLBase: string; +declare interface PagePaths { + // The base subfolder the app is hosted on. + Base: string; + // The subdirectory this bit of the app is hosted on (e.g. admin is usually on "/", myacc is usually on "/my/account") + Current: string; + // Those for other pages + Admin: string; + MyAccount: string; + Form: string; +} + +declare interface GlobalWindow extends Window { + pages: PagePaths; modals: Modals; cssFile: string; availableProfiles: string[]; @@ -25,6 +36,7 @@ declare interface Window { matrixEnabled: boolean; ombiEnabled: boolean; jellyseerrEnabled: boolean; + pwrEnabled: boolean; usernameEnabled: boolean; linkResetEnabled: boolean; token: string; diff --git a/ts/user.ts b/ts/user.ts index b72b22b..cceb19e 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -7,7 +7,7 @@ import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js"; import { PageManager } from "./modules/pages.js"; -interface userWindow extends Window { +interface userWindow extends GlobalWindow { jellyfinID: string; username: string; emailRequired: boolean; @@ -18,14 +18,13 @@ interface userWindow extends Window { discordInviteLink: boolean; matrixUserID: string; discordSendPINMessage: string; - pwrEnabled: string; referralsEnabled: boolean; } -const basePath = window.location.pathname.replace("/password/reset", ""); - declare var window: userWindow; +const basePath = window.location.pathname.replace("/password/reset", ""); + const theme = new ThemeManager(document.getElementById("button-theme")); window.lang = new lang(window.langFile as LangFile); @@ -38,7 +37,7 @@ window.token = ""; window.modals = {} as Modals; -let pages = new PageManager({ +const pages = new PageManager({ hideOthersOnPageShow: true, defaultName: "", defaultTitle: document.title, @@ -311,7 +310,7 @@ class ReferralCard { path = path.split(split)[0]; } if (path.slice(-1) != "/") { path += "/"; } - path = path + "invite/" + this._code; + path = path + window.pages.Form + "/" + this._code; u.pathname = path; u.hash = ""; @@ -661,7 +660,7 @@ document.addEventListener("details-reload", () => { expiryCard.expiry = details.expiry; const adminBackButton = document.getElementById("admin-back-button") as HTMLAnchorElement; - adminBackButton.href = window.location.href.replace("my/account", ""); + adminBackButton.href = window.location.href.replace(window.pages.MyAccount, window.pages.Admin); let messageCard = document.getElementById("card-message"); if (details.accounts_admin) { diff --git a/user-auth.go b/user-auth.go index 5af2320..c80e213 100644 --- a/user-auth.go +++ b/user-auth.go @@ -67,7 +67,8 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) { // host := gc.Request.URL.Hostname() host := app.ExternalDomain uri := "/my" - if strings.HasPrefix(gc.Request.RequestURI, app.URLBase) { + // FIXME: This seems like a bad idea? I think it's to deal with people having Reverse proxy subfolder/URL base set to /accounts. + if strings.HasPrefix(gc.Request.RequestURI, PAGES.Base) { uri = "/accounts/my" } gc.SetCookie("user-refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, uri, host, true, true) diff --git a/views.go b/views.go index 7542606..be574b4 100644 --- a/views.go +++ b/views.go @@ -32,7 +32,7 @@ func (app *appContext) loadCSSHeader() string { l := len(css) h := "" for i, f := range css { - h += "<" + app.URLBase + "/css/" + f + ">; rel=preload; as=style" + h += "<" + PAGES.Base + "/css/" + f + ">; rel=preload; as=style" if l > 1 && i != (l-1) { h += ", " } @@ -41,18 +41,19 @@ func (app *appContext) loadCSSHeader() string { } func (app *appContext) getURLBase(gc *gin.Context) string { - if strings.HasPrefix(gc.Request.URL.String(), app.URLBase) { + if strings.HasPrefix(gc.Request.URL.String(), PAGES.Base) { // Hack to fix the common URL base /accounts - if app.URLBase == "/accounts" && strings.HasPrefix(gc.Request.URL.String(), "/accounts/user/") { + if PAGES.Base == "/accounts" && strings.HasPrefix(gc.Request.URL.String(), "/accounts/user/") { return "" } - return app.URLBase + return PAGES.Base } return "" } -func gcHTML(gc *gin.Context, code int, file string, templ gin.H) { +func (app *appContext) gcHTML(gc *gin.Context, code int, file string, page Page, templ gin.H) { gc.Header("Cache-Control", "no-cache") + app.BasePageTemplateValues(gc, page, templ) gc.HTML(code, file, templ) } @@ -61,16 +62,14 @@ func (app *appContext) pushResources(gc *gin.Context, page Page) { switch page { case AdminPage: toPush = []string{"/js/admin.js", "/js/theme.js", "/js/lang.js", "/js/modal.js", "/js/tabs.js", "/js/invites.js", "/js/accounts.js", "/js/settings.js", "/js/profiles.js", "/js/common.js"} - break case UserPage: toPush = []string{"/js/user.js", "/js/theme.js", "/js/lang.js", "/js/modal.js", "/js/common.js"} - break default: toPush = []string{} } if pusher := gc.Writer.Pusher(); pusher != nil { for _, f := range toPush { - if err := pusher.Push(app.URLBase+f, nil); err != nil { + if err := pusher.Push(PAGES.Base+f, nil); err != nil { app.debug.Printf(lm.FailedServerPush, err) } } @@ -78,6 +77,48 @@ func (app *appContext) pushResources(gc *gin.Context, page Page) { gc.Header("Link", cssHeader) } +// Returns a gin.H with general values (url base, css version, etc.) +func (app *appContext) BasePageTemplateValues(gc *gin.Context, page Page, base gin.H) { + set := func(k string, v any) { + if _, ok := base[k]; !ok { + base[k] = v + } + } + + pages := PagePathsDTO{ + PagePaths: PAGES, + } + pages.Base = app.getURLBase(gc) + switch page { + case AdminPage: + pages.Current = PAGES.Admin + case FormPage: + pages.Current = PAGES.Form + case UserPage: + pages.Current = PAGES.MyAccount + default: + pages.Current = "/" + } + set("pages", pages) + ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) + jellyseerrEnabled := app.config.Section("jellyseerr").Key("enabled").MustBool(false) + notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool() + set("notifications", notificationsEnabled) + set("cssClass", app.cssClass) + set("cssVersion", cssVersion) + set("emailEnabled", emailEnabled) + set("telegramEnabled", telegramEnabled) + set("discordEnabled", discordEnabled) + set("matrixEnabled", matrixEnabled) + set("ombiEnabled", ombiEnabled) + set("jellyseerrEnabled", jellyseerrEnabled) + // QUIRK: The login modal html template uses this' existence to check if the modal is for the admin or user page. + if page != AdminPage { + set("pwrEnabled", app.config.Section("password_resets").Key("enabled").MustBool(false)) + } + set("referralsEnabled", app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false)) +} + type Page int const ( @@ -132,10 +173,6 @@ func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string func (app *appContext) AdminPage(gc *gin.Context) { app.pushResources(gc, AdminPage) lang := app.getLang(gc, AdminPage, app.storage.lang.chosenAdminLang) - emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool() - notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool() - ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) - jellyseerrEnabled := app.config.Section("jellyseerr").Key("enabled").MustBool(false) jfAdminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) jfAllowAll := app.config.Section("ui").Key("allow_all").MustBool(false) var license string @@ -157,62 +194,36 @@ func (app *appContext) AdminPage(gc *gin.Context) { builtBy = "???" } - gcHTML(gc, http.StatusOK, "admin.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, - "contactMessage": "", - "emailEnabled": emailEnabled, - "telegramEnabled": telegramEnabled, - "discordEnabled": discordEnabled, - "matrixEnabled": matrixEnabled, - "ombiEnabled": ombiEnabled, - "jellyseerrEnabled": jellyseerrEnabled, - "linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false), - "notifications": notificationsEnabled, - "version": version, - "commit": commit, - "buildTime": buildTime, - "builtBy": builtBy, - "buildTags": buildTags, - "username": !app.config.Section("email").Key("no_username").MustBool(false), - "strings": app.storage.lang.Admin[lang].Strings, - "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings, - "language": app.storage.lang.Admin[lang].JSON, - "langName": lang, - "license": license, - "jellyfinLogin": app.jellyfinLogin, - "jfAdminOnly": jfAdminOnly, - "jfAllowAll": jfAllowAll, - "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false), - "showUserPageLink": app.config.Section("user_page").Key("show_link").MustBool(true), - "referralsEnabled": app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false), - "loginAppearance": app.config.Section("ui").Key("login_appearance").MustString("clear"), + app.gcHTML(gc, http.StatusOK, "admin.html", AdminPage, gin.H{ + "contactMessage": "", + "linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false), + "version": version, + "commit": commit, + "buildTime": buildTime, + "builtBy": builtBy, + "buildTags": buildTags, + "username": !app.config.Section("email").Key("no_username").MustBool(false), + "strings": app.storage.lang.Admin[lang].Strings, + "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings, + "language": app.storage.lang.Admin[lang].JSON, + "langName": lang, + "license": license, + "jellyfinLogin": app.jellyfinLogin, + "jfAdminOnly": jfAdminOnly, + "jfAllowAll": jfAllowAll, + "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false), + "showUserPageLink": app.config.Section("user_page").Key("show_link").MustBool(true), + "loginAppearance": app.config.Section("ui").Key("login_appearance").MustString("clear"), }) } func (app *appContext) MyUserPage(gc *gin.Context) { app.pushResources(gc, UserPage) lang := app.getLang(gc, UserPage, app.storage.lang.chosenUserLang) - emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool() - notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool() - ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) - jellyseerrEnabled := app.config.Section("jellyseerr").Key("enabled").MustBool(false) data := gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), - "emailEnabled": emailEnabled, "emailRequired": app.config.Section("email").Key("required").MustBool(false), - "telegramEnabled": telegramEnabled, - "discordEnabled": discordEnabled, - "matrixEnabled": matrixEnabled, - "ombiEnabled": ombiEnabled, - "jellyseerrEnabled": jellyseerrEnabled, - "pwrEnabled": app.config.Section("password_resets").Key("enabled").MustBool(false), "linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false), - "notifications": notificationsEnabled, "username": !app.config.Section("email").Key("no_username").MustBool(false), "strings": app.storage.lang.User[lang].Strings, "validationStrings": app.storage.lang.User[lang].validationStringsJSON, @@ -220,7 +231,6 @@ func (app *appContext) MyUserPage(gc *gin.Context) { "langName": lang, "jfLink": app.config.Section("ui").Key("redirect_url").String(), "requirements": app.validator.getCriteria(), - "referralsEnabled": app.config.Section("user_page").Key("enabled").MustBool(false) && app.config.Section("user_page").Key("referrals").MustBool(false), } if telegramEnabled { data["telegramUsername"] = app.telegram.username @@ -264,7 +274,7 @@ func (app *appContext) MyUserPage(gc *gin.Context) { data[name+"MessageContent"] = template.HTML(markdown.ToHTML([]byte(msg.Content), nil, markdownRenderer)) } - gcHTML(gc, http.StatusOK, "user.html", data) + app.gcHTML(gc, http.StatusOK, "user.html", UserPage, data) } func (app *appContext) ResetPassword(gc *gin.Context) { @@ -278,14 +288,9 @@ func (app *appContext) ResetPassword(gc *gin.Context) { app.pushResources(gc, PWRPage) lang := app.getLang(gc, PWRPage, app.storage.lang.chosenPWRLang) data := gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), "strings": app.storage.lang.PasswordReset[lang].Strings, "success": false, - "ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false), - "jellyseerrEnabled": app.config.Section("jellyseerr").Key("enabled").MustBool(false), "customSuccessCard": false, } pwr, isInternal := app.internalPWRs[pin] @@ -299,6 +304,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) { data["requirements"] = app.validator.getCriteria() data["strings"] = app.storage.lang.PasswordReset[lang].Strings data["validationStrings"] = app.storage.lang.User[lang].validationStringsJSON + // ewwwww, reusing an existing field, FIXME! data["notifications"] = app.storage.lang.User[lang].notificationsJSON data["langName"] = lang data["passwordReset"] = true @@ -309,10 +315,10 @@ func (app *appContext) ResetPassword(gc *gin.Context) { data["reCAPTCHA"] = app.config.Section("captcha").Key("recaptcha").MustBool(false) data["reCAPTCHASiteKey"] = app.config.Section("captcha").Key("recaptcha_site_key").MustString("") data["pwrPIN"] = pin - gcHTML(gc, http.StatusOK, "form-loader.html", data) + app.gcHTML(gc, http.StatusOK, "form-loader.html", PWRPage, data) return } - defer gcHTML(gc, http.StatusOK, "password-reset.html", data) + defer app.gcHTML(gc, http.StatusOK, "password-reset.html", PWRPage, data) // If it's a bot, pretend to be a success so the preview is nice. if isBot { app.debug.Println(lm.IgnoreBotPWR) @@ -413,10 +419,7 @@ func (app *appContext) GetCaptcha(gc *gin.Context) { if !isPWR { inv, ok = app.storage.GetInvitesKey(code) if !ok { - gcHTML(gc, 404, "invalidCode.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, 404, "invalidCode.html", OtherPage, gin.H{ "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) } @@ -453,10 +456,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) { } if !ok { - gcHTML(gc, 404, "invalidCode.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, 404, "invalidCode.html", OtherPage, gin.H{ "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) } @@ -587,10 +587,7 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) { if !isPWR { inv, ok = app.storage.GetInvitesKey(code) if !ok { - gcHTML(gc, 404, "invalidCode.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, 404, "invalidCode.html", OtherPage, gin.H{ "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) return @@ -617,10 +614,7 @@ func (app *appContext) VerifyCaptcha(gc *gin.Context) { func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lang string, gc *gin.Context) { fail := func() { - gcHTML(gc, 404, "404.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{ "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) } @@ -691,10 +685,7 @@ func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lan if app.config.Section("ui").Key("auto_redirect").MustBool(false) { gc.Redirect(301, jfLink) } else { - gcHTML(gc, http.StatusOK, "create-success.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, http.StatusOK, "create-success.html", OtherPage, gin.H{ "strings": app.storage.lang.User[lang].Strings, "successMessage": app.config.Section("ui").Key("success_message").String(), "contactMessage": app.config.Section("ui").Key("contact_message").String(), @@ -725,10 +716,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { // if app.checkInvite(code, false, "") { invite, ok := app.storage.GetInvitesKey(gc.Param("invCode")) if !ok { - gcHTML(gc, 404, "invalidCode.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, 404, "invalidCode.html", FormPage, gin.H{ "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) return @@ -747,7 +735,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) { discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true) matrix := matrixEnabled && app.config.Section("matrix").Key("show_on_reg").MustBool(true) - userPageAddress := fmt.Sprintf("%s/my/account", app.ExternalURI) + userPageAddress := app.ExternalURI + PAGES.MyAccount fromUser := "" if invite.ReferrerJellyfinID != "" { @@ -758,9 +746,6 @@ func (app *appContext) InviteProxy(gc *gin.Context) { } data := gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, "contactMessage": app.config.Section("ui").Key("contact_message").String(), "helpMessage": app.config.Section("ui").Key("help_message").String(), "successMessage": app.config.Section("ui").Key("success_message").String(), @@ -772,28 +757,29 @@ func (app *appContext) InviteProxy(gc *gin.Context) { "username": !app.config.Section("email").Key("no_username").MustBool(false), "strings": app.storage.lang.User[lang].Strings, "validationStrings": app.storage.lang.User[lang].validationStringsJSON, - "notifications": app.storage.lang.User[lang].notificationsJSON, - "code": invite.Code, - "confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false), - "userExpiry": invite.UserExpiry, - "userExpiryMonths": invite.UserMonths, - "userExpiryDays": invite.UserDays, - "userExpiryHours": invite.UserHours, - "userExpiryMinutes": invite.UserMinutes, - "userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"), - "langName": lang, - "passwordReset": false, - "customSuccessCard": false, - "telegramEnabled": telegram, - "discordEnabled": discord, - "matrixEnabled": matrix, - "emailRequired": app.config.Section("email").Key("required").MustBool(false), - "captcha": app.config.Section("captcha").Key("enabled").MustBool(false), - "reCAPTCHA": app.config.Section("captcha").Key("recaptcha").MustBool(false), - "reCAPTCHASiteKey": app.config.Section("captcha").Key("recaptcha_site_key").MustString(""), - "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false), - "userPageAddress": userPageAddress, - "fromUser": fromUser, + // ewwwww, reusing an existing field, FIXME! + "notifications": app.storage.lang.User[lang].notificationsJSON, + "code": invite.Code, + "confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false), + "userExpiry": invite.UserExpiry, + "userExpiryMonths": invite.UserMonths, + "userExpiryDays": invite.UserDays, + "userExpiryHours": invite.UserHours, + "userExpiryMinutes": invite.UserMinutes, + "userExpiryMessage": app.storage.lang.User[lang].Strings.get("yourAccountIsValidUntil"), + "langName": lang, + "passwordReset": false, + "customSuccessCard": false, + "telegramEnabled": telegram, + "discordEnabled": discord, + "matrixEnabled": matrix, + "emailRequired": app.config.Section("email").Key("required").MustBool(false), + "captcha": app.config.Section("captcha").Key("enabled").MustBool(false), + "reCAPTCHA": app.config.Section("captcha").Key("recaptcha").MustBool(false), + "reCAPTCHASiteKey": app.config.Section("captcha").Key("recaptcha_site_key").MustString(""), + "userPageEnabled": app.config.Section("user_page").Key("enabled").MustBool(false), + "userPageAddress": userPageAddress, + "fromUser": fromUser, } if telegram { data["telegramPIN"] = app.telegram.NewAuthToken() @@ -837,15 +823,12 @@ func (app *appContext) InviteProxy(gc *gin.Context) { // pin := "" // for _, token := range app.discord.tokens { // if - gcHTML(gc, http.StatusOK, "form-loader.html", data) + app.gcHTML(gc, http.StatusOK, "form-loader.html", OtherPage, data) } func (app *appContext) NoRouteHandler(gc *gin.Context) { app.pushResources(gc, OtherPage) - gcHTML(gc, 404, "404.html", gin.H{ - "urlBase": app.getURLBase(gc), - "cssClass": app.cssClass, - "cssVersion": cssVersion, + app.gcHTML(gc, 404, "404.html", OtherPage, gin.H{ "contactMessage": app.config.Section("ui").Key("contact_message").String(), }) } From 302c4c189c63adf78218688c18080a32f65db13a Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 14 May 2025 19:42:50 +0100 Subject: [PATCH 009/249] build: check and re-copy modified config-base --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6657649..1e4b2be 100644 --- a/Makefile +++ b/Makefile @@ -192,7 +192,7 @@ STATIC_TARGET = $(STATIC_SRC:static/%=$(DATA)/web/%) COPY_SRC = images/banner.svg jfa-go.service LICENSE $(LANG_SRC) $(STATIC_SRC) COPY_TARGET = $(DATA)/jfa-go.service # $(DATA)/LICENSE $(LANG_TARGET) $(STATIC_TARGET) $(DATA)/web/css/$(CSSVERSION)bundle.css -$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) +$(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE) $(info copying $(CONFIG_BASE)) cp $(CONFIG_BASE) $(DATA)/ $(info copying crash page) From 0967d471eee5fcd8b2aff37a8f82ecc51f6475d6 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 14 May 2025 20:08:21 +0100 Subject: [PATCH 010/249] urlpaths: seemingly full functionality various subpath combos seem to work, and trailing slashes from them are trimmed (including for the empty admin path "/", which is now "" by default). --- config.go | 33 ++++++++++++++++++++++----------- config/config-base.yaml | 24 ++++++++++++++++-------- logmessages/logmessages.go | 3 ++- ts/admin.ts | 1 - ts/user.ts | 2 +- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/config.go b/config.go index 4cb2bb7..05acb5b 100644 --- a/config.go +++ b/config.go @@ -39,12 +39,19 @@ func (app *appContext) MustSetValue(section, key, val string) { } func (app *appContext) MustSetURLPath(section, key, val string) { - if !strings.HasPrefix(val, "/") { + if !strings.HasPrefix(val, "/") && val != "" { val = "/" + val } app.MustSetValue(section, key, val) } +func FormatSubpath(path string) string { + if path == "/" { + return "" + } + return strings.TrimSuffix(path, "/") +} + func (app *appContext) loadConfig() error { var err error app.config, err = ini.ShadowLoad(app.configPath) @@ -52,15 +59,23 @@ func (app *appContext) loadConfig() error { return err } - app.MustSetURLPath("url_paths", "admin", "/") + // URLs + app.MustSetURLPath("ui", "url_base", "") + app.MustSetURLPath("url_paths", "admin", "") app.MustSetURLPath("url_paths", "user_page", "/my/account") app.MustSetURLPath("url_paths", "form", "/invite") - PAGES.Admin = app.config.Section("url_paths").Key("admin").MustString("/") - PAGES.MyAccount = app.config.Section("url_paths").Key("user_page").MustString("/my/account") - PAGES.Form = app.config.Section("url_paths").Key("form").MustString("/invite") - + PAGES.Base = FormatSubpath(app.config.Section("ui").Key("url_base").String()) + PAGES.Admin = FormatSubpath(app.config.Section("url_paths").Key("admin").String()) + PAGES.MyAccount = FormatSubpath(app.config.Section("url_paths").Key("user_page").String()) + PAGES.Form = FormatSubpath(app.config.Section("url_paths").Key("form").String()) + if !(app.config.Section("user_page").Key("enabled").MustBool(true)) { + PAGES.MyAccount = "disabled" + } + if PAGES.Base == PAGES.Form || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" { + app.err.Printf(lm.BadURLBase, PAGES.Base) + } + app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form) app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String()) - app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String()) for _, key := range app.config.Section("files").Keys() { @@ -75,10 +90,6 @@ func (app *appContext) loadConfig() error { app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".db")))) } - PAGES.Base = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") - if PAGES.Base == "/invite" || PAGES.Base == "/accounts" || PAGES.Base == "/settings" || PAGES.Base == "/activity" { - app.err.Printf(lm.BadURLBase, PAGES.Base) - } app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/") if !strings.HasSuffix(app.ExternalURI, PAGES.Base) { app.err.Println(lm.NoURLSuffix) diff --git a/config/config-base.yaml b/config/config-base.yaml index 2a17342..1492f70 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -194,12 +194,10 @@ sections: value: Your account has been created. Click below to continue to Jellyfin. description: Displayed when a user creates an account. Use the "post-signup card" in the Message editor for more control. - - setting: url_base - name: Reverse Proxy subfolder - requires_restart: true - type: text - description: URL base for when running jfa-go with a reverse proxy in a subfolder. - include preceding /, e.g "/accounts". + - setting: subfolder_note + name: "Reverse proxy-ing to a subfolder?" + type: note + description: "Put the folder (e.g. /accounts) in \"Reverse Proxy subfolder\", and the full URL including it (e.g. https://jellyf.in/accounts) in \"External jfa-go URL\"." - setting: jfa_url name: External jfa-go URL required: true @@ -209,6 +207,12 @@ sections: description: The URL at which the jfa-go root (admin page) is accessible, including the subfolder if you use one. This is necessary because using a reverse proxy means the program has no way of knowing the URL itself. + - setting: url_base + name: Reverse Proxy subfolder + requires_restart: true + type: text + description: URL base for when running jfa-go with a reverse proxy in a subfolder. + include preceding /, e.g "/accounts". - setting: redirect_url name: Form success redirect URL type: text @@ -232,10 +236,14 @@ sections: - ["opaque", "Opaque"] value: clear description: Appearance of the Admin login screen. + - setting: urlpaths_note + name: "URL Paths:" + type: note + description: "Want \"My Account\" at \"/\" or the admin page at \"/admin\"? Enable advanced settings and check the \"URL Paths\" section." - section: url_paths meta: name: URL Paths - description: Settings for changing where different pages are accessed. + description: Settings for changing where different pages are accessed. If you change & forget these, they're printed in the logs on startup. Paths should have a slash at the beginning but not at the end. advanced: true settings: - setting: admin @@ -243,7 +251,7 @@ sections: type: text required: true requires_restart: true - value: "/" + value: "" description: URL subpath the admin page should be at. - setting: user_page name: "\"My Account\" subpath" diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index fbde1e1..9e8249b 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -212,9 +212,10 @@ const ( InitProxy = "Initialized proxy @ \"%s\"" FailedInitProxy = "Failed to initialize proxy @ \"%s\": %v\nStartup will pause for a bit to grab your attention." NoURLSuffix = `Warning: Given "jfa_url"/"External jfa-go URL" value does not include "url_base" value!` - BadURLBase = `Warning: Given URL Base "%s" may conflict with the applications subpaths.` + BadURLBase = `Warning: Given reverse proxy subfolder "%s" may conflict with the applications subpaths.` NoExternalHost = `No "External jfa-go URL" provided, set one in Settings > General.` LoginWontSave = ` Your login won't save until you do.` + SubpathBlockMessage = `URLs: Root subfolder = "%s", Admin = "%s", My Account = "%s", Invite forms = "%s"` // discord.go StartDaemon = "Started %s daemon" diff --git a/ts/admin.ts b/ts/admin.ts index 090a8e4..6ee213b 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -178,7 +178,6 @@ for (const tab of tabs) { } } // Default tab -// if ((window.URLBase + "/").includes(window.location.pathname)) { if (!matchedTab) { window.tabs.switch("", true); } diff --git a/ts/user.ts b/ts/user.ts index cceb19e..e087066 100644 --- a/ts/user.ts +++ b/ts/user.ts @@ -660,7 +660,7 @@ document.addEventListener("details-reload", () => { expiryCard.expiry = details.expiry; const adminBackButton = document.getElementById("admin-back-button") as HTMLAnchorElement; - adminBackButton.href = window.location.href.replace(window.pages.MyAccount, window.pages.Admin); + adminBackButton.href = window.pages.Base + window.pages.Admin; let messageCard = document.getElementById("card-message"); if (details.accounts_admin) { From f26042a21e673501ede83e7a16784626f027b2cc Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 14 May 2025 20:23:16 +0100 Subject: [PATCH 011/249] config: change base url-related text --- config/config-base.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/config-base.yaml b/config/config-base.yaml index 1492f70..a4a2f9a 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -204,7 +204,7 @@ sections: depends_true: enabled type: text value: http://accounts.jellyf.in:8056 - description: The URL at which the jfa-go root (admin page) is accessible, including + description: The URL at which the jfa-go root (usually the admin page) is accessible, including the subfolder if you use one. This is necessary because using a reverse proxy means the program has no way of knowing the URL itself. - setting: url_base @@ -1123,8 +1123,7 @@ sections: type: note depends_true: link_reset required: false - description: Set the "External jfa-go URL" in General so that links to jfa-go - can be made. + description: Set the "External jfa-go URL" in General so that links to jfa-go can be made. - setting: language name: Default reset link language requires_restart: true From acba411c3aeab5ca7e40860799b9b49c2817c750 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 14 May 2025 21:11:44 +0100 Subject: [PATCH 012/249] build: fix constant re-build of css tailwind command taking part-bundle and turning it into v3bundle didn't do anything when no changes occurred, so v3bundle kept on being left untouched, and therefore with an old timestamp. part-bundle and v3bundle are both deleted before CSS is built, so the tailwind command always generates a new file. --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1e4b2be..6e5b2e8 100644 --- a/Makefile +++ b/Makefile @@ -170,6 +170,7 @@ $(CSS_FULLTARGET): $(TYPESCRIPT_TARGET) $(VARIANTS_TARGET) $(ALL_CSS_SRC) $(wild $(info copying fonts) cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 $(DATA)/web/css/ $(info bundling css) + rm -f $(CSS_TARGET) $(CSS_FULLTARGET) $(ESBUILD) --bundle css/base.css --outfile=$(CSS_TARGET) --external:remixicon.css --external:../fonts/hanken* --minify npx tailwindcss -i $(CSS_TARGET) -o $(CSS_FULLTARGET) $(TAILWIND) @@ -206,7 +207,7 @@ $(COPY_TARGET): $(INLINE_TARGET) $(STATIC_SRC) $(LANG_SRC) $(CONFIG_BASE) cp -r lang $(DATA)/ cp LICENSE $(DATA)/ -BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) +BUILDDEPS := $(DATA) $(CONFIG_DEFAULT) $(EMAIL_TARGET) $(COPY_TARGET) $(SWAGGER_TARGET) $(INLINE_TARGET) $(CSS_FULLTARGET) $(TYPESCRIPT_TARGET) precompile: $(BUILDDEPS) COMPDEPS = From f1b56268bbd487b59d09da99641a091d3c8e91ad Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 14 May 2025 21:15:55 +0100 Subject: [PATCH 013/249] setup: fix url-based navigation popstate messages from the browser don't have an event.state, and i don't even know what the overridePopState stuff is trying to do. If event.state is null, try window.location.hash, or the last part of the URL path. --- ts/modules/pages.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ts/modules/pages.ts b/ts/modules/pages.ts index edf21f3..cc29c9a 100644 --- a/ts/modules/pages.ts +++ b/ts/modules/pages.ts @@ -32,7 +32,15 @@ export class PageManager { private _onpopstate = (event: PopStateEvent) => { let name = event.state; - if (!this.pages.has(event.state)) { + if (name == null) { + // Attempt to use hash from URL, if it isn't there, try the last part of the URL. + if (window.location.hash && window.location.hash.charAt(0) == "#") { + name = window.location.hash.substring(1); + } else { + name = window.location.pathname.split("/").filter(Boolean).at(-1); + } + } + if (!this.pages.has(name)) { name = this.pageList[0] } let success = this.pages.get(name).show(); @@ -73,7 +81,6 @@ export class PageManager { } loadPage (p: Page) { - console.log("loading page with", p.name || this.defaultName, p.title, p.url + window.location.search); window.history.pushState(p.name || this.defaultName, p.title, p.url + window.location.search); } From d2da9048d7cd3ce1a0ca5453ea19a754d19a67f2 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 14 May 2025 21:16:52 +0100 Subject: [PATCH 014/249] setup: add note about changed url just mentioned that you should check the URL before refreshing if you changed host/port/subfolder/etc. --- html/setup.html | 2 +- lang/setup/en-us.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/html/setup.html b/html/setup.html index ffed526..f4f8b02 100644 --- a/html/setup.html +++ b/html/setup.html @@ -557,7 +557,7 @@
{{ .lang.EndPage.finished }} -

{{ .lang.EndPage.restartMessage }}

+

{{ .lang.EndPage.restartMessage }} {{ .lang.EndPage.urlChangedNotice }}

-
+
{{ .lang.Language.title }}

@@ -53,7 +53,7 @@ {{ .lang.Strings.next }}
-
+
{{ .lang.General.title }}
@@ -153,7 +153,7 @@ {{ .lang.Strings.next }}
-
+
{{ .lang.Login.title }}

{{ .lang.Login.description }}

@@ -198,7 +198,7 @@ {{ .lang.Strings.next }}
-
+
{{ .lang.JellyfinEmby.title }}

{{ .lang.JellyfinEmby.description }}

@@ -249,7 +249,7 @@
-
+
{{ .lang.Ombi.title }}

{{ .lang.Ombi.description }}

@@ -291,7 +291,7 @@
-
+
{{ .lang.UserPage.title }}

{{ .lang.UserPage.description }}

@@ -308,7 +308,7 @@
-
+
{{ .lang.Messages.title }}

@@ -403,7 +403,7 @@
- - - -
+
{{ .lang.PasswordValidation.title }}

{{ .lang.PasswordValidation.description }}

@@ -521,7 +521,7 @@
-
+
{{ .lang.HelpMessages.title }}

{{ .lang.HelpMessages.description }}

@@ -554,10 +554,10 @@
-
+
{{ .lang.EndPage.finished }} -

{{ .lang.EndPage.restartMessage }} {{ .lang.EndPage.urlChangedNotice }}

+

{{ .lang.EndPage.restartMessage }} {{ .lang.EndPage.urlChangedNotice }}

diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 34955a7..19f285d 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -41,7 +41,9 @@ "delete": "Delete", "myAccount": "My Account", "referrals": "Referrals", - "inviteRemainingUses": "Remaining uses" + "inviteRemainingUses": "Remaining uses", + "internal": "Internal", + "external": "External" }, "notifications": { "errorLoginBlank": "The username and/or password were left blank.", diff --git a/lang/setup/en-us.json b/lang/setup/en-us.json index 55c13e7..b2ba28e 100644 --- a/lang/setup/en-us.json +++ b/lang/setup/en-us.json @@ -33,8 +33,9 @@ }, "endPage": { "finished": "Finished!", - "restartMessage": "Features like Discord/Telegram/Matrix bots, custom Markdown messages, and a user-accessible \"My Account\" page can be found in Settings, so make sure to give it a browse. Click below to restart, then refresh the page.", - "urlChangedNotice": "If you've changed the host, port, subfolder etc. that jfa-go is hosted on, check the URL is right.", + "moreFeatures": "Tons more features like Discord/Telegram/Matrix bots and custom Markdown messages can be found in Settings, so make sure to give it a browse.", + "restartReload": "Click below to restart, then access jfa-go at one of the given internal/external URLs.", + "ifFailedLoad": "If it doesn't load, check the application's logs for any clues as to why.", "refreshPage": "Refresh" }, "language": { diff --git a/ts/setup.ts b/ts/setup.ts index d987060..61470b1 100644 --- a/ts/setup.ts +++ b/ts/setup.ts @@ -365,6 +365,33 @@ const checkTheme = () => { settings["ui"]["theme"].onchange = checkTheme; checkTheme(); +const fixFullURL = (v: string): string => { + if (!(v.startsWith("http://")) && !(v.startsWith("https://"))) { + v = "http://" + v; + } + return v; +}; + +const formatSubpath = (v: string): string => { + if (v == "/") return ""; + if (v.charAt(-1) == "/") { v = v.slice(0, -1); } + return v; +} + +const constructNewURLs = (): string[] => { + let local = settings["ui"]["host"].value + ":" + settings["ui"]["port"].value; + if (settings["ui"]["url_base"].value != "") { + local += formatSubpath(settings["ui"]["url_base"].value); + } + local = fixFullURL(local); + let remote = settings["ui"]["jfa_url"].value; + if (remote == "") { + return [local]; + } + remote = fixFullURL(remote); + return [local, remote]; +} + const restartButton = document.getElementById("restart") as HTMLSpanElement; const serialize = () => { toggleLoader(restartButton); @@ -409,12 +436,16 @@ const serialize = () => { } restartButton.parentElement.querySelector("span.back").classList.add("unfocused"); restartButton.classList.add("unfocused"); - const refresh = document.getElementById("refresh") as HTMLSpanElement; - refresh.classList.remove("unfocused"); - refresh.onclick = () => { - let host = window.location.href.split("#")[0].split("?")[0] + settings["ui"]["url_base"].value; - window.location.href = host; - }; + const refreshURLs = constructNewURLs(); + const refreshButtons = [document.getElementById("refresh-internal") as HTMLAnchorElement, document.getElementById("refresh-external") as HTMLAnchorElement]; + ["internal", "external"].forEach((urltype, i) => { + const button = refreshButtons[i]; + button.classList.remove("unfocused"); + button.href = refreshURLs[i]; + button.innerHTML = `${urltype.charAt(0).toUpperCase() + urltype.slice(1)}:${button.href}`; + // skip external if it isn't set + if (refreshURLs.length == 1) return; + }); } }, true, (req: XMLHttpRequest) => { if (req.status == 0) { From 4cc5fd7189c8724bddc52b424dd38799058bf67f Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 May 2025 15:38:35 +0100 Subject: [PATCH 022/249] mediabrowser: bump for parental rating setting fixes #382. --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6cd8c7a..6b17a34 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc - github.com/hrfee/mediabrowser v0.3.24 + github.com/hrfee/mediabrowser v0.3.25 github.com/itchyny/timefmt-go v0.1.6 github.com/lithammer/shortuuid/v3 v3.0.7 github.com/mailgun/mailgun-go/v4 v4.18.1 diff --git a/go.sum b/go.sum index c263bc6..68f46b1 100644 --- a/go.sum +++ b/go.sum @@ -207,6 +207,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hrfee/mediabrowser v0.3.24 h1:cT5+X3bZeaSBQFevMYkFIw6JJ8nW7Myvb+11a2/THMA= github.com/hrfee/mediabrowser v0.3.24/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= +github.com/hrfee/mediabrowser v0.3.25 h1:UxpSSTmr5q12gKfeOR2ommvA/xhrP2rxVWmpWjDPRUY= +github.com/hrfee/mediabrowser v0.3.25/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= From 01a75c3e235dc7e5ed87733e82bc85b4f133e1fe Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 May 2025 16:14:15 +0100 Subject: [PATCH 023/249] settings: add jellyseerr wiki link, clarify API key src An API key is shown in Jellyseerr's setup which is actually for Jellyfin. I (and I imagine other users have) copied it expecting it was for Jellyseerr and was surprised the app didn't work. It's now clarified in the API Key setting description to get it from the first tab in Jellyseerr, and not the "Jellyfin" tab. --- config/config-base.yaml | 3 ++- go.sum | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/config/config-base.yaml b/config/config-base.yaml index a4a2f9a..16a021f 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1292,6 +1292,7 @@ sections: on account creation, and to automatically link contact methods (email, discord and telegram). A template must be added to a User Profile for accounts to be created. + wiki_link: https://wiki.jfa-go.com/docs/external-services/jellyseerr/ settings: - setting: enabled name: Enabled @@ -1318,7 +1319,7 @@ sections: requires_restart: true type: text depends_true: enabled - description: API Key. Get this from the first tab in Jellyseerr's settings. + description: API Key. Get this from the first tab in Jellyseerr's settings (NOT the "Jellyfin" tab!) - setting: import_existing name: Import existing users to Jellyseerr requires_restart: true diff --git a/go.sum b/go.sum index 68f46b1..c9c85b6 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,6 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hrfee/mediabrowser v0.3.24 h1:cT5+X3bZeaSBQFevMYkFIw6JJ8nW7Myvb+11a2/THMA= -github.com/hrfee/mediabrowser v0.3.24/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/hrfee/mediabrowser v0.3.25 h1:UxpSSTmr5q12gKfeOR2ommvA/xhrP2rxVWmpWjDPRUY= github.com/hrfee/mediabrowser v0.3.25/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= From 07d02f830245e652a29a369fb59dc5b63320851c Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 May 2025 17:49:06 +0100 Subject: [PATCH 024/249] discord: fix admin-check for /inv it was being checked in the EmailAddress record, only set if Jellyfin login is disabled, or "access jfa-go" is checked for a non-Jellyfin-admin user in Accounts. Instead, i've factored out the actual auth code into a "canAccessAdminPage"-ish function, which is called for this too. Should fix #378. --- auth.go | 43 +++++++++++++++++++++++++++++----------- discord.go | 13 ++++++++---- lang/telegram/en-us.json | 3 ++- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/auth.go b/auth.go index 541c97a..1296630 100644 --- a/auth.go +++ b/auth.go @@ -165,6 +165,31 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) return } +func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool { + // 1. "Allow all" is enabled, so simply being a user implies access. + if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" { + return true + } + // 2. You've been made an "accounts admin" from the accounts tab. + if emailStore.Admin { + return true + } + // 3. (Jellyfin) "Admins only" is enabled, and you're one. + if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator { + return true + } + return false +} + +func (app *appContext) canAccessAdminPageByID(jfID string) bool { + user, err := app.jf.UserByID(jfID, false) + if err != nil { + return false + } + emailStore, _ := app.storage.GetEmailsKey(jfID) + return app.canAccessAdminPage(user, emailStore) +} + func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) { ok = false user, err := app.authJf.Authenticate(username, password) @@ -220,18 +245,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) { return } jfID = user.ID - if !app.config.Section("ui").Key("allow_all").MustBool(false) { - accountsAdmin := false - adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) - if emailStore, ok := app.storage.GetEmailsKey(jfID); ok { - accountsAdmin = emailStore.Admin - } - accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator) - if !accountsAdmin { - app.authLog(fmt.Sprintf(lm.NonAdminUser, username)) - respond(401, "Unauthorized", gc) - return - } + emailStore, _ := app.storage.GetEmailsKey(jfID) + accountsAdmin := app.canAccessAdminPage(user, emailStore) + if !accountsAdmin { + app.authLog(fmt.Sprintf(lm.NonAdminUser, username)) + respond(401, "Unauthorized", gc) + return } // New users are only added when using jellyfinLogin. userID = shortuuid.New() diff --git a/discord.go b/discord.go index 96fa4bc..df34929 100644 --- a/discord.go +++ b/discord.go @@ -612,11 +612,16 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s //if mins > 0 { // expmin = mins //} - // Check whether requestor is linked to the admin account - requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID) - if !(ok && requesterEmail.Admin) { + // We want the same criteria for running this command as accessing the admin page (i.e. an "admin" of some sort) + if !(d.app.canAccessAdminPageByID(requester.JellyfinID)) { d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID)) - // FIXME: add response message + s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ + Type: dg.InteractionResponseChannelMessageWithSource, + Data: &dg.InteractionResponseData{ + Content: d.app.storage.lang.Telegram[lang].Strings.get("noPermission"), + Flags: 64, // Ephemeral + }, + }) return } diff --git a/lang/telegram/en-us.json b/lang/telegram/en-us.json index 4cb03a7..cd3c3ca 100644 --- a/lang/telegram/en-us.json +++ b/lang/telegram/en-us.json @@ -13,6 +13,7 @@ "languageSet": "Language set to {language}.", "discordDMs": "Please check your DMs for a response.", "sentInvite": "Sent invite.", - "sentInviteFailure": "Failed to send invite, check logs." + "sentInviteFailure": "Failed to send invite, check logs.", + "noPermission": "You do not have permissions for this action." } } From 28440a90960f2d7ce7f69cc83d26e33795bfc484 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 May 2025 19:19:51 +0100 Subject: [PATCH 025/249] accounts: add "record count", start searchable user cache RecordCounter class created from that in activityList, and put in accountsList. PageCount-type route standardized and made for /users (/users/count). Created userCache, which regularly generates the respUser list returned by /users. Added a currently dumb POST /users for searching/pagination, GET /users is now just for getting -all- users. go-getted expr, an expression language that seems like it'll be useful for evaluating local searches. We don't store this data in the badger DB, so we can't use the nice query form provided by badgerhold. --- api-activities.go | 6 +-- api-users.go | 62 +++++++++++++++++---- go.mod | 1 + go.sum | 2 + html/admin.html | 7 +-- main.go | 1 + models.go | 14 ++++- router.go | 2 + scripts/account-gen/main.go | 12 +++-- ts/modules/accounts.ts | 30 ++++++++--- ts/modules/activity.ts | 104 +++++++++++++++++++++++------------- ts/typings/d.ts | 4 ++ usercache.go | 35 ++++++++++++ 13 files changed, 213 insertions(+), 67 deletions(-) create mode 100644 usercache.go diff --git a/api-activities.go b/api-activities.go index f4b2581..5ff3b76 100644 --- a/api-activities.go +++ b/api-activities.go @@ -126,8 +126,8 @@ func (app *appContext) GetActivities(gc *gin.Context) { resp := GetActivitiesRespDTO{ Activities: make([]ActivityDTO, len(results)), - LastPage: len(results) != req.Limit, } + resp.LastPage = len(results) != req.Limit for i, act := range results { resp.Activities[i] = ActivityDTO{ @@ -173,12 +173,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) { // @Summary Returns the total number of activities stored in the database. // @Produce json -// @Success 200 {object} GetActivityCountDTO +// @Success 200 {object} PageCountDTO // @Router /activity/count [get] // @Security Bearer // @tags Activity func (app *appContext) GetActivityCount(gc *gin.Context) { - resp := GetActivityCountDTO{} + resp := PageCountDTO{} var err error resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{}) if err != nil { diff --git a/api-users.go b/api-users.go index 849bdfe..60934b1 100644 --- a/api-users.go +++ b/api-users.go @@ -337,8 +337,8 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey // FIXME: figure these out in a nicer way? this relies on the current ordering, // which may not be fixed. if discordEnabled { - if req.completeContactMethods[0].User != nil { - discordUser = req.completeContactMethods[0].User.(*DiscordUser) + if req.completeContactMethods[0].User != nil { + discordUser = req.completeContactMethods[0].User.(*DiscordUser) } if telegramEnabled && req.completeContactMethods[1].User != nil { telegramUser = req.completeContactMethods[1].User.(*TelegramUser) @@ -894,7 +894,25 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { } -// @Summary Get a list of Jellyfin users. +// @Summary Returns the total number of Jellyfin users. +// @Produce json +// @Success 200 {object} PageCountDTO +// @Router /users/count [get] +// @Security Bearer +// @tags Activity +func (app *appContext) GetUserCount(gc *gin.Context) { + resp := PageCountDTO{} + err := app.userCache.Gen(app) + if err != nil { + app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) + respond(500, "Couldn't get users", gc) + return + } + resp.Count = uint64(len(app.userCache.Cache)) + gc.JSON(200, resp) +} + +// @Summary Get a list of -all- Jellyfin users. // @Produce json // @Success 200 {object} getUsersDTO // @Failure 500 {object} stringResponse @@ -903,19 +921,43 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { // @tags Users func (app *appContext) GetUsers(gc *gin.Context) { var resp getUsersDTO - users, err := app.jf.GetUsers(false) - resp.UserList = make([]respUser, len(users)) + // We're sending all users, so this is always true + resp.LastPage = true + err := app.userCache.Gen(app) if err != nil { app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) respond(500, "Couldn't get users", gc) return } - i := 0 - for _, jfUser := range users { - user := app.userSummary(jfUser) - resp.UserList[i] = user - i++ + resp.UserList = app.userCache.Cache + gc.JSON(200, resp) +} + +// @Summary Get a paginated, searchable list of Jellyfin users. +// @Produce json +// @Param getUsersReqDTO body getUsersReqDTO true "search / pagination parameters" +// @Success 200 {object} getUsersDTO +// @Failure 500 {object} stringResponse +// @Router /users [post] +// @Security Bearer +// @tags Users +func (app *appContext) SearchUsers(gc *gin.Context) { + req := getUsersReqDTO{} + gc.BindJSON(&req) + + // FIXME: Figure out how to search, sort and paginate []mediabrowser.User! + // Expr! + + var resp getUsersDTO + // We're sending all users, so this is always true + resp.LastPage = true + err := app.userCache.Gen(app) + if err != nil { + app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) + respond(500, "Couldn't get users", gc) + return } + resp.UserList = app.userCache.Cache gc.JSON(200, resp) } diff --git a/go.mod b/go.mod index 6b17a34..eb0fcb0 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgraph-io/ristretto v1.0.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/expr-lang/expr v1.17.3 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/errors v1.0.4 // indirect diff --git a/go.sum b/go.sum index c9c85b6..a12698d 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/expr-lang/expr v1.17.3 h1:myeTTuDFz7k6eFe/JPlep/UsiIjVhG61FMHFu63U7j0= +github.com/expr-lang/expr v1.17.3/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= diff --git a/html/admin.html b/html/admin.html index 25630bd..72953a0 100644 --- a/html/admin.html +++ b/html/admin.html @@ -738,6 +738,7 @@
+
{{ .strings.actions }}
{{ .quantityStrings.addUser.Singular }} @@ -838,11 +839,7 @@
-
- - - -
+
diff --git a/main.go b/main.go index 7d1de5a..cb6d2c4 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,7 @@ type appContext struct { pwrCaptchas map[string]Captcha ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request confirmationKeysLock sync.Mutex + userCache UserCache } func generateSecret(length int) (string, error) { diff --git a/models.go b/models.go index 686effd..360a2e2 100644 --- a/models.go +++ b/models.go @@ -164,8 +164,18 @@ type respUser struct { ReferralsEnabled bool `json:"referrals_enabled"` } +type PaginatedDTO struct { + LastPage bool `json:"last_page"` +} + +type getUsersReqDTO struct { + Limit int `json:"limit"` + Page int `json:"page"` // zero-indexed +} + type getUsersDTO struct { UserList []respUser `json:"users"` + LastPage bool `json:"last_page"` } type ombiUser struct { @@ -437,11 +447,11 @@ type GetActivitiesDTO struct { } type GetActivitiesRespDTO struct { + PaginatedDTO Activities []ActivityDTO `json:"activities"` - LastPage bool `json:"last_page"` } -type GetActivityCountDTO struct { +type PageCountDTO struct { Count uint64 `json:"count"` } diff --git a/router.go b/router.go index 9ab3530..4fa3952 100644 --- a/router.go +++ b/router.go @@ -183,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { router.POST(p+"/logout", app.Logout) api.DELETE(p+"/users", app.DeleteUsers) api.GET(p+"/users", app.GetUsers) + api.GET(p+"/users/count", app.GetUserCount) + api.POST(p+"/users", app.SearchUsers) api.POST(p+"/user", app.NewUserFromAdmin) api.POST(p+"/users/extend", app.ExtendExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) diff --git a/scripts/account-gen/main.go b/scripts/account-gen/main.go index bc1c528..47faa6f 100644 --- a/scripts/account-gen/main.go +++ b/scripts/account-gen/main.go @@ -15,11 +15,11 @@ import ( var ( names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab"} + COUNT = 3000 ) const ( PASSWORD = "test" - COUNT = 10 ) func main() { @@ -57,6 +57,12 @@ func main() { password = strings.TrimSuffix(password, "\n") } + if countEnv := os.Getenv("COUNT"); countEnv != "" { + COUNT, _ = strconv.Atoi(countEnv) + } + + fmt.Printf("Will generate %d users\n", COUNT) + jf, err := mediabrowser.NewServer( mediabrowser.JellyfinServer, server, @@ -99,7 +105,7 @@ func main() { user, status, err := jf.NewUser(name, PASSWORD) if (status != 200 && status != 201 && status != 204) || err != nil { - log.Fatalf("Failed to create user \"%s\" (%d): %+v\n", name, status, err) + log.Fatalf("Acc no %d: Failed to create user \"%s\" (%d): %+v\n", i, name, status, err) } if rand.Intn(100) > 65 { @@ -112,7 +118,7 @@ func main() { status, err = jf.SetPolicy(user.ID, user.Policy) if (status != 200 && status != 201 && status != 204) || err != nil { - log.Fatalf("Failed to set policy for user \"%s\" (%d): %+v\n", name, status, err) + log.Fatalf("Acc no %d: Failed to set policy for user \"%s\" (%d): %+v\n", i, name, status, err) } if rand.Intn(100) > 20 { diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index c338bfc..2bc902d 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -5,6 +5,7 @@ import { stripMarkdown } from "../modules/stripmd.js"; import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; import { HiddenInputField } from "./ui.js"; +import { RecordCounter } from "./activity.js"; declare var window: GlobalWindow; @@ -702,7 +703,11 @@ class user implements User, SearchableItem { } this._row.remove(); } -} +} + +interface UsersDTO extends paginatedDTO { + users: User[]; +} export class accountsList { private _table = document.getElementById("accounts-list") as HTMLTableSectionElement; @@ -771,6 +776,8 @@ export class accountsList { private _filterArea = document.getElementById("accounts-filter-area"); private _searchOptionsHeader = document.getElementById("accounts-search-options-header"); + private _counter: RecordCounter; + // Whether the "Extend expiry" is extending or setting an expiry. private _settingExpiry = false; @@ -1779,6 +1786,9 @@ export class accountsList { constructor() { this._populateNumbers(); + + this._counter = new RecordCounter(document.getElementById("accounts-record-counter")); + this._users = {}; this._selectAll.checked = false; this._selectAll.onchange = () => { @@ -2035,12 +2045,16 @@ export class accountsList { } reload = (callback?: () => void) => { + this._counter.reset() + this._counter.getTotal("/users/count"); + _get("/users", null, (req: XMLHttpRequest) => { if (req.readyState == 4 && req.status == 200) { + let resp = req.response as UsersDTO; // same method as inviteList.reload() let accountsOnDOM: { [id: string]: boolean } = {}; for (let id in this._users) { accountsOnDOM[id] = true; } - for (let u of (req.response["users"] as User[])) { + for (let u of resp.users) { if (u.id in this._users) { this._users[u.id].update(u); delete accountsOnDOM[u.id]; @@ -2055,10 +2069,10 @@ export class accountsList { // console.log("reload, so sorting by", this._activeSortColumn); this._ordering = this._columns[this._activeSortColumn].sort(this._users); this._search.ordering = this._ordering; - if (!(this._search.inSearch)) { - this.setVisibility(this._ordering, true); - this._notFoundPanel.classList.add("unfocused"); - } else { + + this._counter.loaded = this._ordering.length; + + if (this._search.inSearch) { const results = this._search.search(this._searchBox.value); if (results.length == 0) { this._notFoundPanel.classList.remove("unfocused"); @@ -2066,6 +2080,10 @@ export class accountsList { this._notFoundPanel.classList.add("unfocused"); } this.setVisibility(results, true); + } else { + this._counter.shown = this._counter.loaded; + this.setVisibility(this._ordering, true); + this._notFoundPanel.classList.add("unfocused"); } this._checkCheckCount(); diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 135d50b..59ed865 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -346,9 +346,64 @@ export class Activity implements activity, SearchableItem { asElement = () => { return this._card; }; } -interface ActivitiesDTO { +export class RecordCounter { + private _container: HTMLElement; + private _totalRecords: HTMLElement; + private _loadedRecords: HTMLElement; + private _shownRecords: HTMLElement; + private _total: number; + private _loaded: number; + private _shown: number; + constructor(container: HTMLElement) { + this._container = container; + this._container.innerHTML = ` + + + + `; + this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement; + this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement; + this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement; + this.total = 0; + this.loaded = 0; + this.shown = 0; + } + + reset() { + this.total = 0; + this.loaded = 0; + this.shown = 0; + } + + // Sets the total using a PageCountDTO-returning API endpoint. + getTotal(endpoint: string) { + _get(endpoint, null, (req: XMLHttpRequest) => { + if (req.readyState != 4 || req.status != 200) return; + this.total = req.response["count"] as number; + }); + } + + get total(): number { return this._total; } + set total(v: number) { + this._total = v; + this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); + } + + get loaded(): number { return this._loaded; } + set loaded(v: number) { + this._loaded = v; + this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); + } + + get shown(): number { return this._shown; } + set shown(v: number) { + this._shown = v; + this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); + } +} + +interface ActivitiesDTO extends paginatedDTO { activities: activity[]; - last_page: boolean; } export class activityList { @@ -368,31 +423,7 @@ export class activityList { private _keepSearchingDescription = document.getElementById("activity-keep-searching-description"); private _keepSearchingButton = document.getElementById("activity-keep-searching"); - private _totalRecords = document.getElementById("activity-total-records"); - private _loadedRecords = document.getElementById("activity-loaded-records"); - private _shownRecords = document.getElementById("activity-shown-records"); - - private _total: number; - private _loaded: number; - private _shown: number; - - get total(): number { return this._total; } - set total(v: number) { - this._total = v; - this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); - } - - get loaded(): number { return this._loaded; } - set loaded(v: number) { - this._loaded = v; - this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); - } - - get shown(): number { return this._shown; } - set shown(v: number) { - this._shown = v; - this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); - } + private _counter: RecordCounter; private _search: Search; private _ascending: boolean; @@ -421,9 +452,8 @@ export class activityList { this._loadAllButton.classList.remove("unfocused"); this._loadAllButton.disabled = false; - this.total = 0; - this.loaded = 0; - this.shown = 0; + this._counter.reset(); + this._counter.getTotal("/activity/count"); // this._page = 0; let limit = 10; @@ -438,10 +468,6 @@ export class activityList { "ascending": this.ascending } - _get("/activity/count", null, (req: XMLHttpRequest) => { - if (req.readyState != 4 || req.status != 200) return; - this.total = req.response["count"] as number; - }); _post("/activity", send, (req: XMLHttpRequest) => { if (req.readyState != 4) return; @@ -467,13 +493,13 @@ export class activityList { this._search.items = this._activities; this._search.ordering = this._ordering; - this.loaded = this._ordering.length; + this._counter.loaded = this._ordering.length; if (this._search.inSearch) { this._search.onSearchBoxChange(true); this._loadAllButton.classList.remove("unfocused"); } else { - this.shown = this.loaded; + this._counter.shown = this._counter.loaded; this.setVisibility(this._ordering, true); this._loadAllButton.classList.add("unfocused"); this._notFoundPanel.classList.add("unfocused"); @@ -526,7 +552,7 @@ export class activityList { // this._search.items = this._activities; // this._search.ordering = this._ordering; - this.loaded = this._ordering.length; + this._counter.loaded = this._ordering.length; if (this._search.inSearch || loadAll) { if (this._lastPage) { @@ -699,6 +725,8 @@ export class activityList { this._activityList = document.getElementById("activity-card-list"); document.addEventListener("activity-reload", this.reload); + this._counter = new RecordCounter(document.getElementById("activity-record-counter")); + let conf: SearchConfiguration = { filterArea: this._filterArea, sortingByButton: this._sortingByButton, @@ -711,7 +739,7 @@ export class activityList { filterList: document.getElementById("activity-filter-list"), // notFoundCallback: this._notFoundCallback, onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => { - this.shown = visibleCount; + this._counter.shown = visibleCount; if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused"); else this._loadAllButton.classList.add("unfocused"); diff --git a/ts/typings/d.ts b/ts/typings/d.ts index b19a1c7..a4f7f80 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -155,5 +155,9 @@ interface inviteList { // submitter: HTMLInputElement; // } +interface paginatedDTO { + last_page: boolean; +} + declare var config: Object; declare var modifiedConfig: Object; diff --git a/usercache.go b/usercache.go new file mode 100644 index 0000000..68af907 --- /dev/null +++ b/usercache.go @@ -0,0 +1,35 @@ +package main + +import ( + "sync" + "time" +) + +const ( + // FIXME: Follow mediabrowser, or make tuneable, or both + WEB_USER_CACHE_SYNC = 30 * time.Second +) + +type UserCache struct { + Cache []respUser + LastSync time.Time + Lock sync.Mutex +} + +func (c *UserCache) Gen(app *appContext) error { + if !time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) { + return nil + } + users, err := app.jf.GetUsers(false) + if err != nil { + return err + } + c.Lock.Lock() + c.Cache = make([]respUser, len(users)) + for i, jfUser := range users { + c.Cache[i] = app.userSummary(jfUser) + } + c.LastSync = time.Now() + c.Lock.Unlock() + return nil +} From 3067db9c3113c1cba41f52c3ae4dad35740d7e28 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 15 May 2025 20:08:08 +0100 Subject: [PATCH 026/249] usercache: we'll do it ourselves we don't need expr or anything like that, cmp.Less and vim macros exist. --- go.mod | 1 - go.sum | 2 - usercache.go | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index eb0fcb0..6b17a34 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,6 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgraph-io/ristretto v1.0.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/expr-lang/expr v1.17.3 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect github.com/getlantern/errors v1.0.4 // indirect diff --git a/go.sum b/go.sum index a12698d..c9c85b6 100644 --- a/go.sum +++ b/go.sum @@ -58,8 +58,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/expr-lang/expr v1.17.3 h1:myeTTuDFz7k6eFe/JPlep/UsiIjVhG61FMHFu63U7j0= -github.com/expr-lang/expr v1.17.3/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= diff --git a/usercache.go b/usercache.go index 68af907..c04b600 100644 --- a/usercache.go +++ b/usercache.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "sync" "time" ) @@ -17,6 +18,7 @@ type UserCache struct { } func (c *UserCache) Gen(app *appContext) error { + c.Lock.Lock() if !time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) { return nil } @@ -24,7 +26,6 @@ func (c *UserCache) Gen(app *appContext) error { if err != nil { return err } - c.Lock.Lock() c.Cache = make([]respUser, len(users)) for i, jfUser := range users { c.Cache[i] = app.userSummary(jfUser) @@ -33,3 +34,116 @@ func (c *UserCache) Gen(app *appContext) error { c.Lock.Unlock() return nil } + +type SortableUserList struct { + Cache []respUser + lessFunc func(a, b *respUser) bool +} + +func (sc *SortableUserList) Len() int { + return len(sc.Cache) +} + +func (sc *SortableUserList) Swap(i, j int) { + sc.Cache[i], sc.Cache[j] = sc.Cache[j], sc.Cache[i] +} + +func (sc *SortableUserList) Less(i, j int) bool { + return sc.lessFunc(&sc.Cache[i], &sc.Cache[j]) +} + +// instead of making a Less for bools, just convert them to integers +// https://0x0f.me/blog/golang-compiler-optimization/ +func bool2int(b bool) int { + var i int + if b { + i = 1 + } else { + i = 0 + } + return i +} + +// Allow sorting by respUser's struct fields (well, it's JSON-representation's fields) +// Ugly I know, but at least cmp.Less exists. +// Done with vim macros, thank god they exist +func SortUsersBy(u []respUser, field string) SortableUserList { + s := SortableUserList{Cache: u} + + switch field { + case "id": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.ID, b.ID) + } + + case "name": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.Name, b.Name) + } + case "email": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.Email, b.Email) + } + case "notify_email": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.NotifyThroughEmail), bool2int(b.NotifyThroughEmail)) + } + case "last_active": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.LastActive, b.LastActive) + } + case "admin": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.Admin), bool2int(b.Admin)) + } + case "expiry": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.Expiry, b.Expiry) + } + case "disabled": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.Disabled), bool2int(b.Disabled)) + } + case "telegram": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.Telegram, b.Telegram) + } + case "notify_telegram": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.NotifyThroughTelegram), bool2int(b.NotifyThroughTelegram)) + } + case "discord": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.Discord, b.Discord) + } + case "discord_id": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.DiscordID, b.DiscordID) + } + case "notify_discord": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.NotifyThroughDiscord), bool2int(b.NotifyThroughDiscord)) + } + case "matrix": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.Matrix, b.Matrix) + } + case "notify_matrix": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.NotifyThroughMatrix), bool2int(b.NotifyThroughMatrix)) + } + case "label": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(a.Label, b.Label) + } + case "accounts_admin": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.AccountsAdmin), bool2int(b.AccountsAdmin)) + } + case "referrals_enabled": + s.lessFunc = func(a, b *respUser) bool { + return cmp.Less(bool2int(a.ReferralsEnabled), bool2int(b.ReferralsEnabled)) + } + } + return s +} From c5683dbc71e0a1d06568e8b2b29e6d6ec1a9d8bc Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Fri, 16 May 2025 16:50:13 +0100 Subject: [PATCH 027/249] search: factor out date and bool comparison --- api-users.go | 13 +- ts/modules/search.ts | 287 ++++++++++++++++++++++++++++++------------- usercache.go | 21 +++- 3 files changed, 226 insertions(+), 95 deletions(-) diff --git a/api-users.go b/api-users.go index 60934b1..38149f0 100644 --- a/api-users.go +++ b/api-users.go @@ -902,13 +902,13 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser { // @tags Activity func (app *appContext) GetUserCount(gc *gin.Context) { resp := PageCountDTO{} - err := app.userCache.Gen(app) + userList, err := app.userCache.Gen(app) if err != nil { app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) respond(500, "Couldn't get users", gc) return } - resp.Count = uint64(len(app.userCache.Cache)) + resp.Count = uint64(len(userList)) gc.JSON(200, resp) } @@ -923,13 +923,14 @@ func (app *appContext) GetUsers(gc *gin.Context) { var resp getUsersDTO // We're sending all users, so this is always true resp.LastPage = true - err := app.userCache.Gen(app) + var err error + resp.UserList, err = app.userCache.Gen(app) if err != nil { app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) respond(500, "Couldn't get users", gc) return } - resp.UserList = app.userCache.Cache + app.debug.Printf("sending usercache of length %d", len(resp.UserList)) gc.JSON(200, resp) } @@ -951,13 +952,13 @@ func (app *appContext) SearchUsers(gc *gin.Context) { var resp getUsersDTO // We're sending all users, so this is always true resp.LastPage = true - err := app.userCache.Gen(app) + var err error + resp.UserList, err = app.userCache.Gen(app) if err != nil { app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) respond(500, "Couldn't get users", gc) return } - resp.UserList = app.userCache.Cache gc.JSON(200, resp) } diff --git a/ts/modules/search.ts b/ts/modules/search.ts index fa8d1e1..a0a446a 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -2,6 +2,23 @@ const dateParser = require("any-date-parser"); declare var window: GlobalWindow; +export enum QueryOperator { + Greater = ">", + Lower = "<", + Equal = "=" +} + +export function QueryOperatorToDateText(op: QueryOperator): string { + switch (op) { + case QueryOperator.Greater: + return window.lang.strings("after"); + case QueryOperator.Lower: + return window.lang.strings("before"); + default: + return ""; + } +} + export interface QueryType { name: string; description?: string; @@ -28,6 +45,172 @@ export interface SearchConfiguration { loadMore?: () => void; } +export abstract class Query { + protected _subject: QueryType; + protected _operator: QueryOperator; + protected _card: HTMLElement; + + constructor(subject: QueryType, operator: QueryOperator) { + this._subject = subject; + this._operator = operator; + this._card = document.createElement("span"); + this._card.ariaLabel = window.lang.strings("clickToRemoveFilter"); + } + + set onclick(v: () => void) { + this._card.addEventListener("click", v); + } + + asElement(): HTMLElement { return this._card; } +} + + +export class BoolQuery extends Query { + protected _value: boolean; + constructor(subject: QueryType, value: boolean) { + super(subject, QueryOperator.Equal); + this._value = value; + this._card.classList.add("button", "~" + (this._value ? "positive" : "critical"), "@high", "center", "mx-2", "h-full"); + this._card.innerHTML = ` + ${subject.name} + + `; + } + + public static paramsFromString(valueString: string): [boolean, boolean] { + let isBool = false; + let boolState = false; + if (valueString == "true" || valueString == "yes" || valueString == "t" || valueString == "y") { + isBool = true; + boolState = true; + } else if (valueString == "false" || valueString == "no" || valueString == "f" || valueString == "n") { + isBool = true; + boolState = false; + } + return [boolState, isBool] + } + + get value(): boolean { return this._value; } + + // Ripped from old code. Why it's like this, I don't know + public compare(subjectBool: boolean): boolean { + return ((subjectBool && this._value) || (!subjectBool && !this._value)) + } +} + +export class StringQuery extends Query { + protected _value: string; + constructor(subject: QueryType, value: string) { + super(subject, QueryOperator.Equal); + this._value = value; + this._card.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full"); + this._card.innerHTML = ` + ${subject.name}: "${this._value}" + `; + } + + get value(): string { return this._value; } +} + +export interface DateAttempt { + year?: number; + month?: number; + day?: number; + hour?: number; + minute?: number +} + +export interface ParsedDate { + attempt: DateAttempt; + date: Date; + text: string; +}; + +const dateGetters: Map number> = (() => { + let m = new Map number>(); + m.set("year", Date.prototype.getFullYear); + m.set("month", Date.prototype.getMonth); + m.set("day", Date.prototype.getDate); + m.set("hour", Date.prototype.getHours); + m.set("minute", Date.prototype.getMinutes); + return m; +})(); +const dateSetters: Map void> = (() => { + let m = new Map void>(); + m.set("year", Date.prototype.setFullYear); + m.set("month", Date.prototype.setMonth); + m.set("day", Date.prototype.setDate); + m.set("hour", Date.prototype.setHours); + m.set("minute", Date.prototype.setMinutes); + return m; +})(); + +export class DateQuery extends Query { + protected _value: ParsedDate; + + constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) { + super(subject, operator); + this._value = value; + console.log("op:", operator, "date:", value); + this._card.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full"); + let dateText = QueryOperatorToDateText(operator); + this._card.innerHTML = ` + ${subject.name}: ${dateText != "" ? dateText+" " : ""}${value.text} + `; + } + public static paramsFromString(valueString: string): [ParsedDate, QueryOperator, boolean] { + // FIXME: Validate this! + let op = QueryOperator.Equal; + if ((Object.values(QueryOperator) as string[]).includes(valueString.charAt(0))) { + op = valueString.charAt(0) as QueryOperator; + // Trim the operator from the string + valueString = valueString.substring(1); + } + + let out: ParsedDate = { + text: valueString, + // Used just to tell use what fields the user passed. + attempt: dateParser.attempt(valueString), + // note Date.fromString is also provided by dateParser. + date: (Date as any).fromString(valueString) as Date + }; + // Month in Date objects is 0-based, so make our parsed date that way too + if ("month" in out.attempt) out.attempt.month -= 1; + let isValid = true; + if ("invalid" in (out.date as any)) { isValid = false; }; + + return [out, op, isValid]; + } + + get value(): ParsedDate { return this._value; } + + public compare(subjectDate: Date): boolean { + // We want to compare only the fields given in this._value, + // so we copy subjectDate and apply on those fields from this._value. + const temp = new Date(subjectDate.valueOf()); + for (let [field] of dateGetters) { + if (field in this._value.attempt) { + dateSetters.get(field).call( + temp, + dateGetters.get(field).call(this._value.date) + ); + } + } + + if (this._operator == QueryOperator.Equal) { + return subjectDate.getTime() == temp.getTime(); + } else if (this._operator == QueryOperator.Lower) { + return subjectDate < temp; + } + return subjectDate > temp; + } +} + + +// FIXME: Continue taking stuff from search function, making XQuery classes! + + + export interface SearchableItem { matchesSearch: (query: string) => boolean; } @@ -99,33 +282,20 @@ export class Search { const queryFormat = this._c.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} - - `; + let formattedQuery = [] - filterCard.addEventListener("click", () => { + if (queryFormat.bool) { + let [boolState, isBool] = BoolQuery.paramsFromString(split[1]); + if (isBool) { + let q = new BoolQuery(queryFormat, boolState); + q.onclick = () => { for (let quote of [`"`, `'`, ``]) { this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, ""); } this._c.search.oninput((null as Event)); - }) + }; - this._c.filterArea.appendChild(filterCard); + this._c.filterArea.appendChild(q.asElement()); // console.log("is bool, state", boolState); // So removing elements doesn't affect us @@ -135,7 +305,7 @@ export class Search { const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u); // console.log("got", queryFormat.getter + ":", value); // Remove from result if not matching query - if (!((value && boolState) || (!value && !boolState))) { + if (!q.compare(value)) { // console.log("not matching, result is", result); result.splice(result.indexOf(id), 1); } @@ -144,22 +314,17 @@ export class Search { } } 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]}" - `; + const q = new StringQuery(queryFormat, split[1]); - filterCard.addEventListener("click", () => { + q.onclick = () => { for (let quote of [`"`, `'`, ``]) { let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig"); this._c.search.value = this._c.search.value.replace(regex, ""); } this._c.search.oninput((null as Event)); - }) + } - this._c.filterArea.appendChild(filterCard); + this._c.filterArea.appendChild(q.asElement()); let cachedResult = [...result]; for (let id of cachedResult) { @@ -172,39 +337,20 @@ export class Search { 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]} - `; + let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]); + if (!isDate) continue; + const q = new DateQuery(queryFormat, op, parsedDate); - filterCard.addEventListener("click", () => { + q.onclick = () => { for (let quote of [`"`, `'`, ``]) { - let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig"); + let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig"); this._c.search.value = this._c.search.value.replace(regex, ""); } this._c.search.oninput((null as Event)); - }) + } - this._c.filterArea.appendChild(filterCard); + this._c.filterArea.appendChild(q.asElement()); let cachedResult = [...result]; for (let id of cachedResult) { @@ -215,33 +361,8 @@ export class Search { 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 <
-
+
+
+ + {{ .strings.searchAllRecords }} +
+
@@ -825,10 +839,14 @@ -
+
- +
+ + {{ .strings.searchAllRecords }} +
+
-
+